Compare commits

..

8 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
Zayden-Jung 1b81d1a30a Merge pull request '保留公开元数据摘要内的 Unicode 分隔符' (#17) from codex/jsonl-lines into main
Metadata integrity checks / metadata (push) Successful in 3m15s
Reviewed-on: #17
2026-11-08 06:26:27 +00:00
Zayden-Jung 6cf74672a5 Merge pull request '只读审计跨会议投稿历史与决定来源关系' (#23) from codex/history-audit into main
Metadata integrity checks / metadata (push) Successful in 2m25s
Reviewed-on: #23
2026-11-08 06:26:09 +00:00
codex-bot 5a925457b0 Audit cross-venue submission histories without inferring chronology
Metadata integrity checks / metadata (push) Successful in 3m0s
2026-11-08 04:30:19 +08:00
codex-bot bf8178354f Preserve Unicode separators in public metadata preflight
Metadata integrity checks / metadata (push) Successful in 2m42s
2026-11-07 16:34:54 +08:00
17 changed files with 872 additions and 165 deletions
+3
View File
@@ -49,12 +49,15 @@ 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','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)
subprocess.run([python,'-I','-B','-m','paper_atlas','audit','public-openreview','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-m','paper_atlas','audit','histories','--help'],cwd=outside,check=True)
subprocess.run([python,'-I','-B','-c','from paper_atlas.postgres.writer import PostgresStore, WriteConnection'],cwd=outside,check=True)
smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert {'COLM','MM','MLSys'} <= {v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}; s=Store('installed.sqlite'); s.conn.close()"
subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True)
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','audit','histories','--db','installed.sqlite','--examples','0'],cwd=outside,check=True)
PY
+2
View File
@@ -86,3 +86,5 @@ paper-atlas collect colm --cache /data/paper-index/raw/http-cache \
CI 在已授权的远程 runner 上运行测试、构建 wheel,并从源码目录外验证安装后的命令及随包资源。数据库全量去重和覆盖率是独立的数据验收步骤,不因代码测试通过而自动完成。
开发通过工作分支和 PR 交付,由用户手动合并。
历史联合会议的既有届次归属可通过 [`maintain joint-editions`](docs/joint-editions.md) 先只读规划,再按计划哈希执行;保留论文与投稿决定来源,不重导入。
-6
View File
@@ -93,12 +93,6 @@ paper-atlas audit public-openreview --db /data/paper-index/papers.sqlite \
输出按会议、届次及 main、position、datasets_benchmarks 分文件。workshop 默认进入单独的 held 清单;有明确 workshop 名称时可通过 `--include-workshops` 单列采集,不合并到主会。Retraction、未映射的轨道或负面标签也保留在 `held-records.jsonl`,不强改为 Reject 或 Withdrawn。当前决定表没有独立 retracted 状态,因此撤回发表记录仍需后续专门建模。
`parser_version=2` 还会将明确负面标签但标题缺失、空白或 null 的记录放入 `held-records.jsonl`,保留原始状态、forum ID、固定来源版本和行号;不制造标题或导入不可识别的论文,也不让这一条缺失阻断其他可用记录。held 文件哈希与各原因数量保存在 inventory;非文本标题、错误身份、冲突届次和哈希不符仍使整批失败。旧年 ICLR 镜像的明确 `Withdraw` 标签归为 `withdrawn`,`Workshop`、`Poster Workshop`、`Oral Workshop` 均不作为拒稿。采集文件与导入主库仍是两个独立步骤。
`parser_version=3` 增加五组已核实身份的 ICLR workshop 标签:2024 ME-FoMo / GenAI4DM 的 Withdrawn Submission、2025 AgenticAI 的 Reject / LMRL 的 Withdrawn Submission,以及 2026 LLM Reasoning 的 Withdrawn Submission。仅在 `--include-workshops` 下输出,并分别保存 `workshop_me_fomo`、`workshop_genai4dm`、`workshop_agenticai`、`workshop_lmrl`、`workshop_llm_reasoning`;默认仍暂存。标签必须与原文件会议、年份和原始 workshop track 一致,错年/错会议使采集失败,错轨道单独暂存,未知标签继续待核查,不用宽泛的 Reject/Withdraw 正则猜测。
[OpenReview 官方 ICLR 活动目录](https://openreview.net/venue?id=ICLR.cc) 确认上述独立 workshop 身份;[ME-FoMo 2024](https://sites.google.com/view/me-fomo2024/call-for-papers)、[GenAI4DM 2024](https://sites.google.com/view/genai4dm-iclr2024)、[AgenticAI 2025](https://iclragenticai.github.io/) 和 [LLM Reasoning 2026](https://sites.google.com/view/iclr-2026-llmreasoning) 的活动网站也提供对应投稿入口。输出在 metadata 保存 `workshop_venueid` 和身份目录证据,决定仍只标为固定镜像的公开分类断言,保留原始标签、forum、来源行号与哈希,不声称核验原始决定签名。2026-11-08 核查时部分原站记录页要求浏览器验证;身份目录和主办方入口可读,不代表逐篇原始决定已可访问,也不外推其他 workshop 的公开政策。采集后仍须经过 PostgreSQL 身份/决定冲突预检、串行导入和独立验收。
该来源明确标记为 `curated_public_status_assertion`:保留源 commit、文件哈希、行号、原标签和 forum ID,不声称已核验原始 PC 签名。只保存标题、摘要、公开作者名、关键词、领域、索引与决定来源,不缓存镜像中的个人资料、邮箱、性别、评审内容或 PDF。所有输出清单均为 partial;盘点中的零条记录仅描述这个固定镜像。
入库前审计核验文件哈希、数量、轨道和来源声明,并用真实 forum ID 对比同届同轨道已有投稿。存在不同决定时阻止整批导入,交由来源和时序核查;另一会议的后续录用属于独立投稿历史,不构成该次拒稿的冲突。同一 forum 的镜像不新增投稿事件,但论文实体的跨平台去重仍须在全量结束后核验。审计通过的报告包含带 SHA-256 的导入队列;应在单写入维护锁下及时串行导入,主库变化后重新审计。
+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.
+21 -5
View File
@@ -79,10 +79,10 @@ paper-atlas maintain merge --db /data/paper-index/samples/example.sqlite \
## 4. 查询与分析前检查
```sh
paper-atlas query --db /data/paper-index/papers.sqlite --query '"language model"' --limit 20
paper-atlas query --db /data/paper-index/papers.sqlite --tag arxiv:category=cs.AI
paper-atlas query --db /data/paper-index/papers.sqlite --venue ICLR --year 2025 --status rejected
paper-atlas query --db /data/paper-index/papers.sqlite --paper 123
paper-atlas query --pg-service foundry-research --query '"language model"' --limit 20
paper-atlas query --pg-service foundry-research --tag arxiv:category=cs.AI
paper-atlas query --pg-service foundry-research --venue ICLR --year 2025 --status rejected
paper-atlas query --pg-service foundry-research --paper 123
paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-index/collected/coverage.json
```
@@ -91,10 +91,26 @@ paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-inde
例如,查找在 ICLR 有拒稿记录、但在其他已知投稿中已有录用的论文:
```sh
paper-atlas query --db /data/paper-index/papers.sqlite \\
paper-atlas query --pg-service foundry-research \
--venue ICLR --status rejected --outcome accepted
```
`known_acceptances` 列出目前仍为录用状态的各次投稿及会议、年份、track 和决定证据,`publication_outcome` 汇总为 accepted 或 no_known_acceptance。汇总会检查所有会议和 track,不把某次拒稿覆盖到整篇论文;同一次投稿的后续撤稿也会更新该次状态并保留原决定。no_known_acceptance 只表示现有来源中没有查到录用,不能称为“从未录用”。
`--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。
## 5. 投稿历史关系审计
```sh
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas audit histories \
--pg-service foundry-research --pg-schema research --examples 20 \
--out /data/paper-index/collected/history-audit.json
```
使用一个只读一致性快照,区分论文实体、投稿和决定观察;分别统计同次投稿曾出现负面与录用观察、不同投稿共存负面与当前已知录用、不同会议之间的共存。workshop/main 是不同 track,属于同一会议时不算跨会议;会议别名也不会增加一个会议。当前已知录用使用与 `query --outcome` 相同的“每次投稿最新观察”规则,后续撤稿的该次投稿不再算当前录用,但旧录用决定仍保留。
报告不凭观察时间、届次年份或行顺序断言“拒稿发生在录用之前”。样例保留真实 `decision_date`、`observed_at`、原始标签、证据 URL 和决定来源,供进一步核查;日期缺失仍保持未知。`negative` 统计包含 rejected、desk_rejected 和 withdrawn,其原始状态不互相替换。
总数覆盖快照内全部投稿,样例由 `--examples` 限定为 0–100 篇;每篇最多展示 20 次投稿、每次最多 50 条决定、每条决定最多 100 个来源,并明确标记截断。样例优先保留负面/录用的不同投稿对。四项来源关系检查报告缺失关联和来源论文归属不一致,不自动修复。
这些统计依赖现有论文身份关联,不证明实体合并正确,也不证明原始决定签名、公开拒稿全集、标签类型或最终全库去重已完成。SQLite 独立样本可使用 `--db`;冻结备份不能代表当前 PostgreSQL 的历史情况。
+196
View File
@@ -0,0 +1,196 @@
"""Audit linked submission histories without inferring event chronology."""
import argparse
from collections import Counter
import json
from pathlib import Path
import sqlite3
from paper_atlas.db.backend import database_arguments, open_read_connection
from paper_atlas.db.submissions import timestamp
from paper_atlas.querying import LATEST_DECISION
ATTEMPTS = '''SELECT s.paper_id,s.id AS submission_id,e.venue_id,v.acronym AS venue,e.year,e.track,
count(d.id) AS decision_observations,
MAX(CASE WHEN d.status IN ('rejected','desk_rejected','withdrawn') THEN 1 ELSE 0 END)
AS historical_negative,
MAX(CASE WHEN d.status='accepted' THEN 1 ELSE 0 END) AS historical_acceptance,
COALESCE(MAX(CASE WHEN ''' + LATEST_DECISION + ''' THEN d.status END),'unknown')
AS latest_observed_status
FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id
LEFT JOIN decisions d ON d.submission_id=s.id
GROUP BY s.paper_id,s.id,e.venue_id,v.acronym,e.year,e.track
ORDER BY s.paper_id,e.venue_id,s.id'''
def _paper_counts(attempts):
negative = sum(row['historical_negative'] for row in attempts)
accepted = sum(row['latest_observed_status'] == 'accepted' for row in attempts)
overlap = sum(row['historical_negative'] and row['latest_observed_status'] == 'accepted'
for row in attempts)
venues = {}
for row in attempts:
counts = venues.setdefault(row['venue_id'], [0, 0])
counts[0] += row['historical_negative']
counts[1] += row['latest_observed_status'] == 'accepted'
return {
'papers_with_submissions': 1,
'papers_with_multiple_submissions': int(len(attempts) > 1),
'papers_with_historical_negative': int(negative > 0),
'papers_with_current_known_acceptance': int(accepted > 0),
'papers_with_negative_and_acceptance_in_distinct_attempts': int(negative * accepted > overlap),
'papers_with_negative_and_acceptance_in_distinct_venues':
int(negative * accepted > sum(n * a for n, a in venues.values())),
}
def _example(connection, attempts):
# Keep a negative/accepted pair visible even when a paper has many attempts.
accepted = [row for row in attempts if row['latest_observed_status'] == 'accepted']
first = accepted[0] if accepted else None
other_venue = next((row for row in accepted if row['venue_id'] != first['venue_id']), None)
pair = None
for negative in attempts:
if negative['historical_negative'] and first:
candidate = first if first['venue_id'] != negative['venue_id'] else other_venue
if candidate:
pair = (negative, candidate)
break
if pair is None:
for negative in attempts:
if negative['historical_negative'] and first:
candidate = first if first['submission_id'] != negative['submission_id'] else (
accepted[1] if len(accepted) > 1 else None)
if candidate:
pair = (negative, candidate)
break
selected = {}
for row in list(pair or ()) + attempts:
if len(selected) < 20:
selected.setdefault(row['submission_id'], dict(row))
for row in selected.values():
row['decisions'] = []
for decision in connection.execute('''SELECT id,status,raw_value,evidence_url,
decision_date,observed_at FROM decisions WHERE submission_id=?
ORDER BY observed_at DESC,COALESCE(decision_date,observed_at) DESC,id DESC LIMIT 50''',
(row['submission_id'],)):
entry = dict(decision)
sources = [dict(source) for source in connection.execute('''
SELECT so.name,r.external_id,ds.source_decision_id FROM decision_sources ds
JOIN source_records r ON r.id=ds.source_record_id JOIN sources so ON so.id=r.source_id
WHERE ds.decision_id=? ORDER BY so.name,r.external_id LIMIT 101''', (entry['id'],))]
entry['sources'] = sources[:100]
entry['sources_truncated'] = len(sources) > 100
row['decisions'].append(entry)
row['decisions_truncated'] = row['decision_observations'] > len(row['decisions'])
paper_id = attempts[0]['paper_id']
return {'paper_id': paper_id,
'title': connection.execute('SELECT title FROM papers WHERE id=?', (paper_id,)).fetchone()[0],
'counts': _paper_counts(attempts), 'attempt_count': len(attempts),
'attempts': list(selected.values()), 'attempts_truncated': len(attempts) > len(selected),
'chronology': 'Not inferred from observed_at, edition year, or row order.'}
def report(db=None, *, pg_service=None, pg_schema=None, examples=20):
if not 0 <= examples <= 100:
raise ValueError('Examples must be between 0 and 100')
started_at = timestamp()
connection = open_read_connection(db, pg_service=pg_service, pg_schema=pg_schema)
if not pg_service:
connection.row_factory = sqlite3.Row
try:
if pg_service:
identity = connection.execute('SELECT current_database(),current_schema()').fetchone()
database, schema = identity[0], identity[1]
else:
database, schema = str(Path(db).resolve()), None
names = ['papers_with_submissions', 'papers_with_multiple_submissions',
'papers_with_historical_negative', 'papers_with_current_known_acceptance',
'papers_with_negative_and_acceptance_in_distinct_attempts',
'papers_with_negative_and_acceptance_in_distinct_venues']
totals = Counter({key: 0 for key in names})
totals.update(submissions=0, decision_observations=0,
attempts_with_historical_negative=0,
attempts_with_historical_negative_and_acceptance=0)
latest = Counter()
sample_attempts = []
def consume(rows):
if not rows:
return
counts = _paper_counts(rows)
totals.update(counts)
if counts['papers_with_negative_and_acceptance_in_distinct_attempts'] and len(sample_attempts) < examples:
sample_attempts.append(rows)
current = []
for raw in connection.execute(ATTEMPTS):
row = dict(raw)
if current and row['paper_id'] != current[0]['paper_id']:
consume(current)
current = []
current.append(row)
totals['submissions'] += 1
totals['decision_observations'] += row['decision_observations']
totals['attempts_with_historical_negative'] += row['historical_negative']
totals['attempts_with_historical_negative_and_acceptance'] += int(
row['historical_negative'] and row['historical_acceptance'])
latest[row['latest_observed_status']] += 1
consume(current)
# These are relationship checks, not a substitute for the final entity/FK audit.
integrity = {}
checks = {
'submissions_without_source_links': '''SELECT count(*) FROM submissions s
WHERE NOT EXISTS(SELECT 1 FROM submission_sources ss WHERE ss.submission_id=s.id)''',
'decisions_without_source_links': '''SELECT count(*) FROM decisions d
WHERE NOT EXISTS(SELECT 1 FROM decision_sources ds WHERE ds.decision_id=d.id)''',
'submission_source_paper_mismatches': '''SELECT count(*) FROM submission_sources ss
JOIN submissions s ON s.id=ss.submission_id JOIN source_records r ON r.id=ss.source_record_id
WHERE s.paper_id<>r.paper_id''',
'decision_source_paper_mismatches': '''SELECT count(*) FROM decision_sources ds
JOIN decisions d ON d.id=ds.decision_id JOIN submissions s ON s.id=d.submission_id
JOIN source_records r ON r.id=ds.source_record_id WHERE s.paper_id<>r.paper_id''',
}
for name, query in checks.items():
integrity[name] = connection.execute(query).fetchone()[0]
return {'report_started_at': started_at, 'checked_at': timestamp(),
'engine': 'postgresql' if pg_service else 'sqlite', 'database': database,
'schema': schema, 'pg_service': pg_service,
'status': 'observed_history_audit_not_final_dedup', 'counts': dict(totals),
'latest_attempt_status_counts': dict(sorted(latest.items())),
'evidence_relationship_checks': integrity,
'examples': [_example(connection, rows) for rows in sample_attempts],
'example_limit': examples,
'interpretation': [
'Counts use existing canonical paper links, not independently verified real-world identity.',
'Negative includes rejected, desk_rejected and withdrawn observations; these remain distinct decisions.',
'Known acceptance follows the latest observed decision per attempt, matching query --outcome.',
'Distinct attempts/venues require separate submission/venue IDs; aliases do not create new venues.',
'Coexisting evidence does not prove rejection happened before acceptance. observed_at is collection time.',
'No known acceptance is not proof of never accepted. Source signatures and completeness are not established here.',
'Examples are bounded; counts include all attempts and observations in one read-only snapshot.',
'This does not perform entity merging, final deduplication, or label namespace/type validation.',
]}
finally:
connection.close()
def main(argv=None, *, prog=None):
parser = argparse.ArgumentParser(prog=prog)
database_arguments(parser)
parser.add_argument('--examples', type=int, default=20)
parser.add_argument('--out')
args = parser.parse_args(argv)
result = report(args.db, pg_service=args.pg_service, pg_schema=args.pg_schema, examples=args.examples)
text = json.dumps(result, ensure_ascii=False, indent=2)
if args.out:
path = Path(args.out)
stage = path.with_suffix('.part')
stage.write_text(text + '\n')
stage.replace(path)
else:
print(text)
if __name__ == '__main__':
main()
+3 -1
View File
@@ -39,7 +39,9 @@ def audit(db, inventory, out, *, pg_service=None, pg_schema=None):
if json.loads(path.with_suffix('.manifest.json').read_text()) != manifest:
raise ValueError('Collected manifest differs from inventory')
count = 0
for line in path.read_text().splitlines():
# Unicode paragraph/line separators are valid inside JSON strings;
# split physical byte lines, not Unicode text boundaries.
for line in path.read_bytes().splitlines():
row = json.loads(line)
if any(row[key] != manifest[key] for key in ('source', 'venue', 'year', 'track')):
raise ValueError('Record edition differs from inventory')
+2
View File
@@ -25,6 +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','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 类别证据'),
@@ -35,6 +36,7 @@ COMMANDS = {
('audit','arxiv'): ('paper_atlas.audits.arxiv', {}, '核验 arXiv 原始编号、重复行与 ZIP 完整性'),
('audit','arxiv-tags'): ('paper_atlas.audits.arxiv_tags', {}, '逐记录核验 arXiv 类别及出处完整性'),
('audit','public-openreview'): ('paper_atlas.audits.public_openreview', {}, '核对公开负面决定与已有投稿历史'),
('audit','histories'): ('paper_atlas.audits.histories', {}, '只读核对跨会议投稿历史及决定来源关系'),
}
+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.
+4 -35
View File
@@ -27,25 +27,11 @@ RAW_ROOT = 'https://raw.githubusercontent.com/papercopilot/openreview/' + REVISI
TREE_URL = 'https://api.github.com/repos/papercopilot/openreview/git/trees/' + REVISION + '?recursive=1'
NEGATIVE = {'reject': 'rejected', 'rejected': 'rejected',
'desk reject': 'desk_rejected', 'desk rejected': 'desk_rejected',
'withdraw': 'withdrawn', 'withdrawn': 'withdrawn', 'withdrawal': 'withdrawn'}
'withdrawn': 'withdrawn', 'withdrawal': 'withdrawn'}
TRACKS = {'main': 'main', 'Position': 'position',
'Datasets & Benchmarks': 'datasets_benchmarks'}
WORKSHOP_REJECT_LABELS = {"aim-fm workshop @ neurips'24 reject",
'coginterp @ neurips 2025 reject'}
# Exact curated labels, tied to the year and raw workshop track. The official
# venue directory establishes workshop identity, not an original decision signature.
ICLR_WORKSHOP_LABELS = {
'iclr 2024 workshop mefomo withdrawn submission':
(2024, 'workshop_me_fomo', 'withdrawn', 'ME-FoMo'),
'iclr 2024 workshop genai4dm withdrawn submission':
(2024, 'workshop_genai4dm', 'withdrawn', 'GenAI4DM'),
'iclr 2025 workshop agenticai reject':
(2025, 'workshop_agenticai', 'rejected', 'AgenticAI'),
'iclr 2025 workshop lmrl withdrawn submission':
(2025, 'workshop_lmrl', 'withdrawn', 'LMRL'),
'iclr 2026 workshop llm reasoning withdrawn submission':
(2026, 'workshop_llm_reasoning', 'withdrawn', 'LLM_Reasoning'),
}
def download(url, limit):
@@ -77,20 +63,6 @@ def normalize(row, family, year, provenance, include_workshops=False):
"""Return a normalized assertion or a reason requiring separate review."""
raw_status = row.get('status', '')
status = negative_status(raw_status)
workshop = ICLR_WORKSHOP_LABELS.get(' '.join(raw_status.casefold().split()))
workshop_metadata = {}
if workshop:
expected_year, expected_track, status, group_name = workshop
if family != 'iclr' or year != expected_year:
raise ValueError('Workshop status label conflicts with source conference year')
if row.get('track') != expected_track:
return None, 'conflicting_workshop_track'
if not include_workshops:
return None, 'separate_workshop'
workshop_metadata = {
'workshop_venueid': f'ICLR.cc/{year}/Workshop/{group_name}',
'workshop_identity_evidence_url': 'https://openreview.net/venue?id=ICLR.cc',
}
if status is None:
if re.search(r'\bretract\w*\b', raw_status, re.I):
return None, 'retracted'
@@ -118,9 +90,7 @@ def normalize(row, family, year, provenance, include_workshops=False):
return None, 'unmapped_venue'
title = row.get('title')
abstract = row.get('abstract')
if title is None or (isinstance(title, str) and not title.strip()):
return None, 'missing_title'
if not isinstance(title, str):
if not isinstance(title, str) or not title.strip():
raise ValueError('Public submission lacks a title')
if abstract is not None and not isinstance(abstract, str):
raise ValueError('Abstract must be text or null')
@@ -146,8 +116,7 @@ def normalize(row, family, year, provenance, include_workshops=False):
'venue': venue, 'year': year, 'track': normalized_track,
'status': status, 'raw_decision': raw_status, 'evidence_url': evidence,
'observed_at': AS_OF, 'tags': tags,
'metadata': {**provenance, **workshop_metadata,
'raw_track': track, 'raw_status': raw_status,
'metadata': {**provenance, 'raw_track': track, 'raw_status': raw_status,
'evidence_kind': 'curated_public_status_assertion',
'original_decision_signature_verified': False,
'source_snapshot_upper_bound': AS_OF}}, None
@@ -246,7 +215,7 @@ def _collect_files(out, selected, include_workshops, fetcher):
name = f'public-openreview-{family}-{year}-{track}.jsonl'
path = stage / name
path.write_text(''.join(json.dumps(row, ensure_ascii=False) + '\n' for row in records))
manifest = {'source': SOURCE, 'parser_version': 3, 'venue': records[0]['venue'],
manifest = {'source': SOURCE, 'parser_version': 1, 'venue': records[0]['venue'],
'year': year, 'track': track, 'count': len(records), 'status': 'partial',
'file': str(out / name), 'file_sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
'list_url': REPOSITORY + '/tree/' + REVISION, 'source_revision': REVISION,
+14
View File
@@ -144,6 +144,20 @@ class Store:
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'))
+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)
+114
View File
@@ -0,0 +1,114 @@
from pathlib import Path
import tempfile
import unittest
from paper_atlas.audits.histories import report
from paper_atlas.db.store import Store
def add_history_fixture(store):
def event(work, forum, venue, year, track, status, observed):
paper, record = store.record('history_fixture', work + '/' + forum,
'https://example.org/' + forum, work + ' title\x00 preserved', 'Abstract', ['Jane Doe'],
{'arxiv': work})
store.decision(paper, record, venue, year, track, forum,
'https://example.org/' + forum + '/' + str(status), status, status or '',
identities=[('openreview', forum)], observed_at=observed)
return paper
# The negative observation was collected later: observation order is not chronology.
event('2501.00001', 'HistoryA01', 'ICLR', 2024, 'main', 'rejected', '2026-02-01T00:00:00Z')
event('2501.00001', 'HistoryA02', 'ICML', 2025, 'main', 'accepted', '2026-01-01T00:00:00Z')
event('2501.00002', 'HistoryB01', 'ICLR', 2024, 'main', 'rejected', '2026-01-01T00:00:00Z')
event('2501.00002', 'HistoryB01', 'ICLR', 2024, 'main', 'accepted', '2026-02-01T00:00:00Z')
event('2501.00003', 'HistoryC01', 'ICLR', 2024, 'main', 'desk_rejected', '2026-01-01T00:00:00Z')
event('2501.00003', 'HistoryC02', 'ICLR', 2024, 'workshop_example', 'accepted', '2026-02-01T00:00:00Z')
event('2501.00004', 'HistoryD01', 'ICML', 2025, 'main', 'accepted', '2026-01-01T00:00:00Z')
event('2501.00004', 'HistoryD01', 'ICML', 2025, 'main', 'withdrawn', '2026-02-01T00:00:00Z')
event('2501.00004', 'HistoryD02', 'ICLR', 2024, 'main', 'rejected', '2026-01-01T00:00:00Z')
event('2501.00005', 'HistoryE01', 'COLM', 2026, 'main', None, '2026-01-01T00:00:00Z')
store.conn.commit()
class HistoryTests(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)
def test_distinct_attempts_venues_and_latest_acceptances_are_not_observation_counts(self):
add_history_fixture(self.store)
result = report(self.db)
self.assertEqual(result['counts'], {
'papers_with_submissions': 5, 'papers_with_multiple_submissions': 3,
'papers_with_historical_negative': 4, 'papers_with_current_known_acceptance': 3,
'papers_with_negative_and_acceptance_in_distinct_attempts': 2,
'papers_with_negative_and_acceptance_in_distinct_venues': 1,
'submissions': 8, 'decision_observations': 9,
'attempts_with_historical_negative': 5,
'attempts_with_historical_negative_and_acceptance': 2,
})
self.assertEqual(result['latest_attempt_status_counts'],
{'accepted': 3, 'desk_rejected': 1, 'rejected': 2, 'unknown': 1, 'withdrawn': 1})
self.assertEqual(len(result['examples']), 2)
first = result['examples'][0]
self.assertIn('\x00', first['title'])
self.assertEqual({r['venue'] for r in first['attempts']}, {'ICLR', 'ICML'})
self.assertTrue(all(r['decisions'][0]['sources'] for r in first['attempts']))
self.assertTrue(all(r['decisions'][0]['decision_date'] is None for r in first['attempts']))
self.assertIn('Not inferred', first['chronology'])
self.assertEqual(result['status'], 'observed_history_audit_not_final_dedup')
self.assertTrue(all(v == 0 for v in result['evidence_relationship_checks'].values()))
def test_missing_and_mislinked_source_evidence_is_reported_without_repair(self):
add_history_fixture(self.store)
self.store.conn.execute('DELETE FROM decision_sources WHERE decision_id=1')
self.store.conn.execute('DELETE FROM submission_sources WHERE submission_id=1')
# Record3 belongs to paper2; move it to paper1 to create a real mismatch.
self.store.conn.execute('UPDATE source_records SET paper_id=1 WHERE id=3')
self.store.conn.commit()
result = report(self.db, examples=0)
checks = result['evidence_relationship_checks']
self.assertEqual(checks['decisions_without_source_links'], 1)
self.assertEqual(checks['submissions_without_source_links'], 1)
self.assertEqual(checks['submission_source_paper_mismatches'], 1)
self.assertEqual(checks['decision_source_paper_mismatches'], 2)
self.assertEqual(self.store.conn.execute('SELECT paper_id FROM source_records WHERE id=3').fetchone()[0], 1)
def test_example_limit_does_not_change_global_counts(self):
add_history_fixture(self.store)
none, one = report(self.db, examples=0), report(self.db, examples=1)
self.assertEqual(none['counts'], one['counts'])
self.assertEqual(none['examples'], [])
self.assertEqual(len(one['examples']), 1)
def test_empty_snapshot_and_invalid_limit_do_not_create_missing_database(self):
empty = report(self.db)
self.assertTrue(all(v == 0 for v in empty['counts'].values()))
self.assertEqual(empty['latest_attempt_status_counts'], {})
absent = self.db.parent / 'absent.sqlite'
with self.assertRaisesRegex(ValueError, 'Examples'):
report(absent, examples=-1)
with self.assertRaisesRegex(ValueError, 'Existing database'):
report(absent)
self.assertFalse(absent.exists())
def test_truncated_example_keeps_the_cross_venue_acceptance_visible(self):
paper, record = self.store.record('fixture', 'many', 'https://example.org/many',
'Many attempts', 'Abstract', [])
for index in range(26):
accepted = index == 25
forum = f'BoundedForum{index}'
self.store.decision(paper, record, 'ICML' if accepted else 'ICLR', 2025, 'main', forum,
'https://example.org/' + forum, 'accepted' if accepted else 'rejected',
'Accept' if accepted else 'Reject', identities=[('openreview', forum)])
self.store.conn.commit()
result = report(self.db)
example, = result['examples']
self.assertEqual((example['attempt_count'], len(example['attempts'])), (26, 20))
self.assertTrue(example['attempts_truncated'])
self.assertEqual({row['latest_observed_status'] for row in example['attempts']},
{'accepted', 'rejected'})
self.assertEqual(result['counts']['submissions'], 26)
+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')
+16
View File
@@ -175,6 +175,22 @@ class PostgreSQLIntegrationTests(unittest.TestCase):
finally:
reader.close()
def test_history_audit_matches_sqlite_for_actual_postgresql_snapshot(self):
from paper_atlas.audits.histories import report
from tests.test_histories import add_history_fixture
store = Store(self.db)
try:
add_history_fixture(store)
finally:
store.conn.close()
expected = report(self.db)
self.assertEqual(self.migrate()['status'], 'complete')
actual = report(pg_service=self.service, pg_schema=self.target)
self.assertEqual(actual['engine'], 'postgresql')
self.assertEqual(actual['schema'], self.target)
for key in ('counts', 'latest_attempt_status_counts', 'evidence_relationship_checks', 'examples'):
self.assertEqual(actual[key], expected[key], key)
def interrupt_after_first_table(self):
calls = 0
def interrupt(*args):
+30 -118
View File
@@ -45,12 +45,10 @@ class PublicOpenReviewTests(unittest.TestCase):
def test_only_explicit_decisions_and_verified_scope_become_events(self):
for value in ('Pending', 'Active', 'Submitted to NeurIPS 2025', 'TBD', '',
'Low score', 'ICML 2025 Retracted by Authors', 'Do not reject',
'Workshop', 'Poster Workshop', 'Oral Workshop'):
'Low score', 'ICML 2025 Retracted by Authors', 'Do not reject'):
self.assertIsNone(negative_status(value), value)
self.assertEqual(negative_status('Reject'), 'rejected')
self.assertEqual(negative_status('Desk rejected'), 'desk_rejected')
self.assertEqual(negative_status('Withdraw'), 'withdrawn')
self.assertEqual(negative_status('NeurIPS 2023 Conference Withdrawn Submission'), 'withdrawn')
for raw, expected in [('main', 'main'), ('Position', 'position'),
('Datasets & Benchmarks', 'datasets_benchmarks')]:
@@ -100,121 +98,6 @@ class PublicOpenReviewTests(unittest.TestCase):
self.assertEqual(list(Path(tmp).glob('*.jsonl')), [])
self.assertFalse((Path(tmp) / 'public-openreview-inventory.json').exists())
def test_missing_title_is_held_without_losing_valid_negative_records(self):
with tempfile.TemporaryDirectory() as tmp:
result = self.collect(tmp, {'venues/iclr/iclr2017.jsonl': [
self.row(id='SJUdkecgx', title=''),
self.row(id='NoTitle001', title=None),
self.row(id='Whitespace', title=' '),
self.row(id='ValidReject'),
self.row(id='Withdraw01', status='Withdraw'),
self.row(id='Workshop01', status='Workshop'),
]}, families=['iclr'], years=[2017])
self.assertEqual(result['input_rows'], 6)
self.assertEqual(result['normalized_records'], 2)
self.assertEqual(result['source_files'][0]['excluded_or_held'],
{'missing_title': 3, 'not_explicit_negative': 1})
held_path = Path(result['held_file'])
self.assertEqual(hashlib.sha256(held_path.read_bytes()).hexdigest(),
result['held_file_sha256'])
held = [json.loads(line) for line in held_path.read_text().splitlines()]
self.assertEqual([row['id'] for row in held], ['SJUdkecgx', 'NoTitle001', 'Whitespace'])
self.assertTrue(all(row['reason'] == 'missing_title' and row['status'] == 'Reject'
for row in held))
self.assertEqual([row['provenance']['source_line'] for row in held], [1, 2, 3])
self.assertNotIn('private@example.org', held_path.read_text())
self.assertNotIn('private review', held_path.read_text())
manifest = result['files'][0]
self.assertEqual((manifest['parser_version'], manifest['status']), (3, 'partial'))
rows = [json.loads(line) for line in Path(manifest['file']).read_text().splitlines()]
self.assertEqual([(row['identifiers']['openreview'], row['status']) for row in rows],
[('ValidReject', 'rejected'), ('Withdraw01', 'withdrawn')])
for row in held:
self.assertEqual(row['provenance']['source_revision'], REVISION)
def test_nontext_title_still_fails_collection(self):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaisesRegex(ValueError, 'lacks a title'):
self.collect(tmp, {'venues/iclr/iclr2017.jsonl': [self.row(title=['invalid'])]},
families=['iclr'], years=[2017])
self.assertFalse((Path(tmp) / 'public-openreview-inventory.json').exists())
self.assertEqual(list(Path(tmp).glob('*.jsonl')), [])
def workshop_cases(self):
return [
(2024, 'workshop_me_fomo', 'ICLR 2024 Workshop MEFoMo Withdrawn Submission', 'withdrawn', 'ME-FoMo'),
(2024, 'workshop_genai4dm', 'ICLR 2024 Workshop GenAI4DM Withdrawn Submission', 'withdrawn', 'GenAI4DM'),
(2025, 'workshop_agenticai', 'ICLR 2025 Workshop AgenticAI Reject', 'rejected', 'AgenticAI'),
(2025, 'workshop_lmrl', 'ICLR 2025 Workshop LMRL Withdrawn Submission', 'withdrawn', 'LMRL'),
(2026, 'workshop_llm_reasoning', 'ICLR 2026 Workshop LLM Reasoning Withdrawn Submission', 'withdrawn', 'LLM_Reasoning'),
]
def test_iclr_workshop_labels_require_opt_in_and_retain_identity_evidence(self):
for year, track, label, status, group in self.workshop_cases():
with self.subTest(label=label):
raw = self.row(track=track, status=label)
self.assertEqual(normalize(raw, 'iclr', year, self.provenance()),
(None, 'separate_workshop'))
row, reason = normalize(raw, 'iclr', year, self.provenance(), True)
self.assertIsNone(reason)
self.assertEqual((row['venue'], row['year'], row['track'], row['status']),
('ICLR', year, track, status))
self.assertEqual(row['raw_decision'], label)
self.assertEqual(row['metadata']['workshop_venueid'],
f'ICLR.cc/{year}/Workshop/{group}')
self.assertFalse(row['metadata']['original_decision_signature_verified'])
self.assertEqual(row['evidence_url'], 'https://example.org/pinned.jsonl#L7')
self.assertNotIn('private review', json.dumps(row))
def test_workshop_label_scope_conflicts_never_become_main_events(self):
for year, track, label, _, _ in self.workshop_cases():
for enabled in (False, True):
for wrong_track in ('main', 'workshop_other'):
self.assertEqual(normalize(self.row(track=wrong_track, status=label),
'iclr', year, self.provenance(), enabled),
(None, 'conflicting_workshop_track'))
for family, wrong_year in [('icml', year), ('iclr', year + 1)]:
with self.assertRaisesRegex(ValueError, 'conference year'):
normalize(self.row(track=track, status=label), family,
wrong_year, self.provenance(), enabled)
row, reason = normalize(self.row(track='workshop_unknown',
status='ICLR 2026 Workshop Unknown Reject'),
'iclr', 2026, self.provenance(), True)
self.assertIsNone(row)
self.assertEqual(reason, 'unmapped_negative_label')
def test_workshop_collection_and_import_keep_five_tracks_separate_from_main(self):
files = {}
for index, (year, track, label, _, _) in enumerate(self.workshop_cases()):
files.setdefault(f'venues/iclr/iclr{year}.jsonl', []).append(
self.row(id=f'Workshop{index:02}', track=track, status=label))
files['venues/iclr/iclr2024.jsonl'].append(self.row())
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
default = self.collect(root / 'default', files, families=['iclr'])
self.assertEqual(default['normalized_records'], 1)
held = [json.loads(line) for line in Path(default['held_file']).read_bytes().split(b'\n') if line]
self.assertEqual(len(held), 5)
self.assertTrue(all(row['reason'] == 'separate_workshop' for row in held))
selected = self.collect(root / 'selected', files, families=['iclr'], include_workshops=True)
self.assertEqual(selected['normalized_records'], 6)
self.assertEqual(len(selected['files']), 6)
self.assertEqual(Path(selected['held_file']).read_bytes(), b'')
db = root / 'db.sqlite'
for manifest in selected['files']:
self.assertEqual(manifest['parser_version'], 3)
ingest_file(db, manifest['file'])
store = Store(db)
try:
scopes = set(store.conn.execute('SELECT e.year,e.track,d.status FROM submissions s '
'JOIN editions e ON e.id=s.edition_id '
'JOIN decisions d ON d.submission_id=s.id'))
expected = {(year, track, status) for year, track, _, status, _ in self.workshop_cases()}
self.assertEqual(scopes, expected | {(2024, 'main', 'rejected')})
self.assertEqual(store.conn.execute('PRAGMA foreign_key_check').fetchall(), [])
finally:
store.conn.close()
def test_mirror_reimport_preserves_one_attempt_and_later_acceptance_elsewhere(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -269,6 +152,35 @@ class PublicOpenReviewTests(unittest.TestCase):
finally:
store.conn.close()
def test_unicode_separators_in_abstract_survive_audit_and_import(self):
for separator in ('\u0085', '\u2028', '\u2029'):
with self.subTest(separator=repr(separator)), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
abstract = 'First paragraph' + separator + 'Second paragraph'
result = self.collect(root / 'source', {
'venues/icml/icml2025.jsonl': [self.row(abstract=abstract)]})
path = Path(result['files'][0]['file'])
data = path.read_bytes()
self.assertIn(separator.encode(), data)
self.assertEqual(data.count(b'\n'), 1)
db = root / 'db.sqlite'
store = Store(db)
store.conn.close()
inventory = root / 'source/public-openreview-inventory.json'
before = audit(db, inventory, root / 'before.json')
self.assertEqual(before['status'], 'ready_for_serial_import')
self.assertEqual(before['records_checked'], 1)
ingest_file(db, path)
after = audit(db, inventory, root / 'after.json')
self.assertEqual(after['status'], 'ready_for_serial_import')
self.assertEqual(after['existing_same_edition_attempts'], 1)
store = Store(db)
try:
self.assertEqual(store.conn.execute(
'SELECT abstract FROM source_records').fetchone()[0], abstract)
finally:
store.conn.close()
if __name__ == '__main__':
unittest.main()