diff --git a/docs/usage.md b/docs/usage.md index bde3024..93c586c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -42,4 +42,13 @@ paper-atlas coverage --db /data/paper-index/papers.sqlite --out /data/paper-inde `--status rejected` 筛选某次投稿的最新已知决定。它并不表示论文在所有会议均未录用;分析转投情况时查看该论文的完整投稿历史。年份表示会议届次,观察时间和决定日期分别保留。 +例如,查找在 ICLR 有拒稿记录、但在其他已知投稿中已有录用的论文: + +```sh +paper-atlas query --db /data/paper-index/papers.sqlite \\ + --venue ICLR --status rejected --outcome accepted +``` + +`known_acceptances` 列出目前仍为录用状态的各次投稿及会议、年份、track 和决定证据,`publication_outcome` 汇总为 accepted 或 no_known_acceptance。汇总会检查所有会议和 track,不把某次拒稿覆盖到整篇论文;同一次投稿的后续撤稿也会更新该次状态并保留原决定。no_known_acceptance 只表示现有来源中没有查到录用,不能称为“从未录用”。 + `--stats` 中 papers 是当前规则下的论文实体数;source_records 是来源记录数;decisions 是决定观察数。一个投稿可能有多个来源和多条观察,不能相加当作论文数。 diff --git a/paper_atlas/querying.py b/paper_atlas/querying.py index 1f7f344..f4af7dd 100644 --- a/paper_atlas/querying.py +++ b/paper_atlas/querying.py @@ -5,6 +5,22 @@ import sqlite3 from pathlib import Path +LATEST_DECISION = '''NOT EXISTS ( + SELECT 1 FROM decisions newer WHERE newer.submission_id=d.submission_id + AND (newer.observed_at,COALESCE(newer.decision_date,newer.observed_at),newer.id) + > (d.observed_at,COALESCE(d.decision_date,d.observed_at),d.id) +)''' +ACCEPTED_PAPERS = "SELECT s.paper_id FROM submissions s JOIN decisions d ON d.submission_id=s.id WHERE d.status='accepted' AND "+LATEST_DECISION + + +def known_acceptances(conn,paper): + return [dict(row) for row in conn.execute('''SELECT s.id AS submission_id,v.acronym AS venue,e.year,e.track, + d.id AS decision_id,d.decision_date,d.observed_at,d.evidence_url + FROM submissions s JOIN editions e ON e.id=s.edition_id JOIN venues v ON v.id=e.venue_id + JOIN decisions d ON d.submission_id=s.id WHERE s.paper_id=? AND d.status='accepted' AND '''+LATEST_DECISION+ + ' ORDER BY e.year,v.acronym,e.track,s.id',(paper,))] + + def main(argv=None, *, prog=None): parser = argparse.ArgumentParser(prog=prog) parser.add_argument('--db', required=True) @@ -15,6 +31,7 @@ def main(argv=None, *, prog=None): parser.add_argument('--year', type=int) parser.add_argument('--track',help='Exact track, e.g. main, findings, workshop') parser.add_argument('--status', choices=['accepted','rejected','desk_rejected','withdrawn','under_review','unknown']) + parser.add_argument('--outcome',choices=['accepted','no_known_acceptance'],help='跨会议的已知录用情况;未查到录用不等于从未录用') parser.add_argument('--limit', type=int, default=20) parser.add_argument('--stats', action='store_true') args = parser.parse_args(argv) @@ -25,6 +42,10 @@ def main(argv=None, *, prog=None): result = {name:conn.execute('SELECT count(*) FROM '+name).fetchone()[0] for name in ('papers','source_records','submissions','decisions','tags','match_candidates')} result['records_by_source'] = [dict(r) for r in conn.execute('SELECT s.name,count(r.id) AS imported_records FROM sources s LEFT JOIN source_records r ON r.source_id=s.id GROUP BY s.id ORDER BY s.name')] result['decision_events_by_status'] = [dict(r) for r in conn.execute('SELECT status,count(*) AS events FROM decisions GROUP BY status')] + accepted=conn.execute('SELECT count(DISTINCT paper_id) FROM ('+ACCEPTED_PAPERS+')').fetchone()[0] + result['papers_with_known_acceptance']=accepted + result['papers_without_known_acceptance']=result['papers']-accepted + result['outcome_scope']='Only imported evidence is known. No known acceptance is not proof that a paper was never accepted.' result['coverage'] = [dict(r) for r in conn.execute('SELECT status,count(*) AS sources FROM edition_sources GROUP BY status')] result['runs'] = [dict(r) for r in conn.execute('SELECT * FROM ingest_runs ORDER BY id DESC LIMIT 10')] else: @@ -36,6 +57,8 @@ def main(argv=None, *, prog=None): if args.tag: namespace,value = args.tag.split('=',1) where.append('EXISTS(SELECT 1 FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.paper_id=p.id AND t.namespace=? AND t.value=?)'); params.extend([namespace,value]) + if args.outcome: + where.append('p.id '+('IN' if args.outcome=='accepted' else 'NOT IN')+' ('+ACCEPTED_PAPERS+')') if args.venue or args.year or args.status or args.track: clause = ['1=1'] if args.venue: @@ -48,14 +71,7 @@ def main(argv=None, *, prog=None): if args.year: clause.append('e.year=?'); params.append(args.year) if args.status: - latest='''EXISTS ( - SELECT 1 FROM decisions d WHERE d.submission_id=s.id AND d.status=? - AND NOT EXISTS ( - SELECT 1 FROM decisions newer WHERE newer.submission_id=d.submission_id - AND (newer.observed_at,COALESCE(newer.decision_date,newer.observed_at),newer.id) - > (d.observed_at,COALESCE(d.decision_date,d.observed_at),d.id) - ) - )''' + latest='EXISTS (SELECT 1 FROM decisions d WHERE d.submission_id=s.id AND d.status=? AND '+LATEST_DECISION+')' clause.append('(NOT EXISTS(SELECT 1 FROM decisions d WHERE d.submission_id=s.id) OR '+latest+')' if args.status=='unknown' else latest); params.append(args.status) where.append('p.id IN (SELECT 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 '+' AND '.join(clause)+')') sql = 'SELECT p.* FROM papers p'+(' WHERE '+' AND '.join(where) if where else '')+' ORDER BY p.id LIMIT ?' @@ -65,6 +81,8 @@ def main(argv=None, *, prog=None): paper = dict(row) paper['authors'] = json.loads(paper.pop('authors_json')) paper['identifiers'] = [dict(r) for r in conn.execute('SELECT scheme,value FROM identifiers WHERE paper_id=?',(row['id'],))] + paper['known_acceptances']=known_acceptances(conn,row['id']) + paper['publication_outcome']='accepted' if paper['known_acceptances'] else 'no_known_acceptance' if args.paper is not None: paper['tags'] = [dict(r) for r in conn.execute('SELECT t.namespace,t.value,pt.evidence_key FROM paper_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.paper_id=?',(row['id'],))] paper['sources'] = [dict(r) for r in conn.execute('SELECT s.name,r.external_id,r.url,r.title,r.abstract,r.retrieved_at FROM source_records r JOIN sources s ON s.id=r.source_id WHERE r.paper_id=?',(row['id'],))] @@ -77,6 +95,7 @@ def main(argv=None, *, prog=None): if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='decision_sources'").fetchone(): decision['sources']=[dict(r) for r in conn.execute('SELECT s.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 s ON s.id=r.source_id WHERE ds.decision_id=?',(d['id'],))] submission['decisions'].append(decision) + submission['latest_status']=submission['decisions'][-1]['status'] if submission['decisions'] else 'unknown' if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='submission_sources'").fetchone(): submission['sources']=[dict(r) for r in conn.execute('SELECT so.name,r.external_id,ss.source_submission_id FROM submission_sources ss JOIN source_records r ON r.id=ss.source_record_id JOIN sources so ON so.id=r.source_id WHERE ss.submission_id=?',(sub['id'],))] submissions.append(submission) diff --git a/tests/test_query.py b/tests/test_query.py index e3497f4..9ea1573 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -9,6 +9,41 @@ from paper_atlas.db.store import Store class QueryTests(unittest.TestCase): + def run_query(self,db,*args): + return json.loads(subprocess.run([sys.executable,'-B','-m','paper_atlas','query','--db',str(db),*args],check=True,capture_output=True,text=True).stdout) + + def test_rejection_then_acceptance_at_another_venue_preserves_both_attempts(self): + with tempfile.TemporaryDirectory() as tmp: + db=Path(tmp)/'papers.sqlite';store=Store(db);self.addCleanup(store.conn.close) + paper,first=store.record('openreview_snapshot','forum1','https://example.org/1','Original title','Abstract',['Jane Doe'],{'arxiv':'2401.00001','openreview':'forum1'}) + store.decision(paper,first,'ICLR',2024,'main','forum1','https://example.org/reject','rejected','Reject',identities=[('openreview','forum1')],observed_at='2024-02-01T00:00:00Z') + same,second=store.record('colm','forum2','https://example.org/2','Revised title','Revised abstract',['Jane Doe'],{'arxiv':'2401.00001','openreview':'forum2'}) + store.decision(same,second,'COLM',2025,'main','forum2','https://example.org/accept','accepted','Accept',identities=[('openreview','forum2')],observed_at='2025-08-01T00:00:00Z') + store.conn.commit();self.assertEqual(same,paper) + rows=self.run_query(db,'--venue','ICLR','--status','rejected','--outcome','accepted') + self.assertEqual([row['id'] for row in rows],[paper]) + self.assertEqual(rows[0]['publication_outcome'],'accepted') + self.assertEqual([a['venue'] for a in rows[0]['known_acceptances']],['COLM']) + detail=self.run_query(db,'--paper',str(paper))[0] + self.assertEqual([(s['acronym'],s['latest_status']) for s in detail['submissions']],[('ICLR','rejected'),('COLM','accepted')]) + self.assertEqual(self.run_query(db,'--venue','ICLR','--status','rejected','--outcome','no_known_acceptance'),[]) + self.assertEqual(self.run_query(db,'--stats')['papers_with_known_acceptance'],1) + + def test_later_rejection_elsewhere_does_not_erase_acceptance_and_unknown_is_not_never(self): + with tempfile.TemporaryDirectory() as tmp: + db=Path(tmp)/'papers.sqlite';store=Store(db);self.addCleanup(store.conn.close) + paper,one=store.record('source','one','https://example.org/1','One paper','Abstract',[]) + store.decision(paper,one,'COLM',2024,'main','one','https://example.org/accept','accepted','Accept',observed_at='2024-01-01T00:00:00Z') + store.decision(paper,one,'ICLR',2025,'main','two','https://example.org/reject','rejected','Reject',observed_at='2025-01-01T00:00:00Z') + unknown,_=store.record('arxiv','other','https://example.org/other','No observed submissions','Abstract',[]) + store.conn.commit() + self.assertEqual([r['id'] for r in self.run_query(db,'--outcome','accepted')],[paper]) + self.assertEqual([r['id'] for r in self.run_query(db,'--outcome','no_known_acceptance')],[unknown]) + store.decision(paper,one,'COLM',2024,'main','one','https://example.org/withdraw','withdrawn','Withdrawn',observed_at='2026-01-01T00:00:00Z') + store.conn.commit();self.assertEqual(self.run_query(db,'--outcome','accepted'),[]) + history=self.run_query(db,'--paper',str(paper))[0] + self.assertEqual([d['status'] for d in history['submissions'][0]['decisions']],['accepted','withdrawn']) + def test_joint_conference_alias_does_not_duplicate_submission(self): with tempfile.TemporaryDirectory() as tmp: db=Path(tmp)/'papers.sqlite';store=Store(db);self.addCleanup(store.conn.close)