Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fad2fc575 | |||
| fd3fdb6cf7 | |||
| 346208a272 |
@@ -53,6 +53,7 @@ jobs:
|
||||
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','coverage','--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()"
|
||||
|
||||
@@ -51,7 +51,8 @@ PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas query --pg-service fo
|
||||
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas query --pg-service foundry-research --paper 123
|
||||
|
||||
# 列出全部目标会议,包括尚未入库的项目
|
||||
paper-atlas coverage --db /data/paper-index/papers.sqlite \
|
||||
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas coverage \
|
||||
--pg-service foundry-research --pg-schema research \
|
||||
--out /data/paper-index/collected/venue-coverage-current.json
|
||||
|
||||
# 采集与导入分开;这里只采集到独立文件
|
||||
|
||||
@@ -178,10 +178,6 @@ paper-atlas collect dblp \
|
||||
|
||||
输出按会议/年份/track 分文件,附 manifest、`dblp-normalization.json` 范围审查统计和带哈希的 `dblp-import-queue.json`。不能识别的书目、年份或归属会逐类登记,不默认算作主会。DBLP 不含摘要,因此输出摘要为空、清单为 partial;需要通过有证据的跨来源关联补齐论文实体摘要。书目发表证据与原始审稿决定分别保留,不依据书目缺席推断拒稿。已存在其他出版来源的届次,还须先回填其权威身份并核验镜像记录,不能直接累加投稿数。
|
||||
|
||||
早期 AISTATS 1995/1997/1999/2001/2003/2005 和 COLT 1988–1991 的主系列名称本身含 Workshop,不能当作附属 workshop 漏掉。识别要求已核对的完整 proceedings key、会议书目名、原始标题前缀、届数和事件年一致;轨道名为 `main` 表示主系列,保留原始 Workshop 名称及官方目录证据,不改造其历史名称或出版形式。参见 [AISTATS 官方历史目录](https://aistats.org/other.html)、[COLT 协会目录](https://www.learningtheory.org/)。1995 AISTATS 的会议年与1996图书出版年分开;[PMLR R0](https://proceedings.mlr.press/r0/) 是后来重发的 pre-proceedings,不与 DBLP 图书条目盲目逐篇等同。
|
||||
|
||||
真正附属的 AISTATS 2010 Active Learning and Experimental Design workshop 仍单列。COLT 2003 与 Kernel Workshop 的 [混合论文集](https://doi.org/10.1007/b12006) 缺少逐篇章节信息,使用 `proceedings_unspecified`,不能整卷改成 main 或附属 workshop。输出保存 `track_scope_note` 与 `track_scope_evidence_url`,导入后仍可追查分类理由;这些出版证据不是真实拒稿或原始决定签名,也不自动修正既有主库记录。
|
||||
|
||||
部分早期 SIGIR、CIKM、IJCAI 出版卷使用两位年份键;只有论文集标题明确给出唯一完整年份且与短键相符时才采用,不推测世纪。WWW 特殊兴趣、Alternate track 单列;同卷混合 Applied Data Science、Demo、Industrial 且不能逐篇区分时使用 `proceedings_unspecified`,不把整卷归为某一个具体 track。
|
||||
|
||||
书目中的前后页、目录组合和明确会务标题不产生论文录用记录;缺作者的赞助、委员会、活动汇总等标题单独排除。WWW 2019 companion 的匿名合成活动条目归入待核查材料,原始快照仍保留。范围报告分别统计这些原因;单纯缺作者并不排除真实研究论文,也不补造作者。
|
||||
|
||||
+5
-2
@@ -97,13 +97,16 @@ paper-atlas audit arxiv-tags --db /data/paper-index/papers.sqlite \
|
||||
采集文件、已入库的来源记录和去重论文实体是不同的计数。来源可能覆盖同一篇论文,同一篇论文也可能有多次投稿;不能把各批文件数量相加作为独立论文总数。实时统计与覆盖报告使用:
|
||||
|
||||
```sh
|
||||
paper-atlas query --db /data/paper-index/papers.sqlite --stats
|
||||
paper-atlas coverage --db /data/paper-index/papers.sqlite \
|
||||
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas query --pg-service foundry-research --pg-schema research --stats
|
||||
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas coverage \
|
||||
--pg-service foundry-research --pg-schema research \
|
||||
--out /data/paper-index/collected/venue-coverage-current.json
|
||||
```
|
||||
|
||||
manifest 和采集器状态清单用于确认文件是否齐全及是否缺摘要;`ingest_runs` 和协调器日志用于确认主库导入是否成功。官方程序、书目目录和公开投稿快照分别保留,未出现在某份清单不等于被拒稿;第三方快照也不能代表平台当前全量状态。代码测试成功不能代替最终全库去重、完整性和逐届覆盖核验。
|
||||
|
||||
`coverage` 的所有计数来自同一个只读快照。`latest_observed_status_counts` 按每次投稿的最新观察决定统计,`decision_observation_counts` 保留所有历史观察;重复来源不增加投稿数,缺少决定的投稿计入最新状态 unknown,但不凭空制造 unknown 决定。`source_record_counts` 统计来源在该届次的实际记录与缺摘要数,和 `sources` 中逐清单的预期/导入数量分开;身份补充清单与原始论文集清单可以描述同一条记录。来源缺摘要与论文实体从其他来源获得摘要也分别报告。报告仍将历史覆盖标为待独立审计,不能用某个清单 complete 或没有拒稿记录推断全会议覆盖。
|
||||
|
||||
当前代码与合并状态以 [Pull Requests](https://gitea.entroia.com/Zayden-Jung/paper-atlas/pulls) 为准;CI 结果以对应提交的 Actions 检查为准。工作分支交付后等待用户手动合并。合并代码不自动部署、不自动启动采集,也不代表数据批次已完成。
|
||||
## 修正已有 PMLR 投稿的轨道
|
||||
|
||||
|
||||
+4
-1
@@ -83,11 +83,14 @@ paper-atlas query --pg-service foundry-research --query '"language model"' --lim
|
||||
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
|
||||
PGSERVICEFILE=/etc/paper-atlas/pg_service.conf paper-atlas coverage \
|
||||
--pg-service foundry-research --pg-schema research --out /data/paper-index/collected/coverage.json
|
||||
```
|
||||
|
||||
`--status rejected` 筛选某次投稿的最新已知决定。它并不表示论文在所有会议均未录用;分析转投情况时查看该论文的完整投稿历史。年份表示会议届次,观察时间和决定日期分别保留。
|
||||
|
||||
覆盖报告使用只读一致性快照,标明实际数据库和 schema,并列出零入库目标与注册表外的已入库会议。逐届、逐 track 分别列出最新观察状态的投稿数、全部决定观察数、各来源记录数,以及来源摘要和论文实体摘要的缺失数。摘要缺失指 NULL 或空字符串;有文字不代表内容已经完整核验。同一来源的多个清单 URL 可以重叠,不能把清单计数相加当作来源总量;零条拒稿也不证明该会议没有公开拒稿。SQLite 样本仍可显式使用 `--db`,生产报告应查询当前 PostgreSQL。
|
||||
|
||||
例如,查找在 ICLR 有拒稿记录、但在其他已知投稿中已有录用的论文:
|
||||
|
||||
```sh
|
||||
|
||||
+118
-27
@@ -1,45 +1,136 @@
|
||||
"""Show every target venue, including those with no imported records."""
|
||||
"""Describe imported venue coverage without implying a complete census."""
|
||||
import argparse
|
||||
from paper_atlas.config import DEFAULT_REGISTRY
|
||||
from collections import defaultdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from paper_atlas.config import DEFAULT_REGISTRY
|
||||
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
|
||||
|
||||
|
||||
def report(db,registry):
|
||||
registry=json.loads(Path(registry).read_text());targets=registry['venues']
|
||||
names=[row['acronym'].casefold() for row in targets]
|
||||
if len(names)!=len(set(names)):raise ValueError('Duplicate target venue')
|
||||
scope='(v.acronym=? COLLATE NOCASE OR EXISTS(SELECT 1 FROM venue_aliases a WHERE a.venue_id=v.id AND a.alias=? COLLATE NOCASE))'
|
||||
c=sqlite3.connect(Path(db).resolve().as_uri()+'?mode=ro',uri=True);c.row_factory=sqlite3.Row
|
||||
STATUSES = ('accepted', 'rejected', 'desk_rejected', 'withdrawn', 'under_review', 'unknown')
|
||||
|
||||
|
||||
def _status_counts(connection, *, latest):
|
||||
if latest:
|
||||
query = '''SELECT s.edition_id,COALESCE(d.status,'unknown') AS status,count(*) AS count
|
||||
FROM submissions s LEFT JOIN decisions d ON d.submission_id=s.id AND ''' + LATEST_DECISION
|
||||
else:
|
||||
query = '''SELECT s.edition_id,COALESCE(d.status,'unknown') AS status,count(*) AS count
|
||||
FROM submissions s JOIN decisions d ON d.submission_id=s.id'''
|
||||
counts = defaultdict(dict)
|
||||
for row in connection.execute(query + " GROUP BY s.edition_id,COALESCE(d.status,'unknown')"):
|
||||
counts[row[0]][row[1]] = row[2]
|
||||
return counts
|
||||
|
||||
|
||||
def report(db, registry, *, pg_service=None, pg_schema=None):
|
||||
started_at = timestamp()
|
||||
registry = json.loads(Path(registry).read_text())
|
||||
targets = registry['venues']
|
||||
names = [row['acronym'].casefold() for row in targets]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError('Duplicate target venue')
|
||||
scope = '''(lower(v.acronym)=lower(?) OR EXISTS(
|
||||
SELECT 1 FROM venue_aliases a WHERE a.venue_id=v.id AND lower(a.alias)=lower(?)))'''
|
||||
connection = open_read_connection(db, pg_service=pg_service, pg_schema=pg_schema)
|
||||
if not pg_service:
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
c.execute('BEGIN');venues=[]
|
||||
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
|
||||
latest = _status_counts(connection, latest=True)
|
||||
observations = _status_counts(connection, latest=False)
|
||||
# Aggregate the paper join once for the whole snapshot. Repeating it
|
||||
# for each registry target caused repeated scans on the production DB.
|
||||
edition_counts = {}
|
||||
for row in connection.execute('''SELECT s.edition_id,count(s.id) AS submissions,
|
||||
count(DISTINCT s.paper_id) AS papers,
|
||||
count(DISTINCT CASE WHEN p.abstract IS NULL OR p.abstract='' THEN s.paper_id END)
|
||||
AS papers_without_abstract_text
|
||||
FROM submissions s LEFT JOIN papers p ON p.id=s.paper_id GROUP BY s.edition_id'''):
|
||||
entry = dict(row)
|
||||
edition_counts[entry.pop('edition_id')] = entry
|
||||
source_counts = defaultdict(list)
|
||||
for row in connection.execute('''SELECT s.edition_id,so.name AS source,
|
||||
count(DISTINCT sr.id) AS source_records,
|
||||
count(DISTINCT CASE WHEN sr.abstract IS NULL OR sr.abstract='' THEN sr.id END)
|
||||
AS source_records_without_abstract_text
|
||||
FROM submission_sources ss JOIN submissions s ON s.id=ss.submission_id
|
||||
JOIN source_records sr ON sr.id=ss.source_record_id JOIN sources so ON so.id=sr.source_id
|
||||
GROUP BY s.edition_id,so.name ORDER BY s.edition_id,so.name'''):
|
||||
entry = dict(row)
|
||||
source_counts[entry.pop('edition_id')].append(entry)
|
||||
venues, covered_venue_ids = [], set()
|
||||
for target in targets:
|
||||
acronym=target['acronym'];params=(acronym,acronym)
|
||||
editions=[]
|
||||
for row in c.execute('SELECT e.id,v.acronym AS canonical_venue,e.year,e.track,count(s.id) AS submissions,count(DISTINCT s.paper_id) AS papers FROM editions e JOIN venues v ON v.id=e.venue_id LEFT JOIN submissions s ON s.edition_id=e.id WHERE '+scope+' GROUP BY e.id ORDER BY e.year,e.track',params):
|
||||
edition=dict(row)
|
||||
edition['sources']=[dict(source) for source in c.execute('SELECT so.name,es.status,es.expected_count,es.imported_count,es.list_url,es.last_success_at,es.last_error FROM edition_sources es JOIN sources so ON so.id=es.source_id WHERE es.edition_id=? ORDER BY so.name',(row['id'],))]
|
||||
acronym = target['acronym']
|
||||
params = (acronym, acronym)
|
||||
editions = []
|
||||
for row in connection.execute('''SELECT e.id,v.id AS venue_id,v.acronym AS canonical_venue,
|
||||
e.year,e.track FROM editions e JOIN venues v ON v.id=e.venue_id
|
||||
WHERE ''' + scope + '''
|
||||
ORDER BY e.year,e.track,v.acronym''', params):
|
||||
edition = dict(row)
|
||||
edition.update(edition_counts.get(row['id'], {
|
||||
'submissions': 0, 'papers': 0, 'papers_without_abstract_text': 0}))
|
||||
covered_venue_ids.add(edition['venue_id'])
|
||||
edition['latest_observed_status_counts'] = {status: latest[row['id']].get(status, 0) for status in STATUSES}
|
||||
edition['decision_observation_counts'] = {status: observations[row['id']].get(status, 0) for status in STATUSES}
|
||||
edition['source_record_counts'] = source_counts[row['id']]
|
||||
edition['sources'] = [dict(source) for source in connection.execute('''SELECT so.name,
|
||||
es.status,es.expected_count,es.imported_count,es.list_url,es.last_success_at,es.last_error
|
||||
FROM edition_sources es JOIN sources so ON so.id=es.source_id
|
||||
WHERE es.edition_id=? ORDER BY so.name,es.list_url''', (row['id'],))]
|
||||
editions.append(edition)
|
||||
totals=c.execute('SELECT count(s.id),count(DISTINCT s.paper_id) FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id WHERE '+scope,params).fetchone()
|
||||
venues.append({'acronym':acronym,'field':target['field'],'imported_submissions':totals[0],'distinct_papers':totals[1],
|
||||
'history_status':'partial_history_pending_independent_audit' if totals[0] else 'no_imported_records',
|
||||
'discovery_sources':target.get('discovery_sources',[]),'editions':editions})
|
||||
return {'checked_at':timestamp(),'target_venue_count':len(targets),'venues_with_imported_records':sum(bool(v['imported_submissions']) for v in venues),
|
||||
'scope_note':registry['scope_note'],'interpretation':'All registry targets are listed. Edition source complete means that source file passed import checks; it does not prove full historical or abstract coverage. Papers can appear in multiple venues/years, so do not sum venue paper counts as a global total.',
|
||||
'venues':venues}
|
||||
finally:c.close()
|
||||
totals = connection.execute('''SELECT count(s.id),count(DISTINCT s.paper_id)
|
||||
FROM submissions s JOIN editions e ON e.id=s.edition_id
|
||||
JOIN venues v ON v.id=e.venue_id WHERE ''' + scope, params).fetchone()
|
||||
venues.append({'acronym': acronym, 'field': target['field'],
|
||||
'imported_submissions': totals[0], 'distinct_papers': totals[1],
|
||||
'history_status': 'partial_history_pending_independent_audit' if totals[0] else 'no_imported_records',
|
||||
'discovery_sources': target.get('discovery_sources', []), 'editions': editions})
|
||||
outside_registry = [dict(row) for row in connection.execute('''SELECT v.id AS venue_id,
|
||||
v.acronym,count(s.id) AS submissions,count(DISTINCT s.paper_id) AS papers
|
||||
FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id
|
||||
GROUP BY v.id,v.acronym ORDER BY v.acronym''') if row[0] not in covered_venue_ids]
|
||||
return {'report_started_at': started_at, 'checked_at': timestamp(),
|
||||
'engine': 'postgresql' if pg_service else 'sqlite', 'database': database, 'schema': schema,
|
||||
'pg_service': pg_service, 'target_venue_count': len(targets),
|
||||
'venues_with_imported_records': sum(bool(v['imported_submissions']) for v in venues),
|
||||
'scope_note': registry['scope_note'],
|
||||
'interpretation': 'All registry targets are listed, including zero imports. '
|
||||
'Latest status counts use the latest observed decision for each attempt; decision observations retain history. '
|
||||
'Source record totals are per source and edition, not per list URL; multiple list entries may describe overlapping records. '
|
||||
'Missing abstract text means NULL or empty string; nonempty text does not prove a complete abstract. '
|
||||
'An edition source marked complete is not a proof of complete conference history or public rejection coverage. '
|
||||
'Zero imported rejections does not establish that no rejections are public. '
|
||||
'Papers can appear in several venues/years; do not sum venue paper counts as a global total.',
|
||||
'venues': venues, 'imported_venues_outside_registry': outside_registry}
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def main(argv=None, *, prog=None):
|
||||
parser=argparse.ArgumentParser(prog=prog);parser.add_argument('--db',required=True)
|
||||
parser.add_argument('--registry',default=str(DEFAULT_REGISTRY));parser.add_argument('--out')
|
||||
args=parser.parse_args(argv);result=report(args.db,args.registry);text=json.dumps(result,ensure_ascii=False,indent=2)
|
||||
parser = argparse.ArgumentParser(prog=prog)
|
||||
database_arguments(parser)
|
||||
parser.add_argument('--registry', default=str(DEFAULT_REGISTRY))
|
||||
parser.add_argument('--out')
|
||||
args = parser.parse_args(argv)
|
||||
result = report(args.db, args.registry, pg_service=args.pg_service, pg_schema=args.pg_schema)
|
||||
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)
|
||||
path = Path(args.out)
|
||||
stage = path.with_suffix('.part')
|
||||
stage.write_text(text + '\n')
|
||||
stage.replace(path)
|
||||
else:
|
||||
print(text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -90,8 +90,6 @@ def normalize_row(row,volumes,targets,include_workshops=False,through_year=2026)
|
||||
'ambiguous_identifiers':ambiguous,'publication_evidence':'inproceedings bibliography; not an original decision note'}
|
||||
if edition.get('track_scope_note'):
|
||||
metadata['track_scope_note']=edition['track_scope_note']
|
||||
if edition.get('track_scope_evidence_url'):
|
||||
metadata['track_scope_evidence_url']=edition['track_scope_evidence_url']
|
||||
flagged=bool(re.search(r'\b(?:withdrawn|retracted)\b',row.get('publtype') or '',re.I))
|
||||
result={'source':'dblp','external_id':row['key'],'url':'https://dblp.org/rec/'+row['key']+'.html',
|
||||
'title':title,'abstract':None,'authors':authors,'identifiers':own,
|
||||
|
||||
@@ -38,39 +38,6 @@ BOOKS = {
|
||||
}
|
||||
|
||||
|
||||
# These are the historical primary series themselves, not co-located workshops.
|
||||
# Exact proceedings keys, original names, ordinals and event years must agree.
|
||||
HISTORICAL_WORKSHOP_TITLES = {
|
||||
**{('aistats', year): prefix + ordinal + ' International Workshop on Artificial Intelligence and Statistics'
|
||||
for year, ordinal, prefix in [
|
||||
(1995, 'Fifth', 'Learning from Data - '),
|
||||
(1997, 'Sixth', 'Proceedings of the '),
|
||||
(1999, 'Seventh', 'Proceedings of the '),
|
||||
(2001, 'Eighth', 'Proceedings of the '),
|
||||
(2003, 'Ninth', 'Proceedings of the '),
|
||||
(2005, 'Tenth', 'Proceedings of the ')]},
|
||||
**{('colt', year): 'Proceedings of the ' + ordinal + ' Annual Workshop on Computational Learning Theory'
|
||||
for year, ordinal in [(1988, 'First'), (1989, 'Second'), (1990, 'Third'), (1991, 'Fourth')]},
|
||||
}
|
||||
|
||||
|
||||
def historical_track(family, base, suffix, volume_key, year, title):
|
||||
if suffix or base.casefold() != family or volume_key != f'conf/{family}/{year}':
|
||||
return None
|
||||
prefix = HISTORICAL_WORKSHOP_TITLES.get((family, year))
|
||||
if (prefix and title.casefold().startswith(prefix.casefold() + ',')
|
||||
and re.search(r'\b' + str(year) + r'\b', title)):
|
||||
url = 'https://aistats.org/other.html' if family == 'aistats' else 'https://www.learningtheory.org/'
|
||||
return ('main', 'Historical primary series named Workshop; exact proceedings key, ordinal, title and event year verified.', url)
|
||||
if (family == 'colt' and year == 2003
|
||||
and title.startswith('Computational Learning Theory and Kernel Machines, 16th Annual Conference on Computational Learning Theory and 7th Kernel Workshop, COLT/Kernel 2003,')
|
||||
and title.endswith('Proceedings')):
|
||||
return ('proceedings_unspecified',
|
||||
'Combined COLT 2003 and Kernel Workshop volume; per-paper section absent from DBLP XML. Do not infer a main or workshop track.',
|
||||
'https://doi.org/10.1007/b12006')
|
||||
return None
|
||||
|
||||
|
||||
def scope(row,volume,through_year=2026):
|
||||
fields=row['fields'];books=fields.get('booktitle',[])
|
||||
if len(books)!=1:return None,'ambiguous_booktitle'
|
||||
@@ -108,9 +75,7 @@ def scope(row,volume,through_year=2026):
|
||||
if len(bibliographic)!=1 or not re.fullmatch(r'\d{4}',bibliographic[0]):return None,'ambiguous_bibliographic_year'
|
||||
if int(bibliographic[0])!=year and not re.search(r'\b'+str(year)+r'\b',title):return None,'unverified_year_difference'
|
||||
text=(suffix+' '+title).casefold();track='main'
|
||||
historical = historical_track(family, base, suffix, volume['key'], year, title)
|
||||
if historical:track=historical[0]
|
||||
elif 'findings' in text:track='findings'
|
||||
if 'findings' in text:track='findings'
|
||||
elif 'shared task' in text:track='shared_task'
|
||||
elif 'student research' in text or 'workshop' in text or 'wokshop' in text or base in ('ICCVW','EWSL'):track='workshop'
|
||||
elif ('demo track' in text and ('industrial track' in text or 'applied data science track' in text)):track='proceedings_unspecified'
|
||||
@@ -133,9 +98,6 @@ def scope(row,volume,through_year=2026):
|
||||
if venue=='AAMAS' and year<2002:return None,'predecessor_not_aamas'
|
||||
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 historical:
|
||||
result['track_scope_note'] = historical[1]
|
||||
result['track_scope_evidence_url'] = historical[2]
|
||||
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.
|
||||
|
||||
@@ -7,6 +7,34 @@ from paper_atlas.audits.coverage import report
|
||||
from paper_atlas.db.store import Store
|
||||
|
||||
|
||||
def add_coverage_evidence(store):
|
||||
paper, record = store.record('official_metadata', 'known', 'https://example.org/known',
|
||||
'Known paper with two source observations', 'Complete canonical abstract', ['Jane Doe', 'John Smith'],
|
||||
{'openreview': 'coverageForum', 'arxiv': '2401.98765'})
|
||||
edition = store.decision(paper, record, 'COLING+LREC', 2024, 'main', 'known',
|
||||
'https://example.org/accept', 'accepted', 'Accept', aliases=['COLING', 'LREC'],
|
||||
identities=[('openreview', 'coverageForum')], observed_at='2025-05-01T00:00:00Z')
|
||||
same, mirror = store.record('bibliography', 'mirror', 'https://example.org/mirror',
|
||||
'Known paper with two source observations', None, ['Jane Doe', 'John Smith'], {'openreview': 'coverageForum'})
|
||||
store.decision(same, mirror, 'COLING+LREC', 2024, 'main', 'mirror', 'https://example.org/older',
|
||||
'rejected', 'Reject', identities=[('openreview', 'coverageForum')],
|
||||
observed_at='2025-05-01T00:00:00Z', decision_date='2025-04-30T00:00:00Z')
|
||||
unknown, unknown_record = store.record('unknown_catalogue', 'unknown', 'https://example.org/unknown',
|
||||
'Indexed work without a public decision', None, [])
|
||||
store.decision(unknown, unknown_record, 'COLING+LREC', 2024, 'main', 'unknown',
|
||||
'https://example.org/unknown', None, '', identities=[('openreview', 'unknownForum')])
|
||||
same, later = store.record('official_metadata', 'later', 'https://example.org/later',
|
||||
'Known paper with two source observations', 'Complete canonical abstract', ['Jane Doe', 'John Smith'],
|
||||
{'openreview': 'coverageNext', 'arxiv': '2401.98765'})
|
||||
store.decision(same, later, 'COLING+LREC', 2025, 'findings', 'later', 'https://example.org/later',
|
||||
'accepted', 'Accept', identities=[('openreview', 'coverageNext')], observed_at='2025-06-01T00:00:00Z')
|
||||
source = store.source('official_metadata', 'https://example.org')
|
||||
for url, status in [('https://example.org/list', 'complete'), ('https://example.org/identity-refresh', 'partial')]:
|
||||
store.conn.execute('''INSERT INTO edition_sources(edition_id,source_id,list_url,status,expected_count,imported_count)
|
||||
VALUES(?,?,?,?,?,?)''', (edition, source, url, status, 1, 1))
|
||||
store.conn.commit()
|
||||
|
||||
|
||||
class CoverageTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||
@@ -31,5 +59,46 @@ class CoverageTests(unittest.TestCase):
|
||||
self.store.conn.commit();coling=report(self.db,self.registry)['venues'][0]
|
||||
self.assertEqual(coling['imported_submissions'],2);self.assertEqual(coling['distinct_papers'],1)
|
||||
|
||||
def test_observations_attempts_and_missing_source_abstracts_stay_separate(self):
|
||||
add_coverage_evidence(self.store)
|
||||
result = report(self.db, self.registry)
|
||||
self.assertEqual(result['engine'], 'sqlite')
|
||||
self.assertEqual(result['database'], str(self.db))
|
||||
coling = result['venues'][0]
|
||||
self.assertEqual((coling['imported_submissions'], coling['distinct_papers']), (3, 2))
|
||||
edition, findings = coling['editions']
|
||||
self.assertEqual((edition['submissions'], edition['papers'], edition['papers_without_abstract_text']), (2, 2, 1))
|
||||
self.assertEqual(edition['latest_observed_status_counts']['accepted'], 1)
|
||||
self.assertEqual(edition['latest_observed_status_counts']['rejected'], 0)
|
||||
self.assertEqual(edition['latest_observed_status_counts']['unknown'], 1)
|
||||
self.assertEqual(edition['decision_observation_counts']['accepted'], 1)
|
||||
self.assertEqual(edition['decision_observation_counts']['rejected'], 1)
|
||||
self.assertEqual(edition['decision_observation_counts']['unknown'], 0)
|
||||
counts = {row['source']: row for row in edition['source_record_counts']}
|
||||
self.assertEqual(counts['bibliography']['source_records_without_abstract_text'], 1)
|
||||
self.assertEqual(counts['official_metadata']['source_records'], 1)
|
||||
self.assertEqual(len(edition['sources']), 2)
|
||||
self.assertEqual(findings['track'], 'findings')
|
||||
self.assertEqual(result['imported_venues_outside_registry'], [])
|
||||
|
||||
def test_missing_sqlite_audit_does_not_create_a_database(self):
|
||||
path = self.root / 'absent.sqlite'
|
||||
with self.assertRaisesRegex(ValueError, 'Existing database'):
|
||||
report(path, self.registry)
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
def test_empty_editions_remain_visible_after_shared_aggregation(self):
|
||||
self.store.conn.execute("INSERT INTO venues(acronym,name,field,venue_type) VALUES('AAAI','AAAI','general_ai','conference')")
|
||||
venue = self.store.conn.execute("SELECT id FROM venues WHERE acronym='AAAI'").fetchone()[0]
|
||||
self.store.conn.execute("INSERT INTO editions(venue_id,year,track) VALUES(?,2026,'main')", (venue,))
|
||||
self.store.conn.commit()
|
||||
aaai = report(self.db, self.registry)['venues'][1]
|
||||
self.assertEqual(aaai['history_status'], 'no_imported_records')
|
||||
self.assertEqual(len(aaai['editions']), 1)
|
||||
edition = aaai['editions'][0]
|
||||
self.assertEqual((edition['submissions'], edition['papers'], edition['papers_without_abstract_text']), (0, 0, 0))
|
||||
self.assertEqual(sum(edition['latest_observed_status_counts'].values()), 0)
|
||||
self.assertEqual(edition['source_record_counts'], [])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import patch
|
||||
|
||||
from paper_atlas.collectors.dblp import XML_SHA
|
||||
from paper_atlas.collectors.dblp_records import collect,identifiers,normalize_row,material_reason
|
||||
from paper_atlas.collectors.dblp_scope import scope, HISTORICAL_WORKSHOP_TITLES
|
||||
from paper_atlas.collectors.dblp_scope import scope
|
||||
from paper_atlas.db.store import Store
|
||||
from paper_atlas.ingestion.records import ingest_file
|
||||
|
||||
@@ -70,69 +70,6 @@ class DblpRecordTests(unittest.TestCase):
|
||||
self.assertEqual(scope(self.row(),self.volume('kdd',2024))[1],'crossref_venue_conflict')
|
||||
self.assertEqual(scope(self.row(year=2027),self.volume('aaai',2027))[1],'future_edition')
|
||||
|
||||
def test_historical_primary_series_workshop_names_require_exact_editions(self):
|
||||
self.assertEqual(set(HISTORICAL_WORKSHOP_TITLES),
|
||||
{('aistats',year) for year in [1995,1997,1999,2001,2003,2005]} |
|
||||
{('colt',year) for year in [1988,1989,1990,1991]})
|
||||
for (family,year),prefix in HISTORICAL_WORKSHOP_TITLES.items():
|
||||
with self.subTest(family=family,year=year):
|
||||
title=prefix+f', {family.upper()} {year}, Proceedings.'
|
||||
volume=self.volume(family,year,title)
|
||||
row=self.row(family,family.upper(),year)
|
||||
if year==1995:
|
||||
row['fields']['year']=['1996'];volume['fields']['year']=['1996']
|
||||
edition,reason=scope(row,volume)
|
||||
self.assertIsNone(reason);self.assertEqual(edition['track'],'main')
|
||||
self.assertEqual(edition['year'],year)
|
||||
self.assertIn('Historical primary series',edition['track_scope_note'])
|
||||
record,reason=normalize_row(row,{volume['key']:volume},{family.upper()})
|
||||
self.assertIsNone(reason);self.assertEqual(record['metadata']['track_scope_evidence_url'],edition['track_scope_evidence_url'])
|
||||
self.assertEqual(record['metadata']['proceedings_title'],title)
|
||||
if year==1995:self.assertEqual(record['metadata']['bibliographic_year'],1996)
|
||||
|
||||
def test_historical_exception_does_not_promote_affiliated_or_wrong_scope_workshops(self):
|
||||
title=HISTORICAL_WORKSHOP_TITLES[('aistats',1995)]+', AISTATS 1995, Proceedings.'
|
||||
for book,key,changed in [
|
||||
('AISTATS','conf/aistats/1995w',title),
|
||||
('AISTATS (Workshops)','conf/aistats/1995',title),
|
||||
('AISTATS','conf/aistats/1995',title.replace('Fifth','Sixth')),
|
||||
('AISTATS','conf/aistats/1995',title.replace('1995','1997'))]:
|
||||
volume=self.volume('aistats',1995,changed);volume['key']=key
|
||||
row=self.row('aistats',book,1995);row['fields']['crossref']=[key]
|
||||
self.assertEqual(scope(row,volume)[0]['track'],'workshop')
|
||||
volume=self.volume('aistats',2010,'Active Learning and Experimental Design workshop, In conjunction with AISTATS 2010')
|
||||
volume['key']='conf/aistats/2010w'
|
||||
row=self.row('aistats','AISTATS',2011);row['fields']['crossref']=[volume['key']]
|
||||
self.assertEqual(scope(row,volume)[0]['track'],'workshop')
|
||||
|
||||
def test_combined_colt_kernel_volume_keeps_unknown_per_paper_track(self):
|
||||
title='Computational Learning Theory and Kernel Machines, 16th Annual Conference on Computational Learning Theory and 7th Kernel Workshop, COLT/Kernel 2003, Washington, DC, USA, August 24-27, 2003, Proceedings'
|
||||
volume=self.volume('colt',2003,title);row=self.row('colt','COLT',2003)
|
||||
record,reason=normalize_row(row,{volume['key']:volume},{'COLT'})
|
||||
self.assertIsNone(reason);self.assertEqual(record['track'],'proceedings_unspecified')
|
||||
self.assertEqual(record['status'],'accepted')
|
||||
self.assertIn('per-paper section absent',record['metadata']['track_scope_note'])
|
||||
self.assertEqual(record['metadata']['track_scope_evidence_url'],'https://doi.org/10.1007/b12006')
|
||||
volume['key']='conf/colt/2003w';row['fields']['crossref']=[volume['key']]
|
||||
self.assertEqual(scope(row,volume)[0]['track'],'workshop')
|
||||
|
||||
def test_historical_scope_provenance_survives_file_ingestion(self):
|
||||
title=HISTORICAL_WORKSHOP_TITLES[('colt',1988)]+', COLT 1988, Proceedings.'
|
||||
row=self.row('colt','COLT',1988);volume=self.volume('colt',1988,title)
|
||||
record,reason=normalize_row(row,{volume['key']:volume},{'COLT'})
|
||||
self.assertIsNone(reason)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root=Path(tmp);path=root/'history.jsonl'
|
||||
path.write_text(json.dumps(record)+'\n')
|
||||
path.with_suffix('.manifest.json').write_text(json.dumps({'source':'dblp','count':1,'list_url':'https://doi.org/10.4230/dblp.xml.2026-11-01','file_sha256':hashlib.sha256(path.read_bytes()).hexdigest()}))
|
||||
ingest_file(root/'sample.sqlite',path)
|
||||
with closing(sqlite3.connect(root/'sample.sqlite')) as connection:
|
||||
track=connection.execute('SELECT track FROM editions').fetchone()[0]
|
||||
metadata=json.loads(connection.execute('SELECT metadata_json FROM source_records').fetchone()[0])
|
||||
self.assertEqual(track,'main')
|
||||
self.assertEqual(metadata['track_scope_evidence_url'],'https://www.learningtheory.org/')
|
||||
self.assertEqual(metadata['proceedings_title'],title)
|
||||
|
||||
def test_only_own_identifier_links_are_used_and_ambiguity_is_preserved(self):
|
||||
own,ambiguous=identifiers({'ee':['https://doi.org/10.1234/published','https://doi.org/10.48550/arXiv.2401.00001','https://openreview.net/forum?id=realForum'],
|
||||
'cite':['https://doi.org/10.9999/reference']})
|
||||
|
||||
@@ -243,6 +243,50 @@ class PostgreSQLIngestionTests(unittest.TestCase):
|
||||
finally:
|
||||
reader.close()
|
||||
|
||||
def test_coverage_matches_sqlite_with_history_aliases_and_missing_source_abstracts(self):
|
||||
from paper_atlas.audits.coverage import report
|
||||
from tests.test_coverage_report import add_coverage_evidence
|
||||
registry = self.root / 'venues.json'
|
||||
registry.write_text(json.dumps({'scope_note': 'Candidate scope', 'venues': [
|
||||
{'acronym': 'coling', 'field': 'language'}, {'acronym': 'AAAI', 'field': 'general_ai'}]}))
|
||||
sample = Store(self.root / 'seed.sqlite')
|
||||
try:
|
||||
add_coverage_evidence(sample)
|
||||
finally:
|
||||
sample.conn.close()
|
||||
store = self.store()
|
||||
try:
|
||||
add_coverage_evidence(store)
|
||||
finally:
|
||||
store.conn.close()
|
||||
sqlite_report = report(self.root / 'seed.sqlite', registry)
|
||||
observed = []
|
||||
def reader(*args, **kwargs):
|
||||
connection = open_read_connection(*args, **kwargs)
|
||||
observed.append((connection.execute('SHOW transaction_read_only').fetchone()[0],
|
||||
connection.execute('SHOW transaction_isolation').fetchone()[0]))
|
||||
return connection
|
||||
with patch('paper_atlas.audits.coverage.open_read_connection', side_effect=reader):
|
||||
actual = report(None, registry, pg_service=self.service, pg_schema=self.schema)
|
||||
self.assertEqual(observed, [('on', 'repeatable read')])
|
||||
self.assertEqual(actual['engine'], 'postgresql')
|
||||
self.assertEqual(actual['schema'], self.schema)
|
||||
self.assertEqual(actual['pg_service'], self.service)
|
||||
def content(result):
|
||||
values = json.loads(json.dumps(result['venues']))
|
||||
for venue in values:
|
||||
for edition in venue['editions']:
|
||||
for key in ('id', 'venue_id'):
|
||||
edition.pop(key)
|
||||
return values
|
||||
self.assertEqual(content(actual), content(sqlite_report))
|
||||
self.assertEqual([r['acronym'] for r in actual['imported_venues_outside_registry']], ['ICLR'])
|
||||
edition = actual['venues'][0]['editions'][0]
|
||||
self.assertEqual(edition['latest_observed_status_counts']['accepted'], 1)
|
||||
self.assertEqual(edition['latest_observed_status_counts']['unknown'], 1)
|
||||
self.assertEqual(edition['decision_observation_counts']['rejected'], 1)
|
||||
self.assertEqual(edition['papers_without_abstract_text'], 1)
|
||||
|
||||
def test_backfill_merges_evidence_without_reimporting_or_losing_history(self):
|
||||
files = []
|
||||
for source, external, title in [('official', 'legacy', 'First bibliographic name'), ('openreview_snapshot', 'forum', 'Renamed submission')]:
|
||||
|
||||
Reference in New Issue
Block a user