"""Discovery queries over the citation graph stored in PostgreSQL. Every query resolves ``citations.cited_id`` to a canonical ``papers.id`` through the shared :data:`codex.graph.RESOLVED_CITATIONS_SQL`, so this module and the citation-graph layer agree on what counts as "ingested". Previously this module compared the raw OpenAlex ``cited_id`` against ``papers.id`` (a DOI/arXiv id), which mis-reported already-ingested papers as discovery leads (audit C-1). """ from __future__ import annotations from codex.db import get_conn from codex.graph import RESOLVED_CITATIONS_SQL def discovery_leads(limit: int = 20) -> list[dict[str, int | str]]: """Return papers referenced by already-ingested papers but not yet collected. Returns list of dicts with keys: cited_id (str) — canonical id of the referenced (not-yet-ingested) work pull (int) — how many already-ingested papers cite this target Ordered by pull DESC, limited to `limit` rows. References that resolve to a paper already in the corpus are excluded — they are not leads. """ sql = f""" WITH resolved AS ({RESOLVED_CITATIONS_SQL}) SELECT cited_id, count(*) AS pull FROM resolved WHERE cited_id NOT IN (SELECT id FROM papers) GROUP BY cited_id ORDER BY pull DESC LIMIT %(limit)s """ with get_conn() as conn: rows = conn.execute(sql, {"limit": limit}).fetchall() return [{"cited_id": row["cited_id"], "pull": row["pull"]} for row in rows] def citing_papers(paper_id: str) -> list[str]: """Return IDs of all papers that cite `paper_id` (in-graph reverse citations).""" sql = f""" WITH resolved AS ({RESOLVED_CITATIONS_SQL}) SELECT citing_id FROM resolved WHERE cited_id = %(paper_id)s """ with get_conn() as conn: rows = conn.execute(sql, {"paper_id": paper_id}).fetchall() return [row["citing_id"] for row in rows] def cited_by(paper_id: str) -> list[str]: """Return IDs of all papers that `paper_id` cites (forward citations).""" sql = f""" WITH resolved AS ({RESOLVED_CITATIONS_SQL}) SELECT cited_id FROM resolved WHERE citing_id = %(paper_id)s """ with get_conn() as conn: rows = conn.execute(sql, {"paper_id": paper_id}).fetchall() return [row["cited_id"] for row in rows] def cocited_papers(paper_id: str, limit: int = 10) -> list[dict[str, int | str]]: """Return papers frequently co-cited with `paper_id`. A paper X is co-cited with `paper_id` if some paper cites both. Returns list of dicts: {paper_id: str, co_citations: int}, ordered DESC. """ sql = f""" WITH resolved AS ({RESOLVED_CITATIONS_SQL}) SELECT c2.cited_id AS paper_id, count(*) AS co_citations FROM resolved c1 JOIN resolved c2 ON c1.citing_id = c2.citing_id WHERE c1.cited_id = %(paper_id)s AND c2.cited_id != %(paper_id)s GROUP BY c2.cited_id ORDER BY co_citations DESC LIMIT %(limit)s """ with get_conn() as conn: rows = conn.execute(sql, {"paper_id": paper_id, "limit": limit}).fetchall() return [{"paper_id": row["paper_id"], "co_citations": row["co_citations"]} for row in rows]