Compare commits

..

4 Commits

Author SHA1 Message Date
codex-bot 88c2819edd Keep legacy source refreshes in their verified joint editions
Metadata integrity checks / metadata (push) Successful in 1m13s
2026-11-08 19:15:26 +08:00
codex-bot 866aeaf068 Read PostgreSQL mapping rows by position during joint preflight
Metadata integrity checks / metadata (push) Successful in 1m13s
2026-11-08 19:10:10 +08:00
codex-bot c05b979b01 Bind repair execution receipts to the reviewed plan hash 2026-11-08 19:09:27 +08:00
codex-bot 3f81734e69 Repair verified joint edition membership without reimporting papers
Metadata integrity checks / metadata (push) Failing after 1m5s
2026-11-08 19:07:00 +08:00
20 changed files with 496 additions and 575 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
subprocess.run([str(venv/'bin/paper-atlas'),'--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','collect','cvf','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','postgres','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','event-identity','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','joint-editions','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','import','records','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','import','arxiv','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','maintain','backfill','--help'],cwd=outside,check=True)
+1 -1
View File
@@ -87,4 +87,4 @@ CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目
开发通过工作分支和 PR 交付,由用户手动合并。
同年多届的 `event_key`、显式迁移、旧文件刷新与查询边界见 [会议事件身份](docs/event-identity.md)。
历史联合会议的既有届次归属可通过 [`maintain joint-editions`](docs/joint-editions.md) 先只读规划,再按计划哈希执行;保留论文与投稿决定来源,不重导入。
-44
View File
@@ -1,44 +0,0 @@
# 同年多届会议的事件身份
`venue`、`year`、`track` 描述系列、年份和投稿轨道;`event_key` 区分同系列、同年、同轨道的实际会议事件。例如 WWW 1995 的春季与秋季会议分别使用 `www-1995-spring` 和 `www-1995-fall`,两者仍是 `main`。相同论文可以在两届有不同投稿与决定;论文去重不会把这两次投稿合成一次。
普通单届数据继续使用空键。新输入在同一组已有具名事件或已知同年多届时必须提供明确键;不能任选第一个 edition。已有来源刷新可依据自己的原投稿关系回到原事件,不能被显式改到另一事件。精确固定 DBLP 快照、WWW Fall 书目名、1995 年及 `www1995f.html` 卷共同支持旧秋季文件的归属;不按标题猜年份或季节。
规范 JSONL 行及 manifest 都可提供 `event_key`,非空值限制为最多80个小写字母、数字及 `._:-`,首字符为字母或数字。manifest 提供键时,每行必须一致;一份文件仍仅对应一个事件及轨道。跨行冲突会回滚整份业务导入。身份回填同样按事件选择,不能借机把来源移到另一届。
## 新样本与既有数据
新建 SQLite 样本的 `editions` 唯一键为 `(venue_id, year, track, event_key)`。已有 SQLite/PostgreSQL 不会因普通读写命令自动升级。其普通单届导入继续可用;WWW1994/1995或带事件键的导入会要求先迁移。冻结的 SQLite 生产备份仍受 cutover 标记保护,不能写入或升级。
先在 PostgreSQL 生成只读计划:
```sh
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas maintain event-identity \
--pg-service foundry-research --pg-schema research --out /data/paper-index/event-plan.json
```
检查计划的数据库身份、原 edition IDs、业务表数量、目标来源与历史指纹。精确计数在同一快照中按主键分页,每页最多5000行,只读取主键;不使用估计行数。CLI向stderr持续报告计数进度,避免大库长时间无输出。已有未标记 WWW1995 edition 必须逐投稿都有固定 DBLP Fall 卷证据,才会只为原 edition 填入 `www-1995-fall`;缺证或混合卷会拒绝计划。本文命令不表示生产已经迁移。
应用时传入同一文件及命令输出的文件 SHA256:
```sh
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas maintain event-identity \
--pg-service foundry-research --pg-schema research \
--apply-plan /data/paper-index/event-plan.json --plan-sha256 PLAN_FILE_SHA256 \
--out /data/paper-index/event-applied.json
```
原生 PostgreSQL writer 使用现有 schema advisory lock,并在事务中锁定 editions,核对计划后添加列及替换唯一约束。SQLite 样本使用独占写事务,重建父表时保留 IDs,并在提交前检查外键;不级联删除子记录。额外列、未知唯一约束或 SQLite 额外索引会拒绝自动升级,须单独评估。未改变原版四列的排序内容哈希、任何业务表数量或目标投稿/决定/来源内容,才可提交;失败回滚。迁移标记与 DDL 同事务。原计划立即重试为 `already_applied`;若后续业务已经变化,应重新生成计划,不重放旧计划。
ECML 与 PKDD 的联合举办前事件已有不同 venue/edition,保持原身份即可。这项迁移不会把它们合并,也不会创建新的论文、投稿或决定。WWW 春季及1994两届的逐篇采集、来源核验和导入仍须另做。
## 查询与验收
```sh
paper-atlas query --pg-service foundry-research --pg-schema research \
--venue WWW --year 1995 --track main --event-key www-1995-fall
```
详细投稿历史及 `known_acceptances` 返回 `event_key`。`--event-key` 可与 `--status` 一起限定同一事件中的最新已知决定。未迁移的旧库仍可普通查询,其事件键为空;不能声称已经区分季节。`audit histories` 的新库事件输出也保留键。
远程测试应覆盖同一作品的两届独立投稿、拒稿后另一届录用、原 IDs 与 Unicode/NUL 来源内容保留、旧文件刷新、歧义及错误键拒绝、整文件回滚、明确事件的身份回填、旧计划失效、重试与真实 PostgreSQL 迁移。生产应用后还须独立核验约束、来源和历史、全文索引及 foundry-core 健康;单元测试不替代生产或最终全库去重验收。
+67
View File
@@ -0,0 +1,67 @@
# Repairing joint conference membership
EURAI's [official history](https://www.eurai.org/ecai/history) identifies ECAI 2018
and 2022 as joint IJCAI/ECAI conferences. The pinned DBLP volumes
`conf/ijcai/2018` and `conf/ijcai/2022` use `IJCAI` as their booktitle and title.
Existing records can therefore already be present under IJCAI while an ECAI query
shows a missing year. Adding ECAI as a global alias to the ordinary IJCAI venue
would incorrectly include all other IJCAI years.
The existing canonical `ECAI+IJCAI` venue has both member aliases. This maintenance
command changes only the venue reference of the two existing `main` edition rows.
Edition IDs, submission IDs, decisions, source text, authors, identifiers, tags,
source links and coverage records stay intact. It does not merge or create papers
or submissions, resolve per-paper tracks, or turn reviews into negative decisions.
The DBLP collector applies the joint mapping only to these two exact main volume
keys; original booktitle/title remain in source metadata with the membership evidence.
The shared ingestion entry point also resolves these exact pinned source volumes before
creating an edition, so refreshing an older normalized file cannot recreate a separate
IJCAI edition. Other sources, years, workshop volumes and additional tracks retain their existing rules.
## Review a read-only plan
Use a runtime containing this repair command. The configured PostgreSQL writer lock
is used only for the apply operation; do not replace a newer ingestion runtime with
an older main-based feature branch.
```sh
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas maintain joint-editions \
--pg-service foundry-research --pg-schema research --out joint-plan.json
```
The preflight requires the existing joint venue and exactly its two member aliases,
one nonempty unambiguous edition per year, and matching pinned DBLP volume evidence
for every existing submission. A colliding target edition, missing provenance or
unexpected source scope fails closed. The plan binds current database/schema, IDs,
counts and full fingerprints of affected papers, identifiers, source records, tags,
submissions, decisions and their provenance, and coverage rows. Observation time is
recorded separately from conference year. These are scoped checks, not a final global
identity or conference completeness audit.
## Apply the exact reviewed plan
Compute the file's SHA256 after review and pass it explicitly:
```sh
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas maintain joint-editions \
--pg-service foundry-research --pg-schema research --plan joint-plan.json \
--plan-sha256 "$PLAN_SHA256" --out joint-applied.json
```
The plan hash is checked before opening a writer. Current evidence is checked again
under the dataset writer lock; both edition updates and an audit marker commit in one
transaction. Protected row hashes are checked again before commit. Changed evidence
requires a fresh reviewed plan. Existing output reports are preserved; choose a new
path for another run. SQLite samples use `--db sample.sqlite`; the frozen production
backup remains protected.
If a response is lost after commit, rerun with the same plan and a new output path.
When protected evidence is unchanged and both editions already point to the joint
venue, the result is `already_joint` with zero updates. It does not replay imports or
regenerate source records. Keep the plan, apply report and independent postcheck
alongside collection evidence. The audit marker is `joint_ijcai_ecai_2018_2022_v1`.
Publication counts can differ between the DBLP volume and the current publisher
page. This membership repair preserves all existing rows rather than deleting entries
to force an aggregate match; section-level main/invited/other classification and
publication completeness require separate per-paper evidence.
+1 -5
View File
@@ -124,11 +124,7 @@ def report(db=None, *, pg_service=None, pg_schema=None, examples=20):
sample_attempts.append(rows)
current = []
from paper_atlas.db.events import has_event_key
attempts_sql = ATTEMPTS
if has_event_key(connection):
attempts_sql = attempts_sql.replace('e.year,e.track,', 'e.year,e.track,e.event_key,', 1).replace('e.year,e.track\n', 'e.year,e.track,e.event_key\n')
for raw in connection.execute(attempts_sql):
for raw in connection.execute(ATTEMPTS):
row = dict(raw)
if current and row['paper_id'] != current[0]['paper_id']:
consume(current)
+1 -1
View File
@@ -25,7 +25,7 @@ COMMANDS = {
('catalogue','dblp'): ('paper_atlas.collectors.dblp', {}, '盘点 DBLP 历史会议目录'),
('import','arxiv'): ('paper_atlas.ingestion.arxiv', {}, '导入 arXiv 元数据 ZIP'),
('import','records'): ('paper_atlas.ingestion.records', {}, '导入核验过的会议文件'),
('maintain','event-identity'): ('paper_atlas.ingestion.event_identity', {}, '按核验计划增加同年会议事件身份'),
('maintain','joint-editions'): ('paper_atlas.ingestion.joint_editions', {}, '按已核验计划修正既有联合会议届次归属'),
('maintain','merge'): ('paper_atlas.ingestion.maintenance', {}, '协调单写入合并与恢复'),
('maintain','backfill'): ('paper_atlas.ingestion.backfill', {}, '回填既有投稿的来源身份'),
('maintain','arxiv-tags'): ('paper_atlas.ingestion.arxiv_tags', {}, '恢复逐来源记录的 arXiv 类别证据'),
+2
View File
@@ -88,6 +88,8 @@ def normalize_row(row,volumes,targets,include_workshops=False,through_year=2026)
'scope_rule':edition['rule'],'author_names_raw':fields.get('author',[]),'publtype':row.get('publtype'),
'bibliography':{key:value for key,value in fields.items() if key in ('booktitle','year','ee','url','pages','volume','number','publisher','series','isbn')},
'ambiguous_identifiers':ambiguous,'publication_evidence':'inproceedings bibliography; not an original decision note'}
if edition.get('joint_edition_evidence'):
metadata['joint_edition_evidence'] = edition['joint_edition_evidence']
if edition.get('track_scope_note'):
metadata['track_scope_note']=edition['track_scope_note']
flagged=bool(re.search(r'\b(?:withdrawn|retracted)\b',row.get('publtype') or '',re.I))
+7
View File
@@ -96,8 +96,15 @@ def scope(row,volume,through_year=2026):
elif suffix.casefold() in ('poster','posters') and venue not in ('ICLR','ACL','EMNLP','NAACL','EACL','COLING','CoNLL'):track='poster'
if venue=='AAAI+IAAI':track='proceedings_unspecified'
if venue=='AAMAS' and year<2002:return None,'predecessor_not_aamas'
joint_evidence = None
if (family == 'ijcai' and base == 'IJCAI' and track == 'main'
and year in (2018, 2022) and volume['key'] == 'conf/ijcai/' + str(year)):
venue, aliases = 'ECAI+IJCAI', ['ECAI', 'IJCAI']
joint_evidence = 'https://www.eurai.org/ecai/history'
result = {'venue':venue,'venue_aliases':aliases,'year':year,'track':track,'bibliographic_year':int(bibliographic[0]),
'rule':family+':'+base,'proceedings_title':title,'edition_year_basis':year_basis}
if joint_evidence:
result['joint_edition_evidence'] = joint_evidence
if venue=='MM' and track=='main':
# A main MM volume also includes doctoral, tutorials, companion papers
# and other sections. DBLP XML omits the per-paper TOC section.
-71
View File
@@ -1,71 +0,0 @@
"""Stable event identity within a venue/year/track, independent of paper identity."""
import json
import re
def has_event_key(c):
if getattr(c, 'postgres', False):
return bool(c.execute("SELECT 1 FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='editions' AND column_name='event_key'").fetchone())
return any(row[1] == 'event_key' for row in c.execute('PRAGMA table_info(editions)'))
def validate_event_key(value):
if value is not None and (not isinstance(value, str) or
(value != '' and not re.fullmatch(r'[a-z0-9][a-z0-9._:-]{0,79}', value))):
raise ValueError('Invalid conference event_key')
return value
def pinned_www_fall(source, metadata):
from paper_atlas.collectors.dblp import XML_SHA
b = metadata.get('bibliography') or {}
urls = b.get('url') or []
return (source == 'dblp' and metadata.get('snapshot_sha256') == XML_SHA
and metadata.get('bibliographic_year') == 1995
and str(metadata.get('proceedings_key', '')).startswith('conf/www/')
and b.get('booktitle') == ['WWW (Fall)'] and len(urls) == 1
and urls[0].split('#')[0] == 'db/conf/www/www1995f.html')
def find_edition(store, venue_id, venue, year, track, record, external_id,
event_key=None, *, create=True):
c = store.conn
validate_event_key(event_key)
enabled = has_event_key(c) if getattr(store, 'event_identity', None) is None else store.event_identity
known_multi = venue.upper() == 'WWW' and int(year) in (1994, 1995)
source = c.execute('SELECT src.name,r.metadata_json FROM source_records r JOIN sources src ON src.id=r.source_id WHERE r.id=?', (record,)).fetchone()
inferred = ('www-1995-fall' if known_multi and int(year) == 1995 and source
and pinned_www_fall(source[0], json.loads(source[1])) else None)
if inferred:
if event_key is not None and event_key != inferred:
raise ValueError('event_key conflicts with pinned WWW Fall source evidence')
event_key = inferred
if not enabled:
if event_key or known_multi:
raise ValueError('Explicit event identity migration required before this conference import')
row = c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=?', (venue_id, year, track)).fetchone()
if row or not create:
return row[0] if row else None
c.execute('INSERT INTO editions(venue_id,year,track) VALUES(?,?,?) ON CONFLICT DO NOTHING', (venue_id, year, track))
return c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=?', (venue_id, year, track)).fetchone()[0]
anchored = c.execute('''SELECT DISTINCT e.id,e.event_key FROM editions e JOIN submissions s ON s.edition_id=e.id
JOIN submission_sources ss ON ss.submission_id=s.id WHERE e.venue_id=? AND e.year=? AND e.track=?
AND ss.source_record_id=? AND ss.source_submission_id=?''', (venue_id, year, track, record, external_id)).fetchall()
if len(anchored) > 1:
raise ValueError('Source record already has ambiguous conference events')
if anchored:
if event_key is not None and event_key != anchored[0][1]:
raise ValueError('Cannot move an existing source to another conference event')
event_key = anchored[0][1]
if event_key is None:
rows = c.execute('SELECT event_key FROM editions WHERE venue_id=? AND year=? AND track=?', (venue_id, year, track)).fetchall()
if len(rows) > 1 or any(row[0] for row in rows):
raise ValueError('Explicit event_key required; conference event is ambiguous')
event_key = ''
if known_multi and not event_key:
raise ValueError('Explicit event_key required for same-year WWW events')
row = c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=? AND event_key=?', (venue_id, year, track, event_key)).fetchone()
if row or not create:
return row[0] if row else None
c.execute('INSERT INTO editions(venue_id,year,track,event_key) VALUES(?,?,?,?) ON CONFLICT DO NOTHING', (venue_id, year, track, event_key))
return c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=? AND event_key=?', (venue_id, year, track, event_key)).fetchone()[0]
+1 -2
View File
@@ -29,8 +29,7 @@ CREATE TABLE IF NOT EXISTS venues (
CREATE TABLE IF NOT EXISTS editions (
id INTEGER PRIMARY KEY, venue_id INTEGER NOT NULL REFERENCES venues(id),
year INTEGER NOT NULL, track TEXT NOT NULL DEFAULT 'main',
event_key TEXT NOT NULL DEFAULT '',
UNIQUE(venue_id,year,track,event_key)
UNIQUE(venue_id,year,track)
);
CREATE TABLE IF NOT EXISTS venue_aliases (
venue_id INTEGER NOT NULL REFERENCES venues(id), alias TEXT NOT NULL,
+17 -5
View File
@@ -36,8 +36,6 @@ class Store:
self.conn.execute('PRAGMA synchronous=NORMAL')
self.conn.execute('PRAGMA cache_size=-32768')
migrate_sources(self.conn)
from paper_atlas.db.events import has_event_key
self.event_identity = has_event_key(self.conn)
def source(self,name,url):
self.conn.execute('INSERT INTO sources(name,homepage) VALUES(?,?) ON CONFLICT DO NOTHING',(name,url))
@@ -144,15 +142,29 @@ class Store:
tag=c.execute('SELECT id FROM tags WHERE namespace=? AND value=?',(namespace,value)).fetchone()[0]
c.execute('INSERT INTO paper_tags VALUES(?,?,?,?) ON CONFLICT DO NOTHING',(paper,tag,record,evidence))
def decision(self,paper,record,venue,year,track,external_id,url,status,raw_value,aliases=None,identities=None,observed_at=None,decision_date=None,source_decision_id='',submitted_at=None,guard_status=False,event_key=None):
def decision(self,paper,record,venue,year,track,external_id,url,status,raw_value,aliases=None,identities=None,observed_at=None,decision_date=None,source_decision_id='',submitted_at=None,guard_status=False):
c = self.conn
# An already collected file may still call these exact pinned volumes
# IJCAI. Resolve membership before finding/creating an edition so a
# later refresh cannot split the repaired joint event again.
if venue == 'IJCAI' and year in (2018,2022) and track == 'main':
from paper_atlas.collectors.dblp import XML_SHA
evidence=c.execute('SELECT src.name,r.metadata_json FROM source_records r JOIN sources src ON src.id=r.source_id WHERE r.id=?',(record,)).fetchone()
if evidence and evidence[0] == 'dblp':
metadata=json.loads(evidence[1])
if (metadata.get('snapshot_sha256') == XML_SHA
and metadata.get('proceedings_key') == 'conf/ijcai/'+str(year)
and metadata.get('bibliographic_year') == year
and (metadata.get('bibliography') or {}).get('booktitle') in (['IJCAI'],['IJCAI/ECAI'])):
venue='ECAI+IJCAI'
aliases=sorted(set(aliases or []) | {'ECAI','IJCAI'})
existing=c.execute('SELECT id FROM venues WHERE lower(acronym)=lower(?)',(venue,)).fetchone()
if existing:venue_id=existing[0]
else:venue_id=inserted_id(c,"INSERT INTO venues(acronym,name,field,venue_type) VALUES(?,?,?,'conference')",(venue,venue,'unclassified'))
for alias in aliases or []:
c.execute('INSERT INTO venue_aliases VALUES(?,?) ON CONFLICT DO NOTHING',(venue_id,alias))
from paper_atlas.db.events import find_edition
edition = find_edition(self,venue_id,venue,int(year),track,record,external_id,event_key)
c.execute('INSERT INTO editions(venue_id,year,track) VALUES(?,?,?) ON CONFLICT DO NOTHING',(venue_id,int(year),track))
edition = c.execute('SELECT id FROM editions WHERE venue_id=? AND year=? AND track=?',(venue_id,int(year),track)).fetchone()[0]
# A previous event in this row may already have merged the input paper.
paper=c.execute('SELECT paper_id FROM source_records WHERE id=?',(record,)).fetchone()[0]
submission=attach_submission(self,edition,paper,record,external_id,identities or [])
+1 -6
View File
@@ -37,14 +37,9 @@ def backfill(db,files, *, pg_service=None, pg_schema=None):
seen.add(row['external_id'])
if any(row.get(key)!=manifest.get(key) for key in ('source','venue','year','track')):
raise ValueError('Evidence row differs from edition manifest')
if 'event_key' in manifest and row.get('event_key')!=manifest['event_key']:
raise ValueError('Event identity differs from manifest')
require_track_record(row, manifest)
record=c.execute('SELECT r.id,r.paper_id,r.metadata_json FROM source_records r JOIN sources s ON s.id=r.source_id WHERE s.name=? AND r.external_id=?',(row['source'],row['external_id'])).fetchone()
from paper_atlas.db.events import find_edition
venue=c.execute('SELECT id FROM venues WHERE lower(acronym)=lower(?)',(row['venue'],)).fetchone()
eid=find_edition(store,venue[0],row['venue'],row['year'],row['track'],record[0],row['external_id'],row.get('event_key'),create=False) if record and venue else None
edition=(eid,) if eid is not None else None
edition=c.execute('SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE lower(v.acronym)=lower(?) AND e.year=? AND e.track=?',(row['venue'],row['year'],row['track'])).fetchone()
if not record or not edition:raise ValueError('Evidence backfill requires an already imported record and edition')
if not c.execute('SELECT 1 FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id WHERE ss.source_record_id=? AND ss.source_submission_id=? AND s.edition_id=?',(record[0],row['external_id'],edition[0])).fetchone():
raise ValueError('No existing submission to backfill')
-217
View File
@@ -1,217 +0,0 @@
"""Explicit, plan-bound edition event-key upgrade; never reimport submissions."""
import argparse
from contextlib import closing
import hashlib
import json
from pathlib import Path
import sqlite3
import sys
from paper_atlas.db.backend import database_arguments, open_read_connection, require_sqlite_writable
from paper_atlas.db.events import has_event_key, pinned_www_fall
from paper_atlas.postgres.schema import TABLES, quote
MARKER = 'edition_event_identity_v1:'
def digest(value):
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def values(rows):
return [[r[i] for i in range(len(r))] for r in rows]
def exact_counts(c, *, page_size=5000, progress=None):
"""Count every row in the same snapshot through bounded primary-key pages."""
if not isinstance(page_size, int) or page_size < 1:
raise ValueError('Positive count page size required')
result = {}
for table in TABLES:
if table == 'schema_migrations':
continue
if getattr(c, 'postgres', False):
columns = [r[0] for r in c.execute("SELECT a.attname FROM pg_constraint pc JOIN pg_attribute a ON a.attrelid=pc.conrelid AND a.attnum=ANY(pc.conkey) WHERE pc.conrelid=?::regclass AND pc.contype='p' ORDER BY array_position(pc.conkey,a.attnum)", (table,))]
else:
columns = [r[1] for r in sorted(c.execute('PRAGMA table_info('+quote(table)+')'), key=lambda r:r[5]) if r[5]]
if not columns:
raise ValueError('Primary key required for exact count: '+table)
fields = ','.join(quote(column) for column in columns)
last = None; count = 0; pages = 0
if progress:
progress({'table':table, 'rows_counted':0, 'complete':False})
while True:
where = (' WHERE ('+fields+') > ('+','.join('?' for _ in columns)+')') if last is not None else ''
batch = values(c.execute('SELECT '+fields+' FROM '+quote(table)+where+' ORDER BY '+fields+' LIMIT ?', (*last, page_size) if last is not None else (page_size,)).fetchall())
count += len(batch); pages += 1
complete = len(batch) < page_size
if progress and (complete or pages % 20 == 0):
progress({'table':table, 'rows_counted':count, 'complete':complete})
if complete:
break
last = batch[-1]
result[table] = count
return result
def inspect(c, *, progress=None):
enabled = has_event_key(c)
if getattr(c, 'postgres', False):
columns = [r[0] for r in c.execute("SELECT column_name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='editions' ORDER BY ordinal_position")]
unique = [r[0] for r in c.execute("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid='editions'::regclass AND contype='u'")]
expected_unique = ['UNIQUE (venue_id, year, track, event_key)' if enabled else 'UNIQUE (venue_id, year, track)']
else:
columns = [r[1] for r in c.execute('PRAGMA table_info(editions)')]
indexes = list(c.execute('PRAGMA index_list(editions)'))
unique = [tuple(r[2] for r in c.execute('PRAGMA index_info('+quote(index[1])+')')) for index in indexes if index[2]]
expected_unique = [('venue_id','year','track','event_key') if enabled else ('venue_id','year','track')]
if len(indexes) != 1:
raise ValueError('Unexpected edition indexes; require a reviewed migration')
if columns != ['id','venue_id','year','track'] + (['event_key'] if enabled else []) or unique != expected_unique:
raise ValueError('Unexpected edition schema; require a reviewed migration')
rows = values(c.execute('SELECT id,venue_id,year,track FROM editions ORDER BY id'))
keys = values(c.execute('SELECT id,event_key FROM editions ORDER BY id')) if enabled else [[r[0], ''] for r in rows]
counts = exact_counts(c, progress=progress)
targets = values(c.execute("SELECT e.id FROM editions e JOIN venues v ON v.id=e.venue_id WHERE v.acronym='WWW' AND e.year=1995 AND e.track='main' ORDER BY e.id"))
assignments = []
for (eid,) in targets:
current_key = dict(keys)[eid]
if current_key:
continue
evidence = values(c.execute('''SELECT s.id,src.name,r.id,r.external_id,r.metadata_json FROM submissions s
JOIN submission_sources ss ON ss.submission_id=s.id JOIN source_records r ON r.id=ss.source_record_id
JOIN sources src ON src.id=r.source_id WHERE s.edition_id=? ORDER BY s.id,r.id''', (eid,)))
count = c.execute('SELECT count(*) FROM submissions WHERE edition_id=?', (eid,)).fetchone()[0]
supported = {r[0] for r in evidence if pinned_www_fall(r[1], json.loads(r[4]))}
if not count or len(supported) != count:
raise ValueError('Existing unlabelled WWW1995 edition lacks complete pinned Fall evidence')
assignments.append({'edition_id':eid, 'event_key':'www-1995-fall', 'submissions':count,
'source_evidence_sha256':digest(evidence)})
# Protect all rows in the affected attempt history, including non-DBLP evidence.
target_ids = [r[0] for r in targets]
protected = {}
for table,sql in {
'submissions': 'SELECT s.* FROM submissions s WHERE s.edition_id IN ({}) ORDER BY s.id',
'decisions': 'SELECT d.* FROM decisions d JOIN submissions s ON s.id=d.submission_id WHERE s.edition_id IN ({}) ORDER BY d.id',
'submission_sources': 'SELECT ss.* FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id WHERE s.edition_id IN ({}) ORDER BY ss.submission_id,ss.source_record_id,ss.source_submission_id',
'decision_sources': 'SELECT ds.* FROM decision_sources ds JOIN decisions d ON d.id=ds.decision_id JOIN submissions s ON s.id=d.submission_id WHERE s.edition_id IN ({}) ORDER BY ds.decision_id,ds.source_record_id,ds.source_decision_id',
'source_records': 'SELECT DISTINCT r.* FROM source_records r JOIN submission_sources ss ON ss.source_record_id=r.id JOIN submissions s ON s.id=ss.submission_id WHERE s.edition_id IN ({}) ORDER BY r.id',
}.items():
records = values(c.execute(sql.format(','.join('?' for _ in target_ids)), target_ids)) if target_ids else []
protected[table] = {'count':len(records), 'sha256':digest(records)}
return {'event_column':enabled, 'editions_sha256':digest(rows), 'edition_ids':[r[0] for r in rows],
'event_keys':keys, 'counts':counts, 'assignments':assignments, 'protected':protected}
def database_identity(c, db=None):
if getattr(c, 'postgres', False):
r = c.execute('SELECT current_database(),current_schema()').fetchone()
return {'engine':'postgres', 'database':r[0], 'schema':r[1]}
return {'engine':'sqlite', 'path':str(Path(db).resolve())}
def plan(db=None, *, pg_service=None, pg_schema=None, progress=None):
with closing(open_read_connection(db, pg_service=pg_service, pg_schema=pg_schema)) as c:
return {'format':1, 'database':database_identity(c, db), 'before':inspect(c, progress=progress)}
def apply_plan(c, proposal, *, progress=None):
before = proposal['before']
if proposal.get('format') != 1:
raise ValueError('Unsupported event identity plan')
if getattr(c, 'postgres', False):
c.execute('LOCK TABLE editions IN ACCESS EXCLUSIVE MODE')
current = inspect(c, progress=progress)
signature = digest(proposal)
marker = MARKER + signature
if c.execute('SELECT 1 FROM schema_migrations WHERE name=?', (marker,)).fetchone():
expected_keys = dict(before['event_keys'])
expected_keys.update({a['edition_id']:a['event_key'] for a in before['assignments']})
if (not current['event_column'] or current['editions_sha256'] != before['editions_sha256']
or current['protected'] != before['protected'] or current['counts'] != before['counts']
or dict(current['event_keys']) != expected_keys):
raise ValueError('Applied plan no longer matches current data; generate a new plan')
return {'status':'already_applied', 'updated_editions':0, 'after':current}
if current != before:
raise ValueError('Event identity plan is stale; generate a new plan')
if not current['event_column']:
if getattr(c, 'postgres', False):
constraints = c.execute("SELECT conname,pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid='editions'::regclass AND contype='u'").fetchall()
matches = [name for name,definition in constraints if definition == 'UNIQUE (venue_id, year, track)']
if len(matches) != 1 or len(constraints) != 1:
raise ValueError('Unexpected legacy edition uniqueness')
c.execute("ALTER TABLE editions ADD COLUMN event_key TEXT COLLATE \"C\" NOT NULL DEFAULT ''")
c.execute('ALTER TABLE editions DROP CONSTRAINT '+quote(matches[0]))
c.execute('ALTER TABLE editions ADD CONSTRAINT editions_event_identity_unique UNIQUE(venue_id,year,track,event_key)')
else:
unique = [tuple(r[2] for r in c.execute('PRAGMA index_info('+quote(index[1])+')'))
for index in c.execute('PRAGMA index_list(editions)') if index[2]]
if unique != [('venue_id','year','track')]:
raise ValueError('Unexpected legacy edition uniqueness')
c.execute("CREATE TABLE editions_event_upgrade(id INTEGER PRIMARY KEY,venue_id INTEGER NOT NULL REFERENCES venues(id),year INTEGER NOT NULL,track TEXT NOT NULL DEFAULT 'main',event_key TEXT NOT NULL DEFAULT '',UNIQUE(venue_id,year,track,event_key))")
c.execute('INSERT INTO editions_event_upgrade(id,venue_id,year,track) SELECT id,venue_id,year,track FROM editions')
c.execute('DROP TABLE editions')
c.execute('ALTER TABLE editions_event_upgrade RENAME TO editions')
for item in before['assignments']:
result = c.execute("UPDATE editions SET event_key=? WHERE id=? AND event_key=''", (item['event_key'],item['edition_id']))
if result.rowcount != 1:
raise ValueError('Edition assignment changed during upgrade')
c.execute('INSERT INTO schema_migrations(name) VALUES(?)', (marker,))
after = inspect(c, progress=progress)
expected_keys = dict(before['event_keys'])
expected_keys.update({a['edition_id']:a['event_key'] for a in before['assignments']})
if (after['editions_sha256'] != before['editions_sha256'] or after['protected'] != before['protected']
or after['counts'] != before['counts'] or dict(after['event_keys']) != expected_keys):
raise ValueError('Event upgrade changed protected data')
if not getattr(c, 'postgres', False) and c.execute('PRAGMA foreign_key_check').fetchall():
raise ValueError('Foreign key violation after event upgrade')
return {'status':'applied', 'updated_editions':len(before['assignments']), 'after':after}
def upgrade(proposal, db=None, *, pg_service=None, pg_schema=None, progress=None):
if pg_service:
from paper_atlas.postgres.writer import WriteConnection
with closing(WriteConnection(pg_service, pg_schema)) as c:
if proposal['database'] != database_identity(c):
raise ValueError('Plan targets a different database')
try:
result = apply_plan(c, proposal, progress=progress); c.commit(); return result
except BaseException:
c.rollback(); raise
require_sqlite_writable(db)
if not Path(db).is_file():
raise ValueError('Existing SQLite sample required')
with closing(sqlite3.connect(db, timeout=30)) as c:
if proposal['database'] != database_identity(c, db):
raise ValueError('Plan targets a different database')
c.execute('PRAGMA foreign_keys=OFF') # Outside the transaction; no cascading deletes.
try:
c.execute('BEGIN IMMEDIATE')
result = apply_plan(c, proposal, progress=progress); c.commit(); return result
except BaseException:
c.rollback(); raise
finally:
c.execute('PRAGMA foreign_keys=ON')
def main(argv=None, *, prog=None):
p = argparse.ArgumentParser(prog=prog); database_arguments(p)
p.add_argument('--out', required=True)
p.add_argument('--apply-plan'); p.add_argument('--plan-sha256')
args = p.parse_args(argv)
if bool(args.apply_plan) != bool(args.plan_sha256):
p.error('--apply-plan and --plan-sha256 must be supplied together')
target = Path(args.out)
if target.exists():
raise ValueError('Refusing to overwrite an existing plan/report')
progress = lambda event: print(json.dumps({'phase':'exact_row_count', **event}), file=sys.stderr, flush=True)
if args.apply_plan:
raw = Path(args.apply_plan).read_bytes()
if hashlib.sha256(raw).hexdigest() != args.plan_sha256:
raise ValueError('Plan file SHA256 differs from approved bytes')
result = upgrade(json.loads(raw), args.db, pg_service=args.pg_service, pg_schema=args.pg_schema, progress=progress)
else:
result = plan(args.db, pg_service=args.pg_service, pg_schema=args.pg_schema, progress=progress)
target.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
print(json.dumps({'out':str(target), 'sha256':hashlib.sha256(target.read_bytes()).hexdigest(),
'status':result.get('status','planned')}), flush=True)
+204
View File
@@ -0,0 +1,204 @@
"""Evidence-bound repair of two existing IJCAI/ECAI edition memberships."""
import argparse
from contextlib import closing
import hashlib
import json
from pathlib import Path
from paper_atlas.db.backend import database_arguments, open_read_connection, open_store
from paper_atlas.db.submissions import timestamp
from paper_atlas.collectors.dblp import XML_SHA
YEARS = (2018, 2022)
TARGET = 'ECAI+IJCAI'
MEMBERS = {'ECAI', 'IJCAI'}
EVIDENCE = 'https://www.eurai.org/ecai/history'
MARKER = 'joint_ijcai_ecai_2018_2022_v1'
def _digest(c, sql, params=()):
sha = hashlib.sha256(); count = 0
for row in c.execute(sql, params):
sha.update((json.dumps([row[i] for i in range(len(row))], ensure_ascii=False,
separators=(',', ':')) + '\n').encode())
count += 1
if count > 100000:
raise ValueError('Joint repair evidence scope exceeded bounded row limit')
return {'rows': count, 'sha256': sha.hexdigest()}
def fingerprints(c, ids):
marks = ','.join('?' for _ in ids)
submissions = 'SELECT id FROM submissions WHERE edition_id IN (' + marks + ')'
papers = 'SELECT paper_id FROM submissions WHERE edition_id IN (' + marks + ')'
decisions = 'SELECT id FROM decisions WHERE submission_id IN (' + submissions + ')'
checks = {
'papers': ('id IN (' + papers + ')', 'id'),
'identifiers': ('paper_id IN (' + papers + ')', 'scheme,value'),
'source_records': ('paper_id IN (' + papers + ')', 'id'),
'paper_tags': ('paper_id IN (' + papers + ')', 'paper_id,tag_id,evidence_key'),
'submissions': ('edition_id IN (' + marks + ')', 'id'),
'decisions': ('submission_id IN (' + submissions + ')', 'id'),
'submission_sources': ('submission_id IN (' + submissions + ')', 'submission_id,source_record_id,source_submission_id'),
'submission_identifiers': ('edition_id IN (' + marks + ')', 'edition_id,scheme,value'),
'decision_sources': ('decision_id IN (' + decisions + ')', 'decision_id,source_record_id,source_decision_id'),
'edition_sources': ('edition_id IN (' + marks + ')', 'edition_id,source_id,list_url'),
}
return {table: _digest(c, 'SELECT * FROM ' + table + ' WHERE ' + where + ' ORDER BY ' + order, ids)
for table, (where, order) in checks.items()}
def inspect(c):
target = c.execute('SELECT id FROM venues WHERE acronym=?', (TARGET,)).fetchall()
if len(target) != 1:
raise ValueError('Exactly one existing canonical joint venue required')
target = target[0][0]
aliases = {r[0] for r in c.execute('SELECT alias FROM venue_aliases WHERE venue_id=?', (target,))}
if aliases != MEMBERS:
raise ValueError('Joint target must already have exactly ECAI and IJCAI aliases')
editions = []
for year in YEARS:
rows = c.execute('''SELECT e.id,e.venue_id,v.acronym FROM editions e JOIN venues v ON v.id=e.venue_id
WHERE v.acronym IN ('IJCAI','ECAI+IJCAI') AND e.year=? AND e.track='main' ''', (year,)).fetchall()
if len(rows) != 1:
raise ValueError('Expected one existing edition per year; missing or colliding editions require separate review')
edition, venue, acronym = (rows[0][i] for i in range(3))
count = c.execute('SELECT count(*) FROM submissions WHERE edition_id=?', (edition,)).fetchone()[0]
if not 0 < count <= 5000:
raise ValueError('Existing edition must have a nonempty bounded submission scope')
# Every existing submission must be supported by its own pinned DBLP
# volume record. No title/author matching, new attempts or text writes.
evidence = c.execute('''SELECT s.id,r.external_id,r.metadata_json FROM submissions s
JOIN submission_sources ss ON ss.submission_id=s.id
JOIN source_records r ON r.id=ss.source_record_id JOIN sources src ON src.id=r.source_id
WHERE s.edition_id=? AND src.name='dblp' ORDER BY s.id,r.id''', (edition,)).fetchall()
seen = set()
for row in evidence:
sid, external, metadata = (row[i] for i in range(3))
meta = json.loads(metadata)
bibliography = meta.get('bibliography') or {}
if (meta.get('snapshot_sha256') != XML_SHA
or meta.get('proceedings_key') != 'conf/ijcai/' + str(year)
or meta.get('bibliographic_year') != year
or bibliography.get('booktitle') not in (['IJCAI'], ['IJCAI/ECAI'])
or not external.startswith('conf/ijcai/')):
raise ValueError('Source evidence outside the exact verified IJCAI/ECAI volume')
seen.add(sid)
if len(seen) != count:
raise ValueError('Every submission must have matching original volume evidence')
editions.append({'id': edition, 'year': year, 'track': 'main', 'from_venue_id': venue,
'from_venue': acronym, 'target_venue_id': target, 'submissions': count,
'evidence_url': EVIDENCE, 'proceedings_key': 'conf/ijcai/' + str(year)})
ids = tuple(e['id'] for e in editions)
return {'format_version': 1, 'kind': MARKER, 'checked_at': timestamp(), 'editions': editions,
'content_fingerprints': fingerprints(c, ids), 'target_aliases': sorted(aliases),
'status': 'already_joint' if all(e['from_venue_id'] == target for e in editions) else 'ready',
'scope': 'Only these two existing edition venue references; no source text, paper, submission, decision, track, identifier or tag change.'}
def validate_plan(expected):
if not isinstance(expected, dict):
raise ValueError('Repair plan must be a JSON object')
if set(expected) != {'format_version','kind','checked_at','editions','content_fingerprints','target_aliases','status','scope','database'}:
raise ValueError('Unexpected repair plan fields')
if (type(expected['format_version']) is not int or expected['format_version'] != 1
or expected['kind'] != MARKER or expected['status'] not in ('ready','already_joint')):
raise ValueError('Unsupported repair plan')
if (not isinstance(expected['database'],dict) or not isinstance(expected['content_fingerprints'],dict)
or not isinstance(expected['editions'],list) or len(expected['editions']) != 2
or expected['target_aliases'] != sorted(MEMBERS)):
raise ValueError('Invalid repair plan structure')
for edition, year in zip(expected['editions'], YEARS):
if not isinstance(edition,dict) or edition.get('year') != year:
raise ValueError('Repair plan must name the two exact ordered years')
for key in ('id','year','from_venue_id','target_venue_id','submissions'):
if type(edition.get(key)) is not int or edition[key] <= 0:
raise ValueError('Repair plan IDs/counts must be positive integers')
def apply_plan(c, expected):
validate_plan(expected)
actual = inspect(c)
if actual['content_fingerprints'] != expected['content_fingerprints'] or actual['target_aliases'] != expected['target_aliases']:
raise ValueError('Stale repair plan: protected content or aliases changed')
if len(expected['editions']) != 2:
raise ValueError('Exactly two editions required')
for old, now in zip(expected['editions'], actual['editions']):
if set(old) != set(now):
raise ValueError('Unexpected edition fields')
for key in ('id','year','track','target_venue_id','submissions','evidence_url','proceedings_key'):
if old[key] != now[key]:
raise ValueError('Stale repair plan: edition differs')
if now['from_venue_id'] != old['from_venue_id'] and now['from_venue_id'] != now['target_venue_id']:
raise ValueError('Edition membership differs from plan')
if actual['status'] == 'already_joint':
return {**actual, 'status':'already_joint', 'updated_editions':0}
for edition in actual['editions']:
if edition['from_venue_id'] == edition['target_venue_id']:
continue
changed = c.execute('''UPDATE editions SET venue_id=? WHERE id=? AND venue_id=? AND year=? AND track=?''',
(edition['target_venue_id'],edition['id'],edition['from_venue_id'],edition['year'],edition['track']))
if changed.rowcount != 1:
raise ValueError('Concurrent edition change; entire repair must roll back')
after = inspect(c)
if after['status'] != 'already_joint' or after['content_fingerprints'] != actual['content_fingerprints']:
raise ValueError('Postcheck changed protected content; entire repair must roll back')
c.execute('INSERT INTO schema_migrations(name) VALUES(?) ON CONFLICT DO NOTHING', (MARKER,))
return {**after, 'status':'applied', 'updated_editions':sum(e['from_venue_id'] != e['target_venue_id'] for e in actual['editions'])}
def database_identity(c, db):
if getattr(c, 'postgres', False):
row = c.execute('SELECT current_database(),current_schema()').fetchone()
return {'engine':'postgresql','database':row[0],'schema':row[1]}
return {'engine':'sqlite','path':str(Path(db).resolve())}
def run(db, out, *, pg_service=None, pg_schema=None, plan=None, plan_sha256=None):
out = Path(out).resolve()
if not out.parent.is_dir():
raise ValueError('Report parent directory must already exist')
if out.exists():
raise ValueError('Preserve existing evidence report; choose a new output path')
expected = None
if plan:
raw = Path(plan).read_bytes()
if hashlib.sha256(raw).hexdigest() != plan_sha256:
raise ValueError('Repair plan hash differs before opening database')
expected = json.loads(raw)
validate_plan(expected)
elif plan_sha256:
raise ValueError('Plan hash requires an existing plan')
if db and out == Path(db).resolve():
raise ValueError('Report must not overwrite database')
writer = open_store(db, pg_service=pg_service, pg_schema=pg_schema) if expected is not None else None
c = writer.conn if writer else open_read_connection(db, pg_service=pg_service, pg_schema=pg_schema)
with closing(c):
try:
if expected is not None and not getattr(c, 'postgres', False):
c.commit(); c.execute('BEGIN IMMEDIATE')
identity = database_identity(c, db)
if expected is not None and expected['database'] != identity:
raise ValueError('Plan belongs to another database/schema')
report = apply_plan(c, expected) if expected is not None else inspect(c)
report['database'] = identity
if expected is not None:
report['plan_sha256'] = plan_sha256
if expected is not None:
c.commit()
except BaseException:
if expected is not None: c.rollback()
raise
out.write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n')
print(json.dumps({'status':report['status'],'editions':len(report['editions']),
'updated_editions':report.get('updated_editions',0),'report':str(out)}))
return report
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog); database_arguments(parser)
parser.add_argument('--out', required=True)
parser.add_argument('--plan', help='Apply an already reviewed plan; omitted means read-only preflight')
parser.add_argument('--plan-sha256', help='Required exact plan SHA256 when applying')
args = parser.parse_args(argv)
run(args.db,args.out,pg_service=args.pg_service,pg_schema=args.pg_schema,plan=args.plan,plan_sha256=args.plan_sha256)
+2 -4
View File
@@ -42,11 +42,9 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
row = json.loads(line)
if row['source'] != source_name:
raise ValueError('Source differs from manifest')
if any(key in manifest and manifest[key]!=row.get(key) for key in ('venue','year','track','event_key')):
if any(key in manifest and manifest[key]!=row[key] for key in ('venue','year','track')):
raise ValueError('Conference edition differs from manifest')
require_track_record(row, manifest)
from paper_atlas.db.events import validate_event_key
validate_event_key(row.get('event_key'))
external = row['external_id']
if external in seen:
raise ValueError('Duplicate external ID in one collected file: '+external)
@@ -67,7 +65,7 @@ def ingest_file(db,path, *, pg_service=None, pg_schema=None):
status=event['status']
if events and status is None:raise ValueError('Explicit decision event requires a status')
if status is not None and status not in ('accepted','rejected','desk_rejected','withdrawn','under_review','unknown'):raise ValueError('Unsupported decision event')
edition=store.decision(paper,record,row['venue'],row['year'],row['track'],external,event.get('evidence_url',row['url']),status,event.get('raw_value',''),row.get('venue_aliases'),identities=identities,observed_at=event.get('observed_at') or row_observed,decision_date=event.get('decision_date'),source_decision_id=event.get('source_decision_id',''),submitted_at=row.get('submitted_at'),guard_status=source_name in (PUBLIC_SOURCE,ARCHIVE_SOURCE),event_key=row.get('event_key'))
edition=store.decision(paper,record,row['venue'],row['year'],row['track'],external,event.get('evidence_url',row['url']),status,event.get('raw_value',''),row.get('venue_aliases'),identities=identities,observed_at=event.get('observed_at') or row_observed,decision_date=event.get('decision_date'),source_decision_id=event.get('source_decision_id',''),submitted_at=row.get('submitted_at'),guard_status=source_name in (PUBLIC_SOURCE,ARCHIVE_SOURCE))
editions.add(edition)
else:
raise ValueError('This importer requires an explicit supported decision status')
-2
View File
@@ -85,5 +85,3 @@ class WriteConnection:
class PostgresStore(Store):
def __init__(self, service, schema=None):
self.conn = WriteConnection(service, schema)
from paper_atlas.db.events import has_event_key
self.event_identity = has_event_key(self.conn)
+3 -13
View File
@@ -14,9 +14,7 @@ ACCEPTED_PAPERS = "SELECT s.paper_id FROM submissions s JOIN decisions d ON d.su
def known_acceptances(conn,paper):
from paper_atlas.db.events import has_event_key
event = 'e.event_key' if has_event_key(conn) else "''"
return [dict(row) for row in conn.execute('''SELECT s.id AS submission_id,v.acronym AS venue,e.year,e.track,'''+event+''' AS event_key,
return [dict(row) for row in conn.execute('''SELECT s.id AS submission_id,v.acronym AS venue,e.year,e.track,
d.id AS decision_id,d.decision_date,d.observed_at,d.evidence_url
FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id
JOIN decisions d ON d.submission_id=s.id WHERE s.paper_id=? AND d.status='accepted' AND '''+LATEST_DECISION+
@@ -35,7 +33,6 @@ def main(argv=None, *, prog=None):
parser.add_argument('--venue', help='Conference acronym')
parser.add_argument('--year', type=int)
parser.add_argument('--track',help='Exact track, e.g. main, findings, workshop')
parser.add_argument('--event-key',help='Exact event within a venue/year/track')
parser.add_argument('--status', choices=['accepted','rejected','desk_rejected','withdrawn','under_review','unknown'])
parser.add_argument('--outcome',choices=['accepted','no_known_acceptance'],help='跨会议的已知录用情况;未查到录用不等于从未录用')
parser.add_argument('--limit', type=int, default=20)
@@ -50,11 +47,6 @@ def main(argv=None, *, prog=None):
conn = sqlite3.connect(Path(args.db).resolve().as_uri()+'?mode=ro', uri=True)
conn.row_factory = sqlite3.Row
conn.execute('BEGIN') # One consistent read snapshot while ingestion continues.
from paper_atlas.db.events import has_event_key
event = 'e.event_key' if has_event_key(conn) else "''"
if args.event_key is not None and event == "''":
conn.close()
raise ValueError('Event identity migration required for event-key queries')
if args.stats:
result = {name:conn.execute('SELECT count(*) FROM '+name).fetchone()[0] for name in ('papers','source_records','submissions','decisions','tags','match_candidates')}
result['records_by_source'] = [dict(r) for r in conn.execute('SELECT s.name,count(r.id) AS imported_records FROM sources s LEFT JOIN source_records r ON r.source_id=s.id GROUP BY s.id ORDER BY s.name')]
@@ -81,15 +73,13 @@ def main(argv=None, *, prog=None):
where.append('EXISTS(SELECT 1 FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.paper_id=p.id AND t.namespace=? AND t.value=?)'); params.extend([namespace,value])
if args.outcome:
where.append('p.id '+('IN' if args.outcome=='accepted' else 'NOT IN')+' ('+ACCEPTED_PAPERS+')')
if args.venue or args.year or args.status or args.track or args.event_key is not None:
if args.venue or args.year or args.status or args.track:
clause = ['1=1']
if args.venue:
if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='venue_aliases'").fetchone():
clause.append('(lower(v.acronym)=lower(?) OR EXISTS(SELECT 1 FROM venue_aliases va WHERE va.venue_id=v.id AND lower(va.alias)=lower(?)))'); params.extend([args.venue,args.venue])
else:
clause.append('lower(v.acronym)=lower(?)');params.append(args.venue)
if args.event_key is not None:
clause.append('e.event_key=?');params.append(args.event_key)
if args.track:
clause.append('e.track=?');params.append(args.track)
if args.year:
@@ -111,7 +101,7 @@ def main(argv=None, *, prog=None):
paper['tags'] = [dict(r) for r in conn.execute('SELECT t.namespace,t.value,pt.evidence_key FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.paper_id=?',(row['id'],))]
paper['sources'] = [dict(r) for r in conn.execute('SELECT s.name,r.external_id,r.url,r.title,r.abstract,r.retrieved_at FROM source_records r JOIN sources s ON s.id=r.source_id WHERE r.paper_id=?',(row['id'],))]
submissions = []
for sub in conn.execute('SELECT s.id,s.source_submission_id,s.submitted_at,v.acronym,e.year,e.track,'+event+' AS event_key FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE s.paper_id=? ORDER BY e.year,s.id',(row['id'],)):
for sub in conn.execute('SELECT s.id,s.source_submission_id,s.submitted_at,v.acronym,e.year,e.track FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE s.paper_id=? ORDER BY e.year,s.id',(row['id'],)):
submission = dict(sub)
submission['decisions'] = []
for d in conn.execute('SELECT id,status,raw_value,evidence_url,observed_at,decision_date FROM decisions WHERE submission_id=? ORDER BY observed_at,COALESCE(decision_date,observed_at),id',(sub['id'],)):
-202
View File
@@ -1,202 +0,0 @@
from contextlib import closing, redirect_stdout, contextmanager
import io
import json
import os
from pathlib import Path
import sqlite3
import tempfile
import unittest
import uuid
from importlib.resources import files
from paper_atlas.db.store import Store
from paper_atlas.db.backend import open_store
from paper_atlas.db.events import has_event_key
from paper_atlas.collectors.dblp import XML_SHA
from paper_atlas.ingestion.event_identity import plan, upgrade, exact_counts
from paper_atlas.ingestion.records import ingest_file
from paper_atlas.ingestion.backfill import backfill
from paper_atlas.postgres.client import connect
from paper_atlas.postgres.schema import quote
from paper_atlas.postgres.migration import migrate
from paper_atlas import querying
@contextmanager
def opened_store(**kwargs):
store=open_store(**kwargs)
try:
yield store
finally:
store.conn.close()
def legacy(db):
sql = files('paper_atlas.db').joinpath('schema.sql').read_text().replace(" event_key TEXT NOT NULL DEFAULT '',\n", '').replace('UNIQUE(venue_id,year,track,event_key)', 'UNIQUE(venue_id,year,track)')
c = sqlite3.connect(db); c.executescript(sql); c.close()
s = Store(db)
p,r = s.record('dblp','conf/www/Fall95','https://example.org/fall','Same paper in two events',
'Abstract\x00with\x01text',['A Person'],{'arxiv':'2501.00001'})
s.record_tags(p,r,'dblp',[{'namespace':'dblp:booktitle','value':'WWW (Fall)'}])
s.decision(p,r,'Legacy',1995,'main','conf/www/Fall95','https://example.org/accept','accepted','Accept')
s.conn.execute("UPDATE venues SET acronym='WWW',name='WWW'")
meta={'snapshot_sha256':XML_SHA,'proceedings_key':'conf/www/1995f','bibliographic_year':1995,
'bibliography':{'booktitle':['WWW (Fall)'],'url':['db/conf/www/www1995f.html#Fall95']}}
s.conn.execute('UPDATE source_records SET metadata_json=?',(json.dumps(meta),));s.conn.commit();s.conn.close()
return p,r
def add(store, external, event=None, status='accepted', venue='WWW', year=1995):
p,r = store.record('fixture',external,'https://example.org/'+external,'Same paper in two events',
'Abstract\x00with\x01text',['A Person'],{'arxiv':'2501.00001'})
edition = store.decision(p,r,venue,year,'main',external,'https://example.org/'+status,status,status,event_key=event)
return p,r,edition
def file(root, name, rows, event=None):
p=root/(name+'.jsonl');p.write_text(''.join(json.dumps(r)+'\n'for r in rows))
manifest={'source':'fixture','venue':'WWW','year':1995,'track':'main','count':len(rows),'list_url':'https://example.org/'+name}
if event is not None:manifest['event_key']=event
p.with_suffix('.manifest.json').write_text(json.dumps(manifest));return p
class EventIdentityTests(unittest.TestCase):
def setUp(self):
t=tempfile.TemporaryDirectory();self.addCleanup(t.cleanup);self.root=Path(t.name);self.db=self.root/'sample.sqlite'
def test_same_paper_distinct_events_keep_both_attempts_and_decisions(self):
s=Store(self.db);self.addCleanup(s.conn.close)
paper,_,spring=add(s,'spring','www-1995-spring','rejected');_,_,fall=add(s,'fall','www-1995-fall')
s.conn.commit();self.assertNotEqual(spring,fall)
with redirect_stdout(io.StringIO())as out:querying.main(['--db',str(self.db),'--paper',str(paper)])
row=json.loads(out.getvalue())[0];self.assertEqual({a['event_key']for a in row['submissions']},{'www-1995-spring','www-1995-fall'})
self.assertEqual([a['event_key']for a in row['known_acceptances']],['www-1995-fall'])
self.assertEqual({a['latest_status']for a in row['submissions']},{'rejected','accepted'})
with redirect_stdout(io.StringIO())as out:querying.main(['--db',str(self.db),'--venue','WWW','--event-key','www-1995-spring','--status','rejected'])
self.assertEqual(len(json.loads(out.getvalue())),1)
def test_explicit_migration_preserves_ids_history_and_source_bytes(self):
paper,record=legacy(self.db);proposal=plan(self.db);self.assertFalse(proposal['before']['event_column'])
result=upgrade(proposal,self.db);self.assertEqual(result['updated_editions'],1)
self.assertEqual(result['after']['protected'],proposal['before']['protected'])
self.assertEqual(result['after']['editions_sha256'],proposal['before']['editions_sha256'])
self.assertEqual(upgrade(proposal,self.db)['status'],'already_applied')
s=Store(self.db);self.addCleanup(s.conn.close)
self.assertEqual(s.conn.execute('SELECT id,event_key FROM editions').fetchall(),[(1,'www-1995-fall')])
# An old file without event_key still has its original source anchor.
self.assertEqual(s.decision(paper,record,'WWW',1995,'main','conf/www/Fall95','https://example.org/accept','accepted','Accept'),1)
self.assertEqual(s.conn.execute('SELECT count(*) FROM submissions').fetchone()[0],1)
self.assertEqual(s.conn.execute('PRAGMA foreign_key_check').fetchall(),[])
def test_unverified_legacy_volume_is_not_automatically_labelled(self):
legacy(self.db)
with sqlite3.connect(self.db)as c:c.execute("UPDATE source_records SET metadata_json='{}'")
with self.assertRaisesRegex(ValueError,'complete pinned Fall'):plan(self.db)
with sqlite3.connect(self.db)as c:self.assertFalse(has_event_key(c))
def test_stale_plan_rolls_back_before_schema_change(self):
legacy(self.db);proposal=plan(self.db)
with sqlite3.connect(self.db)as c:c.execute("UPDATE editions SET track='unspecified'")
with self.assertRaisesRegex(ValueError,'stale'):upgrade(proposal,self.db)
with sqlite3.connect(self.db)as c:self.assertFalse(has_event_key(c));self.assertEqual(c.execute('SELECT track FROM editions').fetchone()[0],'unspecified')
def test_unscoped_source_and_wrong_refresh_event_fail(self):
s=Store(self.db);self.addCleanup(s.conn.close);_,r,e=add(s,'first','www-1995-fall');s.conn.commit()
p,r2=s.record('fixture','unscoped','https://example.org/u','Other work',None,[])
with self.assertRaisesRegex(ValueError,'event_key'):s.decision(p,r2,'WWW',1995,'main','unscoped','https://example.org/u','accepted','Accept')
with self.assertRaisesRegex(ValueError,'Cannot move'):s.decision(p,r,'WWW',1995,'main','first','https://example.org/u','accepted','Accept',event_key='www-1995-spring')
self.assertEqual(s.conn.execute('SELECT count(*) FROM editions').fetchone()[0],1)
def test_pinned_fall_cannot_claim_spring(self):
p,r=legacy(self.db);upgrade(plan(self.db),self.db);s=Store(self.db);self.addCleanup(s.conn.close)
with self.assertRaisesRegex(ValueError,'pinned WWW Fall'):s.decision(p,r,'WWW',1995,'main','conf/www/Fall95','https://example.org/u','accepted','Accept',event_key='www-1995-spring')
def test_manifest_event_mismatch_rolls_back_the_whole_file(self):
s=Store(self.db);s.conn.close()
base={'source':'fixture','venue':'WWW','year':1995,'track':'main','status':'accepted','title':'Input work','url':'https://example.org/p','authors':[]}
path=file(self.root,'bad',[dict(base,external_id='a',event_key='www-1995-spring'),dict(base,external_id='b',event_key='www-1995-fall')],'www-1995-spring')
with self.assertRaisesRegex(ValueError,'edition differs'):ingest_file(self.db,path)
with sqlite3.connect(self.db)as c:
self.assertEqual(c.execute('SELECT count(*) FROM papers').fetchone()[0],0)
self.assertEqual(c.execute('SELECT count(*) FROM editions').fetchone()[0],0)
def test_backfill_selects_the_explicit_event_and_preserves_attempts(self):
s=Store(self.db);add(s,'spring','www-1995-spring','rejected');add(s,'fall','www-1995-fall');s.conn.commit();s.conn.close()
row={'source':'fixture','external_id':'fall','venue':'WWW','year':1995,'track':'main','event_key':'www-1995-fall','status':'accepted','identifiers':{'openreview':'forumFall'}}
path=file(self.root,'backfill',[row],'www-1995-fall')
with redirect_stdout(io.StringIO()):result=backfill(self.db,[path])
self.assertEqual(result['submissions_after'],2)
with sqlite3.connect(self.db)as c:
self.assertEqual(c.execute("SELECT e.event_key FROM submission_identifiers si JOIN submissions s ON s.id=si.submission_id JOIN editions e ON e.id=s.edition_id WHERE si.scheme='openreview' AND si.value='forumFall'").fetchone()[0],'www-1995-fall')
def test_exact_paged_counts_cover_composite_key_boundaries(self):
s=Store(self.db);self.addCleanup(s.conn.close)
for i in range(7):add(s,'entry'+str(i),'www-1995-fall')
s.conn.commit();events=[];counts=exact_counts(s.conn,page_size=2,progress=events.append)
self.assertEqual(counts['source_records'],7)
self.assertEqual(counts['identifiers'],8)
self.assertEqual(counts['submission_sources'],7)
self.assertEqual([e['rows_counted']for e in events if e['table']=='identifiers'and e['complete']],[8])
def test_legacy_single_event_writes_and_reads_remain_available(self):
legacy(self.db);s=Store(self.db);self.addCleanup(s.conn.close)
add(s,'ordinary',venue='COLM',year=2025);s.conn.commit()
with redirect_stdout(io.StringIO())as out:querying.main(['--db',str(self.db),'--venue','COLM'])
self.assertEqual(json.loads(out.getvalue())[0]['known_acceptances'][-1]['event_key'],'')
with self.assertRaisesRegex(ValueError,'migration required'):
querying.main(['--db',str(self.db),'--event-key','www-1995-fall'])
def test_unexpected_extra_edition_column_requires_review(self):
legacy(self.db)
with sqlite3.connect(self.db)as c:c.execute('ALTER TABLE editions ADD COLUMN custom_data TEXT')
with self.assertRaisesRegex(ValueError,'Unexpected edition schema'):plan(self.db)
def test_file_spanning_two_events_without_manifest_key_rolls_back(self):
Store(self.db).conn.close()
base={'source':'fixture','venue':'WWW','year':1995,'track':'main','status':'accepted','title':'Input work','url':'https://example.org/p','authors':[]}
path=file(self.root,'two',[dict(base,external_id='a',event_key='www-1995-spring'),dict(base,external_id='b',event_key='www-1995-fall')])
with self.assertRaisesRegex(ValueError,'exactly one'):ingest_file(self.db,path)
with sqlite3.connect(self.db)as c:self.assertEqual(c.execute('SELECT count(*) FROM submissions').fetchone()[0],0)
def test_cutover_sqlite_backup_remains_protected(self):
legacy(self.db);proposal=plan(self.db);Path(str(self.db.resolve())+'.postgresql-cutover.json').write_text('{}')
with self.assertRaisesRegex(RuntimeError,'cut over'):upgrade(proposal,self.db)
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'),'Requires approved remote PostgreSQL test service')
class PostgreSQLEventIdentityTests(unittest.TestCase):
def setUp(self):
self.service=os.environ['PAPER_ATLAS_TEST_PG_SERVICE'];self.schema='pa_event_'+uuid.uuid4().hex[:12]
t=tempfile.TemporaryDirectory();self.addCleanup(t.cleanup);self.root=Path(t.name);self.db=self.root/'seed.sqlite';legacy(self.db)
self.addCleanup(self.cleanup)
with redirect_stdout(io.StringIO()):migrate(self.db,self.service,self.root/'migrate.json',schema=self.schema,min_free_gib=0)
def cleanup(self):
with connect(self.service)as c:
for (name,)in c.execute('SELECT nspname FROM pg_namespace WHERE starts_with(nspname,%s)',(self.schema,)).fetchall():c.execute('DROP SCHEMA '+quote(name)+' CASCADE')
def test_native_upgrade_preserves_ids_and_codec_then_accepts_separate_event(self):
proposal=plan(pg_service=self.service,pg_schema=self.schema)
result=upgrade(proposal,pg_service=self.service,pg_schema=self.schema)
self.assertEqual(result['after']['protected'],proposal['before']['protected'])
self.assertEqual(upgrade(proposal,pg_service=self.service,pg_schema=self.schema)['status'],'already_applied')
with opened_store(pg_service=self.service,pg_schema=self.schema)as s:
paper,_,spring=add(s,'spring','www-1995-spring','rejected');s.conn.commit()
self.assertNotEqual(spring,1)
self.assertEqual(s.conn.execute('SELECT abstract FROM papers WHERE id=?',(paper,)).fetchone()[0],'Abstract\x00with\x01text')
with redirect_stdout(io.StringIO())as out:querying.main(['--pg-service',self.service,'--pg-schema',self.schema,'--paper',str(paper)])
row=json.loads(out.getvalue())[0];self.assertEqual(len(row['submissions']),2);self.assertEqual(len(row['known_acceptances']),1)
def test_native_paged_counts_preserve_encoded_composite_key_order(self):
with opened_store(pg_service=self.service,pg_schema=self.schema)as s:
for value in ['a\x00b','a\x01b','a\x02b','z']:
s.conn.execute('INSERT INTO identifiers(scheme,value,paper_id) VALUES(?,?,?)',('custom',value,1))
s.conn.commit();counts=exact_counts(s.conn,page_size=2)
self.assertEqual(counts['identifiers'],6)
self.assertEqual(counts['submissions'],1)
def test_stale_native_plan_is_rejected_and_lock_released(self):
proposal=plan(pg_service=self.service,pg_schema=self.schema)
with opened_store(pg_service=self.service,pg_schema=self.schema)as s:s.conn.execute("UPDATE editions SET track='unspecified'");s.conn.commit()
with self.assertRaisesRegex(ValueError,'stale'):upgrade(proposal,pg_service=self.service,pg_schema=self.schema)
with opened_store(pg_service=self.service,pg_schema=self.schema)as s:self.assertFalse(has_event_key(s.conn))
+187
View File
@@ -0,0 +1,187 @@
from contextlib import closing, redirect_stdout
import hashlib
import io
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import uuid
from paper_atlas.collectors.dblp import XML_SHA
from paper_atlas.collectors.dblp_scope import scope
from paper_atlas.collectors.dblp_records import normalize_row
from paper_atlas.db.store import Store
from paper_atlas.db.backend import open_store
from paper_atlas.ingestion.joint_editions import (TARGET, MARKER, inspect, apply_plan, run)
from paper_atlas.postgres.client import connect
from paper_atlas.postgres.migration import migrate
from paper_atlas.postgres.schema import quote
from paper_atlas import querying
def seed(store):
c = store.conn
c.execute("INSERT INTO venues(acronym,name,field,venue_type) VALUES(?,?,'reasoning','conference')", (TARGET,TARGET))
target = c.execute('SELECT id FROM venues WHERE acronym=?',(TARGET,)).fetchone()[0]
for alias in ['ECAI','IJCAI']:
c.execute('INSERT INTO venue_aliases VALUES(?,?)',(target,alias))
for year in [2018,2019,2022]:
metadata = {'snapshot_sha256':XML_SHA,'proceedings_key':'conf/ijcai/'+str(year),
'bibliographic_year':year,'bibliography':{'booktitle':['IJCAI']}}
p,r=store.record('dblp','conf/ijcai/fixture'+str(year),'https://dblp.org/rec/fixture',
'Original publication '+str(year),'Abstract\x00with\x01data',[],
{'doi':'10.24963/ijcai.'+str(year)+'/1'}, {})
store.record_tags(p,r,'dblp',[{'namespace':'dblp:booktitle','value':'IJCAI'}])
store.decision(p,r,'IJCAI',year,'main','fixture'+str(year),'https://example.org/decision','accepted','listed')
stored=json.loads(c.execute('SELECT metadata_json FROM source_records WHERE id=?',(r,)).fetchone()[0]);stored.update(metadata)
c.execute('UPDATE source_records SET metadata_json=? WHERE id=?',(json.dumps(stored),r))
c.commit()
def plan(c):
return {**inspect(c),'database':{'engine':'sqlite','path':'fixture'}}
class JointEditionTests(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)
seed(self.store); self.c=self.store.conn
def test_apply_preserves_every_protected_row_and_edition_id(self):
before=plan(self.c); ids=[r['id']for r in before['editions']]
result=apply_plan(self.c,before);self.c.commit()
self.assertEqual(result['updated_editions'],2)
self.assertEqual(result['content_fingerprints'],before['content_fingerprints'])
self.assertEqual([r['id']for r in result['editions']],ids)
untouched=self.c.execute('SELECT v.acronym FROM editions e JOIN venues v ON v.id=e.venue_id WHERE e.year=2019').fetchone()[0]
self.assertEqual(untouched,'IJCAI')
self.assertEqual(self.c.execute('PRAGMA foreign_key_check').fetchall(),[])
self.assertEqual(self.c.execute('SELECT count(*) FROM schema_migrations WHERE name=?',(MARKER,)).fetchone()[0],1)
with redirect_stdout(io.StringIO())as out:
querying.main(['--db',str(self.db),'--venue','ECAI'])
self.assertEqual(len(json.loads(out.getvalue())),2)
self.assertEqual(apply_plan(self.c,before)['updated_editions'],0)
def test_refresh_of_old_normalized_source_does_not_recreate_ordinary_edition(self):
before=plan(self.c);apply_plan(self.c,before)
for year in [2018,2022]:
row=self.c.execute('''SELECT r.paper_id,r.id,d.observed_at FROM source_records r
JOIN submissions s ON s.source_record_id=r.id JOIN decisions d ON d.submission_id=s.id
WHERE r.external_id=?''',('conf/ijcai/fixture'+str(year),)).fetchone()
self.store.decision(row[0],row[1],'IJCAI',year,'main','fixture'+str(year),
'https://example.org/decision','accepted','listed',observed_at=row[2])
self.assertEqual(inspect(self.c)['content_fingerprints'],before['content_fingerprints'])
self.assertEqual(self.c.execute('SELECT count(*) FROM editions').fetchone()[0],3)
def test_any_changed_source_text_rejects_stale_plan_before_membership_updates(self):
before=plan(self.c)
self.c.execute("UPDATE source_records SET title='New evidence' WHERE external_id='conf/ijcai/fixture2018'")
with self.assertRaisesRegex(ValueError,'Stale repair plan'):
apply_plan(self.c,before)
self.assertEqual(inspect(self.c)['status'],'ready')
def test_conflicting_target_edition_is_never_merged_or_deleted(self):
target=self.c.execute('SELECT id FROM venues WHERE acronym=?',(TARGET,)).fetchone()[0]
self.c.execute("INSERT INTO editions(venue_id,year,track) VALUES(?,2018,'main')",(target,))
with self.assertRaisesRegex(ValueError,'colliding'):
inspect(self.c)
self.assertEqual(self.c.execute('SELECT count(*) FROM editions WHERE year=2018').fetchone()[0],2)
def test_wrong_source_volume_and_snapshot_fail_closed(self):
original=self.c.execute("SELECT metadata_json FROM source_records WHERE external_id='conf/ijcai/fixture2018'").fetchone()[0]
for key,value in [('proceedings_key','conf/ijcai/2018w'),('snapshot_sha256','wrong'),('bibliographic_year',2017),('bibliography',{'booktitle':['Other']})]:
m=json.loads(original);m[key]=value
self.c.execute("UPDATE source_records SET metadata_json=? WHERE external_id='conf/ijcai/fixture2018'",(json.dumps(m),))
with self.subTest(key=key),self.assertRaisesRegex(ValueError,'Source evidence'):
inspect(self.c)
def test_missing_source_support_and_wrong_aliases_are_not_guessed(self):
self.c.execute("DELETE FROM venue_aliases WHERE alias='ECAI'")
with self.assertRaisesRegex(ValueError,'aliases'):
inspect(self.c)
self.c.rollback()
self.c.execute('DELETE FROM submission_sources WHERE source_record_id=(SELECT id FROM source_records WHERE external_id=?)',('conf/ijcai/fixture2022',))
with self.assertRaisesRegex(ValueError,'Every submission'):
inspect(self.c)
def test_cli_plan_readonly_hash_binding_identity_and_repeat_apply(self):
out=Path(self.tmp.name)/'plan.json'
with redirect_stdout(io.StringIO()):run(self.db,out)
self.assertEqual(inspect(self.c)['status'],'ready')
raw=out.read_bytes();sha=hashlib.sha256(raw).hexdigest()
with patch('paper_atlas.ingestion.joint_editions.open_store')as writer:
with self.assertRaisesRegex(ValueError,'hash differs'):
run(self.db,Path(self.tmp.name)/'bad.json',plan=out,plan_sha256='0'*64)
writer.assert_not_called()
with redirect_stdout(io.StringIO()):
result=run(self.db,Path(self.tmp.name)/'applied.json',plan=out,plan_sha256=sha)
again=run(self.db,Path(self.tmp.name)/'retry.json',plan=out,plan_sha256=sha)
self.assertEqual(result['updated_editions'],2);self.assertEqual(again['updated_editions'],0)
self.assertEqual(result['plan_sha256'],sha);self.assertEqual(again['plan_sha256'],sha)
altered=json.loads(raw);altered['database']['path']='another.sqlite'
bad=Path(self.tmp.name)/'wrong-db.json';bad.write_text(json.dumps(altered))
with self.assertRaisesRegex(ValueError,'another database'):
run(self.db,Path(self.tmp.name)/'wrong-result.json',plan=bad,plan_sha256=hashlib.sha256(bad.read_bytes()).hexdigest())
def test_invalid_empty_plan_fails_before_opening_writer(self):
f=Path(self.tmp.name)/'empty.json';f.write_text('{}')
with patch('paper_atlas.ingestion.joint_editions.open_store')as writer:
with self.assertRaises(ValueError):
run(self.db,Path(self.tmp.name)/'empty-result.json',plan=f,plan_sha256=hashlib.sha256(f.read_bytes()).hexdigest())
writer.assert_not_called()
def test_collector_only_changes_the_two_exact_main_volume_keys(self):
for year in [2018,2019,2022]:
volume={'key':'conf/ijcai/'+str(year),'fields':{'title':['IJCAI '+str(year)],'year':[str(year)]}}
row={'record_type':'inproceedings','key':'conf/ijcai/One'+str(year),'fields':{'booktitle':['IJCAI'],
'year':[str(year)],'crossref':[volume['key']],'title':['Original research title'],
'author':['Jane Doe'],'ee':['https://doi.org/10.24963/ijcai.'+str(year)+'/1']}}
result,error=normalize_row(row,{volume['key']:volume},{'IJCAI','ECAI'})
self.assertIsNone(error)
self.assertEqual(result['venue'],TARGET if year in(2018,2022)else'IJCAI')
self.assertEqual(result['metadata']['bibliography']['booktitle'],['IJCAI'])
self.assertEqual(result['title'],'Original research title')
if year in(2018,2022):
self.assertIn('joint_edition_evidence',result['metadata'])
row['fields']['booktitle']=['IJCAI (Workshop)']
scoped,_=scope(row,volume)
self.assertEqual(scoped['venue'],'IJCAI')
self.assertEqual(scoped['track'],'workshop')
@unittest.skipUnless(os.environ.get('PAPER_ATLAS_TEST_PG_SERVICE'),'Requires approved remote PostgreSQL service')
class PostgreSQLJointTests(unittest.TestCase):
def setUp(self):
self.service=os.environ['PAPER_ATLAS_TEST_PG_SERVICE'];self.target='pa_joint_'+uuid.uuid4().hex[:12]
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
self.db=Path(self.tmp.name)/'sample.sqlite';s=Store(self.db);seed(s);s.conn.close()
self.addCleanup(self.cleanup)
with redirect_stdout(io.StringIO()):migrate(self.db,self.service,Path(self.tmp.name)/'migration.json',schema=self.target,min_free_gib=0)
def cleanup(self):
with connect(self.service)as c:
for name, in c.execute('SELECT nspname FROM pg_namespace WHERE starts_with(nspname,%s)',(self.target,)).fetchall():
c.execute('DROP SCHEMA '+quote(name)+' CASCADE')
def test_native_repair_is_idempotent_keeps_codec_and_provenance(self):
p=Path(self.tmp.name)/'plan.json'
with redirect_stdout(io.StringIO()):
before=run(None,p,pg_service=self.service,pg_schema=self.target)
after=run(None,Path(self.tmp.name)/'applied.json',pg_service=self.service,pg_schema=self.target,plan=p,plan_sha256=hashlib.sha256(p.read_bytes()).hexdigest())
self.assertEqual(after['content_fingerprints'],before['content_fingerprints'])
with closing(open_store(pg_service=self.service,pg_schema=self.target).conn)as c:
self.assertEqual(apply_plan(c,before)['updated_editions'],0)
self.assertEqual(c.execute('SELECT abstract FROM papers ORDER BY id LIMIT 1').fetchone()[0],'Abstract\x00with\x01data')
def test_native_stale_source_plan_preserves_both_original_memberships(self):
with closing(open_store(pg_service=self.service,pg_schema=self.target).conn)as c:
before=plan(c)
c.execute("UPDATE source_records SET title='Changed' WHERE external_id=?",('conf/ijcai/fixture2022',));c.commit()
with self.assertRaisesRegex(ValueError,'Stale repair plan'):
apply_plan(c,before)
c.rollback()
self.assertEqual(inspect(c)['status'],'ready')
+1 -1
View File
@@ -133,7 +133,7 @@ class SubmissionTests(unittest.TestCase):
c.execute("INSERT INTO papers(id,title,title_key) VALUES(1,'Paper','paper')")
c.execute("INSERT INTO source_records VALUES(1,1,'paper',1,'https://example.org/p','Paper','Abstract','[]','{}',CURRENT_TIMESTAMP)")
c.execute("INSERT INTO venues VALUES(1,'ICLR','ICLR','machine_learning','conference')")
c.execute("INSERT INTO editions(id,venue_id,year,track) VALUES(1,1,2026,'main')")
c.execute("INSERT INTO editions VALUES(1,1,2026,'main')")
c.execute("INSERT INTO submissions VALUES(1,1,1,1,'paper',NULL)")
c.execute("INSERT INTO decisions VALUES(1,1,'accepted','Accept','https://example.org/decision','2026-01-01T00:00:00Z',NULL)")
c.commit();c.close()