Compare commits

..

3 Commits

Author SHA1 Message Date
codex-bot 71d3cf0590 Verify incremental coverage rejects a concurrent parent update
Metadata integrity checks / metadata (push) Successful in 1m18s
2026-11-08 17:33:59 +08:00
codex-bot ee140cfd40 Preserve source coverage when importing incremental metadata batches
Metadata integrity checks / metadata (push) Successful in 1m18s
2026-11-08 17:31:12 +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
19 changed files with 389 additions and 617 deletions
-2
View File
@@ -58,7 +58,5 @@ jobs:
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert {'COLM','MM','MLSys'} <= {v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}; s=Store('installed.sqlite'); s.conn.close()"
subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','query','--db','installed.sqlite','--stats'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','annotate','init','--db','installed.sqlite'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','annotate','list','--db','installed.sqlite','--paper','1'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','audit','histories','--db','installed.sqlite','--examples','0'],cwd=outside,check=True)
PY
-2
View File
@@ -86,5 +86,3 @@ paper-atlas collect colm --cache /data/paper-index/raw/http-cache \
CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目录外验证安装后的命令及随包资源。数据库全量去重和覆盖率是独立的数据验收步骤,不因代码测试通过而自动完成。
开发通过工作分支和 PR 交付,由用户手动合并。
研究类型与自定义标签可通过 [`annotate`](docs/annotations.md) 显式初始化、添加和撤回;两者独立查询,保留证据及完整更正历史,不从会议轨道推断研究类型。
-107
View File
@@ -1,107 +0,0 @@
# Research types and custom annotations
Research type describes a claimed characteristic of the work (for example a survey,
benchmark, empirical study or replication). It is independent of topic, paper length,
conference track, presentation format and acceptance status. No type is inferred from
those fields. Multiple source-specific or human classifications may coexist; no
cross-platform vocabulary mapping or automatic classifier is provided.
`annotation_events` stores both kinds with an explicit `kind` discriminator:
`research_type` and `tag` are separate domains, even when namespace/value strings
happen to coincide. Existing platform labels remain in `tags` / `paper_tags`.
Each assertion carries namespace, value, actor, reason, evidence key, optional source
record and evidence URL, and the time it was recorded. This is observation time, not
paper publication time or a conference decision date.
Values are not forced into a global taxonomy: `custom:method=empirical` and
`platform:declared_type=empirical` retain separate provenance. An explicit platform
assertion API requires a source record belonging to that paper and matching the
namespace prefix. The manual CLI only accepts `custom:*`, and cannot retract platform
assertions. No source type has been automatically assigned to existing papers.
## Initialize explicitly
On the published PostgreSQL dataset, using its protected service file:
```sh
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas annotate init \
--pg-service foundry-research --pg-schema research
```
This creates one optional table, its indexes, and an upgrade marker in one transaction,
using the existing dataset writer lock. It does not copy data, change existing papers
or decisions, or create a PostgreSQL instance. Repeating it is safe; an unexplained
pre-existing table/marker mismatch fails closed. Readers and ordinary imports do not
install the extension. Legacy datasets remain readable. A SQLite **sample** uses
`--db sample.sqlite`; the cutover SQLite backup remains protected against writes.
## Add and retract evidence
Use the canonical paper ID returned by `query`. For a reviewed classification:
```sh
paper-atlas annotate assert --pg-service foundry-research --pg-schema research \
--paper "$PAPER_ID" --kind research_type --namespace custom:method \
--value empirical --evidence-key manual:abstract-review \
--actor reviewer --reason 'Classified from the explicitly described study design' \
--request-id "$REQUEST_ID"
```
`$REQUEST_ID` must be a new, stable identifier for this action (for example a UUID).
Retry with the same request ID and identical content after an interrupted response;
it returns the original event ID. Reusing that ID with changed content is rejected.
A topic annotation uses `--kind tag --namespace custom:topic --value robotics` instead.
Optional `--source-record` and `--evidence-url` attach additional evidence; the source
record must belong to this paper. URLs are retained as evidence and are not fetched.
To withdraw one particular assertion, use its returned event ID:
```sh
paper-atlas annotate retract --pg-service foundry-research --pg-schema research \
--event "$EVENT_ID" --actor reviewer --reason 'Classification corrected after review' \
--request-id "$RETRACTION_REQUEST_ID"
```
Retraction adds an event referencing that assertion, preserving its original content.
It does not remove another contributor's assertion of the same label, platform tags,
source metadata or submission/decision history. A retracted assertion can be asserted
again with a new request ID and evidence; it is a new observation. The API does not
update or delete past annotation events. Database administrators still control direct
SQL access. Work reviewed as the same paper may subsequently be merged by the existing
identity workflow: annotation IDs, original paper IDs, evidence and retraction links
are retained while the canonical paper reference follows the surviving entity.
## Query and analyze
```sh
paper-atlas query --pg-service foundry-research --pg-schema research \
--research-type custom:method=empirical
paper-atlas query --pg-service foundry-research --pg-schema research \
--tag custom:topic=robotics
paper-atlas query --pg-service foundry-research --pg-schema research \
--paper "$PAPER_ID" --annotation-history
paper-atlas annotate list --pg-service foundry-research --pg-schema research \
--paper "$PAPER_ID" --history
```
`--tag` includes original platform labels and active annotation tags. It never matches
research types. `--research-type` matches active type assertions only. Paper details
return separate `tags` (existing provenance), `annotation_tags`, and `research_types`;
`--annotation-history` additionally includes retractions. A missing extension yields
empty annotation results rather than changing the database.
For direct SQL analysis, active research-type evidence is:
```sql
SELECT a.* FROM research.annotation_events a
WHERE a.kind = 'research_type' AND a.retracts_id IS NULL
AND NOT EXISTS (SELECT 1 FROM research.annotation_events r WHERE r.retracts_id = a.id);
```
Several independent assertions can support one label; count distinct papers/labels
when appropriate rather than counting evidence rows as papers. Namespaces stay
separate. Standard paper queries decode the PostgreSQL text codec; direct SQL consumers
must honor the dataset's documented codec, including NUL/SOH values. SQLite-to-PostgreSQL
migration recognizes an installed annotation extension and verifies its full row hashes,
constraints and identity sequence along with the original business tables. An old
21-table snapshot stays a 21-table migration; no synthetic annotations are generated.
+33
View File
@@ -120,3 +120,36 @@ paper-atlas maintain pmlr-tracks --db /data/paper-index/papers.sqlite \
检查报告后,在同一命令添加 `--apply` 并使用新的 `--out` 路径执行迁移。计划使用共享维护锁,实际迁移使用独占锁;已有 arXiv 类别修复或其他写入运行时会拒绝启动,不会停止或重启它们。一次事务内核对整个来源集合、原始题目/摘要/作者、forum 身份、已有事件及目标冲突,更新原投稿的 edition 和身份归属,回填官方轨道出处及逐轨道覆盖数,最后复查轨道与外键后提交。论文、来源、投稿、决定的 ID 及决定观察均保留,不重新创建事件、不做实体合并、不改论文正文元数据或标签。
报告记录每个迁移事件的旧 edition、原身份、完整决定观察及新轨道证据。`planned` 是未执行的计划;`complete` 才表示事务已提交;`error` 表示执行失败。若进程在提交前后中断而报告停在 `prepared`,先对照主库和报告核查,不根据日志缺失自行重启任务。新路径重跑会重新核对当前数据库,已正确的轨道不会再次迁移。共享多个来源、重复目标事件或冲突决定会停止迁移,需另行核对,不能直接删掉其中一条。
## 追加同一目录的部分记录
默认 `import records` 把 manifest 当成一份来源快照,覆盖统计使用该文件的数量。
若目录已有2条,之后仅补入1条,不能把这一小批当成完整快照,否则会把目录计数改成1。
为已核验的部分补充批次,在 manifest 显式声明:
```json
{
"coverage_mode": "incremental",
"coverage_previous_count": 2,
"coverage_previous_expected_count": 2,
"coverage_expected_count": 4,
"status": "partial",
"count": 1
}
```
这只是新增字段示例,manifest仍须保留source、venue、year、track、list_url、文件哈希等来源证据。
`count`是本文件行数,`coverage_previous_count`是导入前该来源/届次的实际记录数,
`coverage_previous_expected_count`是旧覆盖行的expected_count(未知时用null),
`coverage_expected_count`是独立目录证据支持的整份来源预期数,不能小于已知旧预期数。
这个例子成功后覆盖统计为预期4、已导入3;文件checkpoint的records_written仍为1。
刷新相同记录不重复累加。初次导入部分来源可声明previous_count为0、previous_expected_count为null。
只支持当前最多一个规范目录URL且旧覆盖状态为partial的来源/届次;多目录、旧状态complete、
旧计数与实际来源记录不符,或前置计数已变化,都会中止,先核对来源范围或重新取得完整快照。
该模式在写入前检查父状态,并在同一文件事务中按实际来源记录重新计数、检查总量界限和乐观更新覆盖行。
失败会回滚本文件的论文、来源、决定、标签和覆盖变化,保留失败运行记录;更早已提交文件不重放。
增量模式始终保留partial,不能靠一个小批次推断完整论文集、摘要或公开拒稿覆盖。
仍需先独立核对新增记录身份与决定,按原生单写入保护串行导入,再独立核验数据与原有历史。
这些计数检查不证明外部目录完整,也不代替最终全库去重。
+2
View File
@@ -99,6 +99,8 @@ paper-atlas query --pg-service foundry-research \
`--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。
已有稳定标识或原来源记录匹配时,该身份优先;同标题、同作者的另一 arXiv 存档或扩展稿不会仅因相似标题被并入。没有身份锚点时,作者佐证的精确标题只有一个候选才可匹配;多个候选会报 `Ambiguous paper identity`,整个采集文件回滚并记录失败运行。应补有证据的标识或先核查版本关系,不删除重复候选或任取第一个。一个新来源明确同时携带两个既有稳定标识时,仍可按既有规则归并并保留投稿、标签和合并日志;未归并的同标题关系继续保留为待核查候选。这些规则不证明全库去重完成。
## 5. 投稿历史关系审计
```sh
-1
View File
@@ -6,7 +6,6 @@ from paper_atlas import __version__
COMMANDS = {
('annotate',): ('paper_atlas.curation.annotations', {}, '管理独立的研究类型与自定义标签证据'),
('query',): ('paper_atlas.querying', {}, '查询论文、标签和投稿历史'),
('coverage',): ('paper_atlas.audits.coverage', {}, '查看全部候选会议的覆盖情况'),
('download',): ('paper_atlas.io.snapshots', {}, '下载并校验固定版本快照'),
-1
View File
@@ -1 +0,0 @@
"""Evidence-bearing human and explicit source annotations, independent of outcomes."""
-218
View File
@@ -1,218 +0,0 @@
"""Append-only tag/type assertions and explicit retractions; no inferred labels."""
import argparse
from contextlib import closing
from importlib.resources import files
import json
import re
from urllib.parse import urlparse
from paper_atlas.db.backend import database_arguments, inserted_id, open_read_connection, open_store
from paper_atlas.db.submissions import timestamp
TABLE = 'annotation_events'
MIGRATION = 'annotation_events_v1'
COLUMNS = ('id', 'request_id', 'paper_id', 'original_paper_id', 'kind', 'namespace',
'value', 'source_record_id', 'evidence_key', 'evidence_url', 'reason',
'actor', 'observed_at', 'retracts_id')
ACTIVE = '''a.retracts_id IS NULL AND NOT EXISTS
(SELECT 1 FROM annotation_events removed WHERE removed.retracts_id=a.id)'''
def available(conn):
if getattr(conn, 'postgres', False):
if hasattr(conn, 'has_table'):
return conn.has_table(TABLE)
return bool(conn.execute("SELECT 1 FROM information_schema.tables WHERE table_schema=current_schema() AND table_name=?", (TABLE,)).fetchone())
return bool(conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (TABLE,)).fetchone())
def _begin_write(conn):
if not getattr(conn, 'postgres', False) and not conn.in_transaction:
conn.execute('BEGIN IMMEDIATE')
def initialize(conn):
"""Explicit transactional schema extension; does not rescan or rewrite papers."""
_begin_write(conn)
exists = available(conn)
marker = conn.execute('SELECT 1 FROM schema_migrations WHERE name=?', (MIGRATION,)).fetchone()
if exists != bool(marker):
raise RuntimeError('Annotation table/upgrade marker mismatch; inspect before changing schema')
if exists:
result = conn.execute('SELECT * FROM annotation_events LIMIT 0')
description = result.cursor.description if getattr(conn, 'postgres', False) else result.description
if tuple(d[0] for d in description) != COLUMNS:
raise RuntimeError('Unexpected annotation schema')
return False
ddl = files('paper_atlas.db').joinpath('annotations.sql').read_text()
if getattr(conn, 'postgres', False):
ddl = re.sub(r'\bINTEGER\b', 'BIGINT', ddl)
ddl = re.sub(r'\bTEXT\b', 'TEXT COLLATE "C"', ddl)
ddl = ddl.replace('id BIGINT PRIMARY KEY', 'id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY', 1)
for statement in ddl.split(';'):
if statement.strip():
conn.execute(statement)
conn.execute('INSERT INTO schema_migrations(name) VALUES(?)', (MIGRATION,))
return True
def _text(value, name, limit=512):
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise ValueError(name + ' must be nonempty text of at most ' + str(limit) + ' characters')
return value
def _id(value, name):
if type(value) is not int or value <= 0:
raise ValueError(name + ' must be a positive integer')
return value
def _url(value):
if value is not None:
_text(value, 'evidence_url', 4096)
parsed = urlparse(value)
if parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username or parsed.password:
raise ValueError('evidence_url must be an HTTP(S) URL without credentials')
return value
def _require(conn):
if not available(conn) or not conn.execute('SELECT 1 FROM schema_migrations WHERE name=?', (MIGRATION,)).fetchone():
raise RuntimeError('Run paper-atlas annotate init explicitly before adding annotations')
def _row(row):
return dict(zip(COLUMNS, (row[i] for i in range(len(COLUMNS)))))
def _replay(conn, request_id, payload):
old = conn.execute('SELECT * FROM annotation_events WHERE request_id=?', (request_id,)).fetchone()
if old is None:
return None
old = _row(old)
if any(old[k] != value for k, value in payload.items()):
raise ValueError('request_id already identifies different annotation content')
return old['id']
def assert_annotation(conn, paper, kind, namespace, value, *, request_id, evidence_key,
reason, actor, evidence_url=None, source_record_id=None):
_begin_write(conn)
_require(conn)
paper = _id(paper, 'paper')
if kind not in ('tag', 'research_type'):
raise ValueError('kind must be tag or research_type')
_text(namespace, 'namespace', 128)
if not re.fullmatch(r'[a-z][a-z0-9_]*:[a-z][a-z0-9_.-]*', namespace):
raise ValueError('namespace must have an explicit source:scope')
payload = dict(original_paper_id=paper, kind=kind, namespace=namespace,
value=_text(value, 'value'), request_id=_text(request_id, 'request_id', 128),
source_record_id=source_record_id, evidence_key=_text(evidence_key, 'evidence_key', 256),
reason=_text(reason, 'reason', 8192), actor=_text(actor, 'actor', 256),
evidence_url=_url(evidence_url), retracts_id=None)
if source_record_id is not None:
_id(source_record_id, 'source_record_id')
replay = _replay(conn, request_id, payload)
if replay is not None:
return replay
if not conn.execute('SELECT 1 FROM papers WHERE id=?', (paper,)).fetchone():
raise ValueError('Existing canonical paper required; no automatic identity merge')
prefix = namespace.split(':', 1)[0]
if prefix != 'custom' and source_record_id is None:
raise ValueError('Platform annotations require their own source record')
if source_record_id is not None:
source = conn.execute('''SELECT r.paper_id,s.name FROM source_records r
JOIN sources s ON s.id=r.source_id WHERE r.id=?''', (source_record_id,)).fetchone()
if not source or source[0] != paper or (prefix != 'custom' and prefix != source[1]):
raise ValueError('Annotation source record/namespace must belong to this paper and platform')
payload.update(paper_id=paper, observed_at=timestamp())
fields = tuple(payload)
return inserted_id(conn, 'INSERT INTO annotation_events(' + ','.join(fields) + ') VALUES(' +
','.join('?' for _ in fields) + ')', tuple(payload[k] for k in fields))
def retract_annotation(conn, event, *, request_id, reason, actor, evidence_url=None, custom_only=False):
_begin_write(conn)
_require(conn)
event = _id(event, 'event')
payload = dict(request_id=_text(request_id, 'request_id', 128), retracts_id=event,
reason=_text(reason, 'reason', 8192), actor=_text(actor, 'actor', 256), evidence_url=_url(evidence_url))
target = conn.execute('SELECT * FROM annotation_events WHERE id=?', (event,)).fetchone()
if target is None or target[-1] is not None:
raise ValueError('Retraction must reference an existing assertion')
target = _row(target)
if custom_only and not target['namespace'].startswith('custom:'):
raise ValueError('Manual CLI cannot retract platform evidence')
replay = _replay(conn, request_id, payload)
if replay is not None:
return replay
if conn.execute('SELECT 1 FROM annotation_events WHERE retracts_id=?', (event,)).fetchone():
raise ValueError('Assertion already retracted; its history is retained')
payload.update({k: target[k] for k in ('paper_id', 'original_paper_id', 'kind', 'namespace', 'value', 'source_record_id', 'evidence_key')})
payload['observed_at'] = timestamp()
fields = tuple(payload)
return inserted_id(conn, 'INSERT INTO annotation_events(' + ','.join(fields) + ') VALUES(' +
','.join('?' for _ in fields) + ')', tuple(payload[k] for k in fields))
def observations(conn, paper, *, kind=None, history=False):
if not available(conn):
return []
where = ['a.paper_id=?']; params = [_id(paper, 'paper')]
if not history:
where.append(ACTIVE)
if kind is not None:
if kind not in ('tag', 'research_type'):
raise ValueError('Invalid annotation kind')
where.append('a.kind=?'); params.append(kind)
return [_row(r) for r in conn.execute('SELECT a.* FROM annotation_events a WHERE ' +
' AND '.join(where) + ' ORDER BY a.id', params)]
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog, description='显式初始化、追加或撤回带证据的自定义标签和研究类型')
actions = parser.add_subparsers(dest='action', required=True)
for action in ('init', 'assert', 'retract', 'list'):
p = actions.add_parser(action)
database_arguments(p)
if action in ('assert', 'retract'):
for field in ('request-id', 'reason', 'actor'):
p.add_argument('--' + field, required=True)
p.add_argument('--evidence-url')
if action in ('assert', 'list'):
p.add_argument('--paper', type=int, required=True)
p.add_argument('--kind', choices=('tag', 'research_type'), required=action == 'assert')
if action == 'assert':
for field in ('namespace', 'value', 'evidence-key'):
p.add_argument('--' + field, required=True)
p.add_argument('--source-record', type=int)
if action == 'retract':
p.add_argument('--event', type=int, required=True)
if action == 'list':
p.add_argument('--history', action='store_true')
args = parser.parse_args(argv)
if args.action == 'list':
with closing(open_read_connection(args.db, pg_service=args.pg_service, pg_schema=args.pg_schema)) as conn:
result = observations(conn, args.paper, kind=args.kind, history=args.history)
else:
if args.action == 'assert' and not args.namespace.startswith('custom:'):
parser.error('Manual CLI requires custom:* namespace; platform assertions use validated source APIs')
with closing(open_store(args.db, pg_service=args.pg_service, pg_schema=args.pg_schema).conn) as conn:
try:
if not getattr(conn, 'postgres', False):
conn.commit()
conn.execute('BEGIN IMMEDIATE')
if args.action == 'init':
result = {'initialized': initialize(conn)}
else:
kwargs = dict(request_id=args.request_id, reason=args.reason, actor=args.actor, evidence_url=args.evidence_url)
event = (assert_annotation(conn, args.paper, args.kind, args.namespace, args.value,
evidence_key=args.evidence_key, source_record_id=args.source_record, **kwargs)
if args.action == 'assert' else retract_annotation(conn, args.event, custom_only=True, **kwargs))
result = {'event_id': event}
conn.commit()
except BaseException:
conn.rollback()
raise
print(json.dumps(result, ensure_ascii=False, indent=2))
-15
View File
@@ -1,15 +0,0 @@
CREATE TABLE annotation_events (
id INTEGER PRIMARY KEY,
request_id TEXT NOT NULL UNIQUE,
paper_id INTEGER NOT NULL REFERENCES papers(id),
original_paper_id INTEGER NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('tag','research_type')),
namespace TEXT NOT NULL, value TEXT NOT NULL,
source_record_id INTEGER REFERENCES source_records(id),
evidence_key TEXT NOT NULL, evidence_url TEXT,
reason TEXT NOT NULL, actor TEXT NOT NULL, observed_at TEXT NOT NULL,
retracts_id INTEGER UNIQUE REFERENCES annotation_events(id),
CHECK(retracts_id IS NULL OR retracts_id<>id)
);
CREATE INDEX annotation_events_paper ON annotation_events(paper_id,kind,id);
CREATE INDEX annotation_events_label ON annotation_events(kind,namespace,value,paper_id);
+13 -10
View File
@@ -55,9 +55,6 @@ class Store:
c.execute('UPDATE identifiers SET paper_id=? WHERE paper_id=?',(target,other))
c.execute('UPDATE source_records SET paper_id=? WHERE paper_id=?',(target,other))
c.execute('UPDATE submissions SET paper_id=? WHERE paper_id=?',(target,other))
from paper_atlas.curation.annotations import available
if available(c):
c.execute('UPDATE annotation_events SET paper_id=? WHERE paper_id=?', (target,other))
c.execute('INSERT INTO paper_tags SELECT ?,tag_id,source_record_id,evidence_key FROM paper_tags WHERE paper_id=? ON CONFLICT DO NOTHING',(target,other))
c.execute('DELETE FROM paper_tags WHERE paper_id=?',(other,))
c.execute('UPDATE paper_merges SET surviving_paper_id=? WHERE surviving_paper_id=?',(target,other))
@@ -98,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}):
+71 -2
View File
@@ -12,6 +12,55 @@ from paper_atlas.collectors.public_openreview import SOURCE as PUBLIC_SOURCE
from paper_atlas.collectors.icml_public_archive import SOURCE as ARCHIVE_SOURCE
def coverage_options(manifest):
mode = manifest.get('coverage_mode', 'snapshot')
if mode not in ('snapshot', 'incremental'):
raise ValueError('Unsupported coverage mode')
if mode == 'snapshot':
return None
required = ('venue', 'year', 'track', 'count', 'coverage_previous_count',
'coverage_previous_expected_count', 'coverage_expected_count')
if any(key not in manifest for key in required) or manifest.get('status') != 'partial':
raise ValueError('Incremental coverage requires a partial, explicitly scoped manifest')
previous = manifest['coverage_previous_count']
expected = manifest['coverage_expected_count']
prior_expected = manifest['coverage_previous_expected_count']
if (type(previous) is not int or previous < 0 or type(expected) is not int or expected < 1
or type(manifest['count']) is not int or manifest['count'] < 1
or expected < max(previous, manifest['count'])
or (prior_expected is not None and (type(prior_expected) is not int or prior_expected < 0
or expected < prior_expected))):
raise ValueError('Invalid incremental coverage counts')
return previous, prior_expected, expected
def source_record_count(c, source, edition):
return c.execute('''SELECT count(DISTINCT ss.source_record_id) FROM submission_sources ss
JOIN submissions s ON s.id=ss.submission_id
JOIN source_records sr ON sr.id=ss.source_record_id
WHERE s.edition_id=? AND sr.source_id=?''', (edition, source)).fetchone()[0]
def require_incremental_parent(c, source, manifest, options):
edition = c.execute('''SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id
WHERE lower(v.acronym)=lower(?) AND e.year=? AND e.track=?''',
(manifest['venue'], manifest['year'], manifest['track'])).fetchone()
entries = c.execute('''SELECT list_url,status,expected_count,imported_count
FROM edition_sources WHERE edition_id=? AND source_id=?''',
(edition[0], source)).fetchall() if edition else []
previous, prior_expected, _ = options
if entries:
if len(entries) != 1 or entries[0][0] != manifest['list_url']:
raise ValueError('Incremental coverage requires one unambiguous source listing')
if tuple(entries[0][1:]) != ('partial', prior_expected, previous):
raise ValueError('Incremental coverage parent changed or is not partial')
elif previous != 0 or prior_expected is not None:
raise ValueError('Incremental coverage parent is absent')
actual = source_record_count(c, source, edition[0]) if edition else 0
if actual != previous:
raise ValueError('Incremental coverage parent differs from actual source records')
def ingest_file(db,path, *, pg_service=None, pg_schema=None):
path = Path(path)
manifest_path = path.with_suffix('.manifest.json')
@@ -24,6 +73,7 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
source_name = manifest.get('source')
if not source_name:
raise ValueError('A source manifest is required')
incremental = coverage_options(manifest)
store = open_store(db, pg_service=pg_service, pg_schema=pg_schema)
c = store.conn
run = None
@@ -35,6 +85,8 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
source_id = store.source(source_name,manifest['list_url'])
run = inserted_id(c,'INSERT INTO ingest_runs(source_id,checkpoint_json) VALUES(?,?)',(source_id,json.dumps({'file':str(path),'expected_sha256':manifest.get('file_sha256')})))
c.commit()
if incremental is not None:
require_incremental_parent(c, source_id, manifest, incremental)
digest = hashlib.sha256()
with path.open('rb') as stream:
for line in stream:
@@ -82,8 +134,25 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
if expected is not None and expected != count:
raise ValueError(f'Manifest count {expected} differs from records {count}')
complete=expected==count and manifest.get('status') not in ('partial','blocked') and not manifest.get('missing_abstract_count',0) and not missing_abstracts
c.execute('INSERT INTO edition_sources(edition_id,source_id,list_url,status,expected_count,imported_count,last_success_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(edition_id,source_id,list_url) DO UPDATE SET status=excluded.status,expected_count=excluded.expected_count,imported_count=excluded.imported_count,last_success_at=excluded.last_success_at,last_error=NULL',(edition,source_id,manifest['list_url'],'complete' if complete else 'partial',expected,count,time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())))
c.execute("UPDATE ingest_runs SET status='complete',finished_at=?,records_seen=?,records_written=?,checkpoint_json=? WHERE id=?",(timestamp(),count,count,json.dumps({'file':str(path),'file_sha256':digest.hexdigest()}),run))
imported = count
condition = ''
condition_params = ()
if incremental is not None:
previous, prior_expected, expected = incremental
imported = source_record_count(c, source_id, edition)
if imported < previous or imported > expected:
raise ValueError('Incremental coverage exceeds the declared source bounds')
complete = False
condition = " WHERE edition_sources.imported_count=? AND COALESCE(edition_sources.expected_count,-1)=? AND edition_sources.status='partial'"
condition_params = (previous, -1 if prior_expected is None else prior_expected)
result = c.execute('INSERT INTO edition_sources(edition_id,source_id,list_url,status,expected_count,imported_count,last_success_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(edition_id,source_id,list_url) DO UPDATE SET status=excluded.status,expected_count=excluded.expected_count,imported_count=excluded.imported_count,last_success_at=excluded.last_success_at,last_error=NULL'+condition,(edition,source_id,manifest['list_url'],'complete' if complete else 'partial',expected,imported,time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),*condition_params))
if incremental is not None and result.rowcount != 1:
raise ValueError('Incremental coverage parent changed during import')
checkpoint = {'file':str(path),'file_sha256':digest.hexdigest()}
if incremental is not None:
checkpoint['coverage'] = {'mode':'incremental','previous_count':previous,
'expected_count':expected,'imported_count':imported}
c.execute("UPDATE ingest_runs SET status='complete',finished_at=?,records_seen=?,records_written=?,checkpoint_json=? WHERE id=?",(timestamp(),count,count,json.dumps(checkpoint),run))
c.commit()
print(json.dumps({'file':str(path),'status':'imported','records':count,'missing_abstract_count':missing_abstracts}),flush=True)
except BaseException as exc:
+2 -2
View File
@@ -143,7 +143,7 @@ def migrate(db, service, out, *, schema='research', storage_path=None, min_free_
free_space(storage_path, min_free_gib)
control = schema + '_migration'
report = {'status': 'preflight', 'source': str(db), 'service': service, 'schema': schema,
'started_at': timestamp(), 'tables': {}, 'scope': 'All baseline business tables and recognized installed extensions; FTS5 rebuilt as native PostgreSQL GIN.'}
'started_at': timestamp(), 'tables': {}, 'scope': 'All 21 business tables; FTS5 internals rebuilt as native PostgreSQL GIN.'}
with Path(str(db) + '.maintenance.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
if Path(str(db) + '-wal').exists() and Path(str(db) + '-wal').stat().st_size:
@@ -240,7 +240,7 @@ def migrate(db, service, out, *, schema='research', storage_path=None, min_free_
JOIN pg_am am ON am.oid=ix.relam WHERE ns.nspname=%s AND ix.relname='papers_search_gin' ''', (stage,)).fetchone()
if search != ('gin', True, True, 'papers'):
raise ValueError('Native full-text index is not valid and ready')
for table in plan:
for table in TABLES:
pg.execute('ANALYZE ' + qualify(stage, table))
invalid = pg.execute('''SELECT count(*) FROM pg_constraint co JOIN pg_class cl ON cl.oid=co.conrelid
JOIN pg_namespace ns ON ns.oid=cl.relnamespace WHERE ns.nspname=%s AND NOT co.convalidated''', (stage,)).fetchone()[0]
+2 -4
View File
@@ -8,9 +8,7 @@ TABLES = ('sources', 'papers', 'identifiers', 'source_records', 'venues', 'editi
'submission_sources', 'submission_identifiers', 'decision_sources',
'submission_merges', 'decision_merges', 'schema_migrations', 'tags',
'paper_tags', 'match_candidates', 'paper_merges', 'ingest_runs')
OPTIONAL_TABLES = ('annotation_events',)
CHECKS = {
'annotation_events': ["kind IN ('tag','research_type')", 'retracts_id IS NULL OR retracts_id<>id'],
'venues': ["venue_type IN ('conference','journal','review_platform')"],
'edition_sources': ["status IN ('pending','running','complete','partial','blocked','unavailable','not_yet_published')"],
'decisions': ["status IN ('accepted','rejected','desk_rejected','withdrawn','under_review','unknown')"],
@@ -52,10 +50,10 @@ def quote(name):
def inspect(connection):
business = {name for schema, name, kind, *_ in connection.execute('PRAGMA table_list')
if schema == 'main' and kind == 'table' and not name.startswith('sqlite_')}
if not set(TABLES) <= business or business - set(TABLES) - set(OPTIONAL_TABLES):
if business != set(TABLES):
raise ValueError('Unexpected business tables: ' + repr(sorted(business ^ set(TABLES))))
result = {}
for table in (*TABLES, *(name for name in OPTIONAL_TABLES if name in business)):
for table in TABLES:
columns = [{'name': r[1], 'type': r[2].upper(), 'notnull': bool(r[3]),
'default': r[4], 'pk': r[5]}
for r in connection.execute('PRAGMA table_info(' + quote(table) + ')')]
+1 -25
View File
@@ -29,8 +29,6 @@ def main(argv=None, *, prog=None):
parser.add_argument('--pg-schema', help='Optional PostgreSQL schema override; otherwise use the configured search_path')
parser.add_argument('--query', help='Title/abstract search: FTS5 for SQLite, web-search syntax for PostgreSQL')
parser.add_argument('--paper',type=int,help='Paper ID; include sources, tags and submission history')
parser.add_argument('--research-type', help='Independent type filter as namespace=value; no type inferred from track')
parser.add_argument('--annotation-history', action='store_true', help='Include retraction evidence for --paper')
parser.add_argument('--tag', help='Filter as namespace=value, e.g. arxiv:category=cs.AI')
parser.add_argument('--venue', help='Conference acronym')
parser.add_argument('--year', type=int)
@@ -49,8 +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.curation.annotations import available, observations, ACTIVE
has_annotations = available(conn)
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')]
@@ -73,24 +69,8 @@ def main(argv=None, *, prog=None):
where.append('p.id IN (SELECT rowid FROM papers_fts WHERE papers_fts MATCH ?)')
params.append(args.query)
if args.tag:
if '=' not in args.tag:
parser.error('--tag requires namespace=value')
namespace,value = args.tag.split('=',1)
platform = '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 has_annotations:
platform = '(' + platform + ' OR EXISTS(SELECT 1 FROM annotation_events a WHERE a.paper_id=p.id AND a.kind=\'tag\' AND a.namespace=? AND a.value=? AND ' + ACTIVE + '))'
params.extend([namespace,value])
where.append(platform)
if args.research_type:
if '=' not in args.research_type:
parser.error('--research-type requires namespace=value')
namespace,value = args.research_type.split('=',1)
if has_annotations:
where.append('EXISTS(SELECT 1 FROM annotation_events a WHERE a.paper_id=p.id AND a.kind=\'research_type\' AND a.namespace=? AND a.value=? AND ' + ACTIVE + ')')
params.extend([namespace,value])
else:
where.append('1=0')
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:
@@ -118,10 +98,6 @@ def main(argv=None, *, prog=None):
paper['known_acceptances']=known_acceptances(conn,row['id'])
paper['publication_outcome']='accepted' if paper['known_acceptances'] else 'no_known_acceptance'
if args.paper is not None:
paper['research_types'] = observations(conn, row['id'], kind='research_type')
paper['annotation_tags'] = observations(conn, row['id'], kind='tag')
if args.annotation_history:
paper['annotation_history'] = observations(conn, row['id'], history=True)
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 = []
+1 -1
View File
@@ -21,5 +21,5 @@ include = ["paper_atlas*"]
exclude = ["tests*"]
[tool.setuptools.package-data]
"paper_atlas.db" = ["schema.sql", "annotations.sql"]
"paper_atlas.db" = ["schema.sql"]
"paper_atlas.resources" = ["venues.json"]
-227
View File
@@ -1,227 +0,0 @@
from contextlib import closing, redirect_stdout
import io
import json
import os
from pathlib import Path
import tempfile
import unittest
import uuid
from paper_atlas import querying
from paper_atlas.curation.annotations import (assert_annotation, available, initialize, main,
observations, retract_annotation)
from paper_atlas.db.backend import open_store
from paper_atlas.db.store import Store
from paper_atlas.postgres import schema
from paper_atlas.postgres.client import connect
from paper_atlas.postgres.migration import migrate
class AnnotationTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
self.db = Path(self.tmp.name) / 'sample.sqlite'
self.store = Store(self.db); self.addCleanup(self.store.conn.close)
self.c = self.store.conn
self.paper, self.record = self.store.record('fixture', 'one', 'https://example.org/one', 'First research', None, [])
self.other, self.other_record = self.store.record('fixture', 'two', 'https://example.org/two', 'Other research', None, [])
self.store.record_tags(self.paper, self.record, 'fixture', [{'namespace':'fixture:topic','value':'original'}])
self.c.commit()
def add(self, **changes):
args = dict(paper=self.paper, kind='tag', namespace='custom:topic', value='reading',
request_id='request-one', evidence_key='manual:reading', reason='Reviewed metadata', actor='reviewer')
args.update(changes)
return assert_annotation(self.c, **args)
def query(self, *args):
self.c.commit()
with redirect_stdout(io.StringIO()) as output:
querying.main(['--db', str(self.db), *map(str, args)])
return json.loads(output.getvalue())
def test_read_does_not_install_extension_and_write_requires_explicit_init(self):
self.assertFalse(available(self.c))
self.assertEqual(observations(self.c, self.paper), [])
with self.assertRaisesRegex(RuntimeError, 'init explicitly'):
self.add()
self.assertEqual(self.query('--research-type', 'custom:method=empirical'), [])
self.assertFalse(available(self.c))
self.assertEqual(len(schema.inspect(self.c)), 21)
def test_schema_upgrade_is_transactional_and_marker_mismatch_fails_closed(self):
self.assertTrue(initialize(self.c)); self.c.rollback()
self.assertFalse(available(self.c))
self.c.execute("INSERT INTO schema_migrations(name) VALUES('annotation_events_v1')")
with self.assertRaisesRegex(RuntimeError, 'mismatch'):
initialize(self.c)
self.c.rollback()
self.assertTrue(initialize(self.c)); self.assertFalse(initialize(self.c))
self.assertEqual(len(schema.inspect(self.c)), 22)
def test_unmarked_table_cannot_receive_assertions(self):
initialize(self.c)
self.c.execute("DELETE FROM schema_migrations WHERE name='annotation_events_v1'")
with self.assertRaisesRegex(RuntimeError, 'init explicitly'):
self.add()
self.assertEqual(self.c.execute('SELECT count(*) FROM annotation_events').fetchone()[0], 0)
def test_types_tags_and_platform_labels_are_independent_and_queryable(self):
initialize(self.c)
self.add()
self.add(kind='research_type', namespace='custom:topic', request_id='type', value='reading')
self.assertEqual(len(self.query('--tag', 'custom:topic=reading')), 1)
self.assertEqual(len(self.query('--research-type', 'custom:topic=reading')), 1)
self.assertEqual(len(self.query('--tag', 'fixture:topic=original')), 1)
detail = self.query('--paper', self.paper)[0]
self.assertEqual(len(detail['annotation_tags']), 1)
self.assertEqual(len(detail['research_types']), 1)
self.assertNotIn('annotation_history', detail)
self.assertEqual(detail['tags'][0]['namespace'], 'fixture:topic')
def test_repeated_request_is_idempotent_and_changed_content_fails(self):
initialize(self.c); event = self.add()
self.assertEqual(self.add(), event)
for change in ({'value':'different'}, {'kind':'research_type'}, {'reason':'changed'}, {'paper':self.other}):
with self.assertRaisesRegex(ValueError, 'different annotation'):
self.add(**change)
self.assertEqual(self.c.execute('SELECT count(*) FROM annotation_events').fetchone()[0], 1)
def test_retraction_keeps_original_and_other_assertions_of_same_label(self):
initialize(self.c); first = self.add(); second = self.add(request_id='second', evidence_key='independent')
kwargs = dict(request_id='retract', reason='No longer relevant', actor='reviewer')
removed = retract_annotation(self.c, first, **kwargs)
self.assertEqual(retract_annotation(self.c, first, **kwargs), removed)
self.assertEqual([r['id'] for r in observations(self.c, self.paper)], [second])
self.assertEqual(len(self.query('--paper', self.paper, '--annotation-history')[0]['annotation_history']), 3)
with self.assertRaisesRegex(ValueError, 'already retracted'):
retract_annotation(self.c, first, **{**kwargs,'request_id':'duplicate'})
with self.assertRaisesRegex(ValueError, 'existing assertion'):
retract_annotation(self.c, removed, **{**kwargs,'request_id':'nested'})
def test_source_assertions_require_matching_paper_and_platform(self):
initialize(self.c)
for change in ({'namespace':'fixture:type'}, {'namespace':'other:type','source_record_id':self.record},
{'namespace':'fixture:type','source_record_id':self.other_record}):
with self.assertRaises(ValueError):
self.add(**change)
event = self.add(kind='research_type', namespace='fixture:declared_type', source_record_id=self.record)
with self.assertRaisesRegex(ValueError, 'Manual CLI'):
retract_annotation(self.c, event, request_id='cannot', reason='x', actor='y', custom_only=True)
self.assertEqual(observations(self.c, self.paper)[0]['source_record_id'], self.record)
def test_platform_tag_refresh_preserves_both_custom_kinds(self):
initialize(self.c); self.add(); self.add(kind='research_type', request_id='type')
self.store.record_tags(self.paper, self.record, 'fixture', [{'namespace':'fixture:topic','value':'refreshed'}])
self.assertEqual(len(observations(self.c, self.paper)), 2)
self.assertEqual(len(self.query('--tag','fixture:topic=refreshed')), 1)
self.assertEqual(self.query('--tag','fixture:topic=original'), [])
def test_merge_preserves_retractions_evidence_and_original_paper_ids(self):
initialize(self.c); event = self.add(source_record_id=self.record)
removed = retract_annotation(self.c, event, request_id='withdraw', reason='corrected', actor='reviewer')
active = self.add(request_id='active', kind='research_type', value='survey')
self.store.merge(self.other, self.paper, 'Independently reviewed identity')
rows = observations(self.c, self.other, history=True)
self.assertEqual([r['id'] for r in rows], [event, removed, active])
self.assertEqual({r['original_paper_id'] for r in rows}, {self.paper})
self.assertEqual({r['paper_id'] for r in rows}, {self.other})
self.assertEqual([r['id'] for r in observations(self.c, self.other)], [active])
self.assertEqual(self.add(source_record_id=self.record), event) # exact retry survives canonical merge
self.assertEqual(self.c.execute('PRAGMA foreign_key_check').fetchall(), [])
def test_invalid_input_does_not_create_events(self):
initialize(self.c)
for change in ({'paper':True}, {'paper':999999}, {'kind':'main'}, {'namespace':'arxiv'},
{'namespace':'custom:topic;DROP'}, {'value':' '}, {'reason':''},
{'actor':''}, {'request_id':''}, {'evidence_key':''}, {'source_record_id':True},
{'evidence_url':'file:///etc/passwd'}, {'evidence_url':'https://user:secret@example.org/'}):
with self.subTest(change=change), self.assertRaises(ValueError):
self.add(**change)
self.assertEqual(self.c.execute('SELECT count(*) FROM annotation_events').fetchone()[0], 0)
def test_cli_manual_scope_and_complete_assert_retract_lifecycle(self):
with redirect_stdout(io.StringIO()):
main(['init','--db',str(self.db)])
main(['assert','--db',str(self.db),'--paper',str(self.paper),'--kind','research_type',
'--namespace','custom:method','--value','empirical','--evidence-key','manual:review',
'--request-id','cli-one','--reason','Read abstract','--actor','reviewer'])
self.assertEqual(len(self.query('--research-type','custom:method=empirical')), 1)
event = observations(self.c, self.paper)[0]['id']
with redirect_stdout(io.StringIO()):
main(['retract','--db',str(self.db),'--event',str(event),'--request-id','cli-two',
'--reason','Corrected classification','--actor','reviewer'])
self.assertEqual(self.query('--research-type','custom:method=empirical'), [])
with redirect_stdout(io.StringIO()) as out:
main(['list','--db',str(self.db),'--paper',str(self.paper),'--history'])
self.assertEqual(len(json.loads(out.getvalue())), 2)
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'), 'Requires approved remote PostgreSQL service')
class PostgreSQLAnnotationTests(unittest.TestCase):
def setUp(self):
self.service = os.environ['PAPER_ATLAS_TEST_PG_SERVICE']
self.target = 'pa_anno_' + uuid.uuid4().hex[:12]
self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
self.db = Path(self.tmp.name) / 'source.sqlite'
self.s = Store(self.db)
self.paper, self.record = self.s.record('fixture','one','https://example.org','Paper',None,[])
self.s.conn.commit()
self.addCleanup(self.s.conn.close); self.addCleanup(self.cleanup)
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.target,)):
c.execute('DROP SCHEMA ' + schema.quote(name) + ' CASCADE')
def migrate(self):
self.s.conn.commit()
self.s.conn.close() # Freeze/checkpoint the fixture, just like a real source migration.
with redirect_stdout(io.StringIO()):
return migrate(self.db, self.service, Path(self.tmp.name)/'report.json', schema=self.target, min_free_gib=0)
def args(self, **changes):
a = dict(paper=self.paper,kind='research_type',namespace='custom:method',value='survey\x00and\x01',
request_id='native',evidence_key='manual',reason='Reviewed\x00evidence',actor='reviewer')
a.update(changes); return a
def test_native_schema_upgrade_roundtrip_retraction_and_queries(self):
self.migrate()
with closing(open_store(pg_service=self.service, pg_schema=self.target).conn) as c:
self.assertTrue(initialize(c)); self.assertFalse(initialize(c))
event = assert_annotation(c, **self.args()); c.commit()
self.assertEqual(assert_annotation(c, **self.args()), event)
self.assertEqual(observations(c,self.paper)[0]['value'], 'survey\x00and\x01')
c.commit()
with redirect_stdout(io.StringIO()) as out:
querying.main(['--pg-service',self.service,'--pg-schema',self.target,'--paper',str(self.paper)])
self.assertEqual(json.loads(out.getvalue())[0]['research_types'][0]['id'], event)
with closing(open_store(pg_service=self.service,pg_schema=self.target).conn) as c:
retract_annotation(c,event,request_id='removed',reason='Changed',actor='reviewer');c.commit()
self.assertEqual(observations(c,self.paper), [])
self.assertEqual(len(observations(c,self.paper,history=True)), 2)
def test_initialized_sqlite_extension_migrates_all_rows_hashes_and_identity(self):
initialize(self.s.conn)
event = assert_annotation(self.s.conn, **self.args())
retract_annotation(self.s.conn,event,request_id='retracted',reason='Corrected',actor='reviewer')
report = self.migrate()
self.assertEqual(len(report['tables']),22)
self.assertEqual(report['tables']['annotation_events']['source_rows'],2)
self.assertEqual(report['tables']['annotation_events']['source_sha256'],report['tables']['annotation_events']['target_sha256'])
with closing(open_store(pg_service=self.service,pg_schema=self.target).conn) as c:
new = assert_annotation(c, **self.args(request_id='after-migration'))
self.assertGreater(new,2); c.commit()
self.assertEqual(len(observations(c,self.paper,history=True)),3)
def test_native_upgrade_rollback_keeps_legacy_schema_usable(self):
self.migrate()
with closing(open_store(pg_service=self.service,pg_schema=self.target).conn) as c:
initialize(c); c.rollback()
self.assertFalse(available(c))
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],1)
self.assertTrue(initialize(c))
with self.assertRaises(ValueError):
assert_annotation(c, **self.args(namespace='wrong:type',source_record_id=self.record))
self.assertEqual(c.execute('SELECT count(*) FROM annotation_events').fetchone()[0],0)
c.rollback()
+116
View File
@@ -0,0 +1,116 @@
from contextlib import redirect_stdout
import hashlib
import io
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from paper_atlas.ingestion.records import ingest_file
class IncrementalCoverageTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.db = self.root / 'papers.sqlite'
def file(self, name, external_ids, **options):
rows = [{'source':'example','external_id':key,'url':'https://example.org/'+key,
'venue':'Example','year':2026,'track':'main','status':'accepted',
'title':'Independent research contribution '+key,'abstract':'Known abstract',
'authors':[], 'identifiers':{'doi':'10.1234/'+key}} for key in external_ids]
path = self.root / (name+'.jsonl')
path.write_bytes((''.join(json.dumps(row)+'\n' for row in rows)).encode())
manifest = {key:rows[0][key] for key in ('source','venue','year','track')}
manifest.update(list_url='https://example.org/2026', count=len(rows),status='partial',
file_sha256=hashlib.sha256(path.read_bytes()).hexdigest())
manifest.update(options)
path.with_suffix('.manifest.json').write_text(json.dumps(manifest))
return path
def ingest(self, path):
with redirect_stdout(io.StringIO()):
ingest_file(self.db,path)
def incremental(self, previous=2, prior_expected=2, expected=4):
return {'coverage_mode':'incremental','coverage_previous_count':previous,
'coverage_previous_expected_count':prior_expected,'coverage_expected_count':expected}
def coverage(self):
with sqlite3.connect(self.db) as c:
return c.execute('SELECT status,expected_count,imported_count FROM edition_sources').fetchall()
def test_append_and_refresh_count_actual_source_records_without_erasing_earlier_batch(self):
self.ingest(self.file('first',['a','b']))
self.ingest(self.file('append',['c'],**self.incremental()))
self.assertEqual(self.coverage(),[('partial',4,3)])
self.ingest(self.file('refresh',['c'],**self.incremental(3,4,4)))
self.assertEqual(self.coverage(),[('partial',4,3)])
with sqlite3.connect(self.db) as c:
self.assertEqual(c.execute('SELECT count(*) FROM source_records').fetchone()[0],3)
self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],3)
runs = c.execute('SELECT records_written,checkpoint_json FROM ingest_runs ORDER BY id').fetchall()
self.assertEqual([r[0] for r in runs],[2,1,1])
self.assertEqual(json.loads(runs[-1][1])['coverage'],
{'mode':'incremental','previous_count':3,'expected_count':4,'imported_count':3})
self.assertEqual(c.execute('PRAGMA foreign_key_check').fetchall(),[])
def test_stale_parent_stops_before_source_writes(self):
self.ingest(self.file('first',['a','b']))
with self.assertRaisesRegex(ValueError,'parent changed'):
self.ingest(self.file('stale',['c'],**self.incremental(1,2,4)))
self.assertEqual(self.coverage(),[('partial',2,2)])
with sqlite3.connect(self.db) as c:
self.assertEqual(c.execute("SELECT count(*) FROM source_records WHERE external_id='c'").fetchone()[0],0)
self.assertEqual(c.execute('SELECT status FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()[0],'failed')
def test_late_source_bound_failure_rolls_back_new_records_and_coverage(self):
self.ingest(self.file('first',['a','b']))
with self.assertRaisesRegex(ValueError,'source bounds'):
self.ingest(self.file('overflow',['c'],**self.incremental(2,2,2)))
self.assertEqual(self.coverage(),[('partial',2,2)])
with sqlite3.connect(self.db) as c:
for table in ('papers','source_records','submissions','decisions'):
self.assertEqual(c.execute('SELECT count(*) FROM '+table).fetchone()[0],2)
def test_multiple_listings_cannot_be_silently_aggregated(self):
self.ingest(self.file('one',['a']))
self.ingest(self.file('two',['b'],list_url='https://example.org/another-list'))
before=self.coverage()
with self.assertRaisesRegex(ValueError,'unambiguous source listing'):
self.ingest(self.file('ambiguous',['c'],**self.incremental(2,1,4)))
self.assertEqual(self.coverage(),before)
def test_untracked_source_rows_require_reconciliation(self):
self.ingest(self.file('first',['a','b']))
with sqlite3.connect(self.db) as c:
c.execute('UPDATE edition_sources SET imported_count=1')
with self.assertRaisesRegex(ValueError,'actual source records'):
self.ingest(self.file('stale-summary',['c'],**self.incremental(1,2,4)))
self.assertEqual(self.coverage(),[('partial',2,1)])
def test_first_partial_chunk_can_declare_an_independently_known_source_total(self):
self.ingest(self.file('first',['a'],**self.incremental(0,None,4)))
self.assertEqual(self.coverage(),[('partial',4,1)])
def test_complete_listing_is_not_demoted_by_a_partial_chunk(self):
self.ingest(self.file('complete',['a','b'],status='complete'))
with self.assertRaisesRegex(ValueError,'not partial'):
self.ingest(self.file('append',['c'],**self.incremental()))
self.assertEqual(self.coverage(),[('complete',2,2)])
def test_invalid_mode_counts_and_missing_scope_fail_before_database_creation(self):
for options in ({'coverage_mode':'unknown'}, self.incremental(True,2,4),
self.incremental(2,5,4),self.incremental(-1,2,4),
{'coverage_mode':'incremental'},
{**self.incremental(),'status':'complete'}):
with self.subTest(options=options):
with self.assertRaises(ValueError):self.ingest(self.file('invalid',['a'],**options))
self.assertFalse(self.db.exists())
if __name__=='__main__':
unittest.main()
+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')
+104
View File
@@ -87,6 +87,82 @@ class PostgreSQLIngestionTests(unittest.TestCase):
with redirect_stdout(io.StringIO()):
ingest_file(None, path, pg_service=self.service, pg_schema=self.schema)
def test_incremental_source_coverage_counts_prior_chunks_and_refreshes_on_postgres(self):
rows = [self.row(source='dblp',external=key,status='accepted',year=2001,venue='UAI')
for key in ('incremental1','incremental2','incremental3')]
listing = 'https://example.org/uai/2001'
def manifest(path, **options):
target=path.with_suffix('.manifest.json')
value=json.loads(target.read_text())
value.update(list_url=listing,status='partial',**options)
target.write_text(json.dumps(value))
first=self.file('initial',rows[:2]);manifest(first);self.ingest(first)
second=self.file('append',[rows[2]])
manifest(second,coverage_mode='incremental',coverage_previous_count=2,
coverage_previous_expected_count=2,coverage_expected_count=4)
self.ingest(second)
manifest(second,coverage_mode='incremental',coverage_previous_count=3,
coverage_previous_expected_count=4,coverage_expected_count=4)
self.ingest(second)
with closing(ReadConnection(self.service,self.schema)) as c:
coverage=c.execute('SELECT status,expected_count,imported_count FROM edition_sources WHERE list_url=?',(listing,)).fetchone()
self.assertEqual(tuple(coverage.values),('partial',4,3))
stored=c.execute('SELECT abstract FROM source_records WHERE external_id=?',('incremental3',)).fetchone()
self.assertEqual(stored[0],rows[2]['abstract'])
self.assertEqual(c.execute("SELECT count(*) FROM source_records r JOIN sources s ON s.id=r.source_id WHERE s.name='dblp'").fetchone()[0],3)
run=c.execute('SELECT records_written,checkpoint_json FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()
self.assertEqual(run[0],1)
self.assertEqual(json.loads(run[1])['coverage']['imported_count'],3)
def test_incremental_postgres_bound_failure_rolls_back_rows_and_summary(self):
listing='https://example.org/uai/2001'
rows=[self.row(source='dblp',external=key,status='accepted',year=2001,venue='UAI')
for key in ('bound1','bound2','bound3')]
first=self.file('initial',rows[:2]);target=first.with_suffix('.manifest.json')
value=json.loads(target.read_text());value.update(list_url=listing,status='partial');target.write_text(json.dumps(value))
self.ingest(first)
with closing(ReadConnection(self.service,self.schema)) as c:
before={table:c.execute('SELECT count(*) FROM '+table).fetchone()[0]
for table in ('papers','source_records','submissions','decisions')}
path=self.file('overflow',[rows[2]]);target=path.with_suffix('.manifest.json')
value=json.loads(target.read_text());value.update(list_url=listing,status='partial',coverage_mode='incremental',
coverage_previous_count=2,coverage_previous_expected_count=2,coverage_expected_count=2)
target.write_text(json.dumps(value))
with self.assertRaisesRegex(ValueError,'source bounds'):self.ingest(path)
with closing(ReadConnection(self.service,self.schema)) as c:
self.assertEqual({table:c.execute('SELECT count(*) FROM '+table).fetchone()[0] for table in before},before)
coverage=c.execute('SELECT status,expected_count,imported_count FROM edition_sources WHERE list_url=?',(listing,)).fetchone()
self.assertEqual(tuple(coverage.values),('partial',2,2))
self.assertEqual(c.execute('SELECT status FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()[0],'failed')
def test_incremental_postgres_does_not_overwrite_a_concurrently_changed_parent(self):
listing='https://example.org/uai/2001'
rows=[self.row(source='dblp',external=key,status='accepted',year=2001,venue='UAI')
for key in ('concurrent1','concurrent2','concurrent3')]
first=self.file('initial',rows[:2]);target=first.with_suffix('.manifest.json')
value=json.loads(target.read_text());value.update(list_url=listing,status='partial');target.write_text(json.dumps(value))
self.ingest(first)
path=self.file('racing',[rows[2]]);target=path.with_suffix('.manifest.json')
value=json.loads(target.read_text());value.update(list_url=listing,status='partial',coverage_mode='incremental',
coverage_previous_count=2,coverage_previous_expected_count=2,coverage_expected_count=4)
target.write_text(json.dumps(value))
original=Store.record
def change_parent(store,*args,**kwargs):
result=original(store,*args,**kwargs)
# A separate committed SQL writer need not honor the native
# advisory lock. Its update must survive our rejected file.
with connect(self.service) as other:
other.execute('SET search_path TO '+quote(self.schema)+',pg_catalog')
other.execute('UPDATE edition_sources SET expected_count=99 WHERE list_url=%s',(listing,))
return result
with patch.object(Store,'record',change_parent):
with self.assertRaisesRegex(ValueError,'parent changed during import'):self.ingest(path)
with closing(ReadConnection(self.service,self.schema)) as c:
coverage=c.execute('SELECT status,expected_count,imported_count FROM edition_sources WHERE list_url=?',(listing,)).fetchone()
self.assertEqual(tuple(coverage.values),('partial',99,2))
self.assertEqual(c.execute('SELECT count(*) FROM source_records WHERE external_id=?',('concurrent3',)).fetchone()[0],0)
self.assertEqual(c.execute('SELECT status FROM ingest_runs ORDER BY id DESC LIMIT 1').fetchone()[0],'failed')
def test_native_writes_keep_nul_historical_ids_and_later_acceptance(self):
store = self.store()
try:
@@ -110,6 +186,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)