From 346208a2725b824f1aa03c099580c64b60d81cd1 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 02:42:21 +0800 Subject: [PATCH 1/2] Audit current PostgreSQL conference coverage and decision history --- .gitea/workflows/check.yml | 1 + README.md | 3 +- docs/operations.md | 7 +- docs/usage.md | 5 +- paper_atlas/audits/coverage.py | 137 +++++++++++++++++++++++++------ tests/test_coverage_report.py | 56 +++++++++++++ tests/test_postgres_ingestion.py | 44 ++++++++++ 7 files changed, 222 insertions(+), 31 deletions(-) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index c31e712..acf86ab 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -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','-c','from paper_atlas.postgres.writer import PostgresStore, WriteConnection'],cwd=outside,check=True) smoke = "from paper_atlas.db.store import Store; from paper_atlas.config import DEFAULT_REGISTRY; import json; assert {'COLM','MM','MLSys'} <= {v['acronym'] for v in json.loads(DEFAULT_REGISTRY.read_text())['venues']}; s=Store('installed.sqlite'); s.conn.close()" subprocess.run([python,'-I','-B','-c',smoke],cwd=outside,check=True) diff --git a/README.md b/README.md index 09dcf66..e957546 100644 --- a/README.md +++ b/README.md @@ -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 # 采集与导入分开;这里只采集到独立文件 diff --git a/docs/operations.md b/docs/operations.md index e4d8d64..a7cf4d7 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 投稿的轨道 diff --git a/docs/usage.md b/docs/usage.md index 727eea2..7d29507 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -83,11 +83,14 @@ paper-atlas query --db /data/paper-index/papers.sqlite --query '"language model" 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 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 diff --git a/paper_atlas/audits/coverage.py b/paper_atlas/audits/coverage.py index 8550881..277da70 100644 --- a/paper_atlas/audits/coverage.py +++ b/paper_atlas/audits/coverage.py @@ -1,45 +1,128 @@ -"""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) + 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,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 editions e JOIN venues v ON v.id=e.venue_id + LEFT JOIN submissions s ON s.edition_id=e.id LEFT JOIN papers p ON p.id=s.paper_id + WHERE ''' + scope + ''' GROUP BY e.id,v.id,v.acronym,e.year,e.track + ORDER BY e.year,e.track,v.acronym''', params): + edition = dict(row) + 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__': diff --git a/tests/test_coverage_report.py b/tests/test_coverage_report.py index d56364a..bdeacfa 100644 --- a/tests/test_coverage_report.py +++ b/tests/test_coverage_report.py @@ -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,33 @@ 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()) + if __name__=='__main__':unittest.main() diff --git a/tests/test_postgres_ingestion.py b/tests/test_postgres_ingestion.py index c05bb0b..7769b6d 100644 --- a/tests/test_postgres_ingestion.py +++ b/tests/test_postgres_ingestion.py @@ -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')]: -- 2.52.0 From fd3fdb6cf7e70404d7c35051c36442a4e3771d36 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 02:57:09 +0800 Subject: [PATCH 2/2] Aggregate coverage paper counts once per snapshot --- paper_atlas/audits/coverage.py | 20 ++++++++++++++------ tests/test_coverage_report.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/paper_atlas/audits/coverage.py b/paper_atlas/audits/coverage.py index 277da70..42db2b8 100644 --- a/paper_atlas/audits/coverage.py +++ b/paper_atlas/audits/coverage.py @@ -47,6 +47,16 @@ def report(db, registry, *, pg_service=None, pg_schema=None): 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, @@ -63,14 +73,12 @@ def report(db, registry, *, pg_service=None, pg_schema=None): 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,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 editions e JOIN venues v ON v.id=e.venue_id - LEFT JOIN submissions s ON s.edition_id=e.id LEFT JOIN papers p ON p.id=s.paper_id - WHERE ''' + scope + ''' GROUP BY e.id,v.id,v.acronym,e.year,e.track + 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} diff --git a/tests/test_coverage_report.py b/tests/test_coverage_report.py index bdeacfa..3d7a4a5 100644 --- a/tests/test_coverage_report.py +++ b/tests/test_coverage_report.py @@ -87,5 +87,18 @@ class CoverageTests(unittest.TestCase): 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() -- 2.52.0