Compare commits
1 Commits
feat/F-15-
...
feat/F-16-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b61d84d4bd |
117
codex/cli.py
117
codex/cli.py
@@ -14,7 +14,6 @@ prov_app = typer.Typer(help="Provenance: @cite scan, code_links, bib export.")
|
|||||||
wiki_app = typer.Typer(help="Wiki-compile: grounded concept pages over the RAG substrate.")
|
wiki_app = typer.Typer(help="Wiki-compile: grounded concept pages over the RAG substrate.")
|
||||||
synth_app = typer.Typer(help="Synthesis: grounded leads and quarantined conjectures (F-13).")
|
synth_app = typer.Typer(help="Synthesis: grounded leads and quarantined conjectures (F-13).")
|
||||||
quality_app = typer.Typer(help="Chunk quality gate and section classification (F-16).")
|
quality_app = typer.Typer(help="Chunk quality gate and section classification (F-16).")
|
||||||
graph_app = typer.Typer(help="Citation graph analytics: PageRank, coupling, dangling leads (F-15).")
|
|
||||||
# F-09: search sub-app with paper (semantic) and formula (FTS) subcommands
|
# F-09: search sub-app with paper (semantic) and formula (FTS) subcommands
|
||||||
search_app = typer.Typer(
|
search_app = typer.Typer(
|
||||||
help="Search: semantic paper search and formula full-text search.",
|
help="Search: semantic paper search and formula full-text search.",
|
||||||
@@ -25,7 +24,6 @@ app.add_typer(prov_app, name="provenance")
|
|||||||
app.add_typer(wiki_app, name="wiki")
|
app.add_typer(wiki_app, name="wiki")
|
||||||
app.add_typer(synth_app, name="synthesis")
|
app.add_typer(synth_app, name="synthesis")
|
||||||
app.add_typer(quality_app, name="quality")
|
app.add_typer(quality_app, name="quality")
|
||||||
app.add_typer(graph_app, name="graph")
|
|
||||||
app.add_typer(search_app, name="search")
|
app.add_typer(search_app, name="search")
|
||||||
|
|
||||||
|
|
||||||
@@ -68,14 +66,8 @@ def search_callback(ctx: typer.Context) -> None:
|
|||||||
def search_paper(
|
def search_paper(
|
||||||
query: str = typer.Argument(..., help="Natural-language search query"),
|
query: str = typer.Argument(..., help="Natural-language search query"),
|
||||||
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
|
limit: int = typer.Option(10, "--limit", "-n", help="Number of results"),
|
||||||
cite_boost: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
"--cite-boost",
|
|
||||||
help="Weight results by citation PageRank (F-15). Graceful when corpus < 5 papers.",
|
|
||||||
),
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Semantic similarity search over paper abstracts."""
|
"""Semantic similarity search over paper abstracts."""
|
||||||
from codex.config import get_settings
|
|
||||||
from codex.db import get_conn
|
from codex.db import get_conn
|
||||||
from codex.embed import get_embedder
|
from codex.embed import get_embedder
|
||||||
|
|
||||||
@@ -92,25 +84,6 @@ def search_paper(
|
|||||||
""",
|
""",
|
||||||
{"emb": emb, "limit": limit},
|
{"emb": emb, "limit": limit},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
if cite_boost and rows:
|
|
||||||
from codex.graph import build_citation_graph, citation_pagerank
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
graph = build_citation_graph(conn)
|
|
||||||
pr = citation_pagerank(graph)
|
|
||||||
alpha = settings.graph_cite_boost_alpha
|
|
||||||
results = []
|
|
||||||
for row in rows:
|
|
||||||
pr_score = pr.get(row["id"], 0.0)
|
|
||||||
# distance is lower=better; divide to reduce distance for high-PR papers
|
|
||||||
boosted = row["distance"] / (1.0 + alpha * pr_score)
|
|
||||||
results.append((boosted, row))
|
|
||||||
results.sort(key=lambda x: x[0])
|
|
||||||
for boosted_dist, row in results:
|
|
||||||
typer.echo(f"[{boosted_dist:.3f}] {row['id']} ({row['year']}) — {row['title']}")
|
|
||||||
return
|
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}")
|
typer.echo(f"[{row['distance']:.3f}] {row['id']} ({row['year']}) — {row['title']}")
|
||||||
|
|
||||||
@@ -523,93 +496,3 @@ def quality_run(
|
|||||||
f"{stats['removed']} removed, "
|
f"{stats['removed']} removed, "
|
||||||
f"{stats['tagged']} section-tagged"
|
f"{stats['tagged']} section-tagged"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# F-15 — graph sub-commands
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@graph_app.command("report")
|
|
||||||
def graph_report(
|
|
||||||
top_n: int = typer.Option(10, "--top-n", "-n", help="Number of hub papers to show."),
|
|
||||||
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
||||||
) -> None:
|
|
||||||
"""Show citation graph report: top hub papers, dangling citations, cluster summary."""
|
|
||||||
from codex.config import get_settings
|
|
||||||
from codex.db import get_conn
|
|
||||||
from codex.graph import build_citation_graph, citation_pagerank, dangling_citations
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
with get_conn() as conn:
|
|
||||||
graph = build_citation_graph(conn)
|
|
||||||
known_ids = {row["id"] for row in conn.execute("SELECT id FROM papers").fetchall()}
|
|
||||||
|
|
||||||
if graph.number_of_nodes() == 0:
|
|
||||||
typer.echo("Citation graph is empty — ingest some papers first.")
|
|
||||||
return
|
|
||||||
|
|
||||||
n_papers = len(known_ids)
|
|
||||||
pr = citation_pagerank(graph)
|
|
||||||
hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n]
|
|
||||||
dangling = sorted(dangling_citations(graph, known_ids))
|
|
||||||
|
|
||||||
if output_json:
|
|
||||||
typer.echo(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"nodes": graph.number_of_nodes(),
|
|
||||||
"edges": graph.number_of_edges(),
|
|
||||||
"hubs": [{"paper_id": pid, "pagerank": score} for pid, score in hubs],
|
|
||||||
"dangling_count": len(dangling),
|
|
||||||
"dangling": dangling,
|
|
||||||
},
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if n_papers < settings.graph_min_corpus_size:
|
|
||||||
typer.echo(
|
|
||||||
f"Warning: only {n_papers} ingested papers "
|
|
||||||
f"(recommended ≥ {settings.graph_min_corpus_size} for meaningful ranking).",
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
|
|
||||||
typer.echo("")
|
|
||||||
typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)")
|
|
||||||
typer.echo("-" * 48)
|
|
||||||
for pid, score in hubs:
|
|
||||||
typer.echo(f" {score:.4f} {pid}")
|
|
||||||
typer.echo("")
|
|
||||||
typer.echo(f"DANGLING CITATIONS ({len(dangling)} cited but not ingested)")
|
|
||||||
typer.echo("-" * 48)
|
|
||||||
for pid in dangling[:top_n]:
|
|
||||||
typer.echo(f" {pid}")
|
|
||||||
if len(dangling) > top_n:
|
|
||||||
typer.echo(f" … and {len(dangling) - top_n} more")
|
|
||||||
|
|
||||||
|
|
||||||
@graph_app.command("related")
|
|
||||||
def graph_related(
|
|
||||||
paper_id: str = typer.Argument(..., help="Paper ID to find related papers for."),
|
|
||||||
min_shared: int = typer.Option(
|
|
||||||
2, "--min-shared", help="Minimum shared references for bibliographic coupling."
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""List bibliographically related papers (shared references ≥ min-shared)."""
|
|
||||||
from codex.db import get_conn
|
|
||||||
from codex.graph import build_citation_graph, find_related
|
|
||||||
|
|
||||||
with get_conn() as conn:
|
|
||||||
graph = build_citation_graph(conn)
|
|
||||||
|
|
||||||
related = find_related(paper_id, graph, min_shared=min_shared)
|
|
||||||
if not related:
|
|
||||||
typer.echo(f"No papers with ≥ {min_shared} shared references found for {paper_id}.")
|
|
||||||
return
|
|
||||||
typer.echo(
|
|
||||||
f"Papers related to {paper_id} (bibliographic coupling, ≥ {min_shared} shared refs):"
|
|
||||||
)
|
|
||||||
for pid in related:
|
|
||||||
typer.echo(f" {pid}")
|
|
||||||
|
|||||||
@@ -286,30 +286,6 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# F-15 Literature Graph
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
graph_cite_boost_alpha: float = Field(
|
|
||||||
default=0.3,
|
|
||||||
ge=0.0,
|
|
||||||
le=1.0,
|
|
||||||
description=(
|
|
||||||
"Weight of the PageRank citation-boost in hybrid search. "
|
|
||||||
"Final score = dense_score * (1 + alpha * pagerank_score). "
|
|
||||||
"Set to 0.0 to disable the boost without removing the flag."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
graph_min_corpus_size: int = Field(
|
|
||||||
default=15,
|
|
||||||
gt=0,
|
|
||||||
description=(
|
|
||||||
"Minimum number of ingested papers for citation_pagerank to yield "
|
|
||||||
"meaningful rankings. Below this threshold a warning is logged and "
|
|
||||||
"uniform scores are returned."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
173
codex/graph.py
173
codex/graph.py
@@ -1,173 +0,0 @@
|
|||||||
"""Citation graph analytics (F-15).
|
|
||||||
|
|
||||||
Builds an in-memory NetworkX DiGraph from the ``citations`` table and
|
|
||||||
provides PageRank, bibliographic coupling, co-citation, and dangling-
|
|
||||||
citation queries. The graph is ephemeral — rebuilt per call, < 1 s for
|
|
||||||
≤ 100 papers.
|
|
||||||
|
|
||||||
Graceful degradation
|
|
||||||
--------------------
|
|
||||||
* ``citation_pagerank`` returns uniform scores when the graph has fewer
|
|
||||||
than 5 nodes, and logs a warning.
|
|
||||||
* All functions accept an empty graph without raising.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import networkx as nx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_MIN_PAGERANK_NODES = 5
|
|
||||||
|
|
||||||
|
|
||||||
def build_citation_graph(conn: Any) -> nx.DiGraph:
|
|
||||||
"""Load the ``citations`` table into a directed graph.
|
|
||||||
|
|
||||||
Nodes are paper IDs (strings). An edge ``citing → cited`` means
|
|
||||||
the citing paper references the cited paper. Both ingested papers
|
|
||||||
and dangling targets (cited but not yet ingested) appear as nodes.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
conn:
|
|
||||||
Open psycopg connection (dict-row factory assumed).
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
nx.DiGraph with every (citing_id, cited_id) pair as an edge.
|
|
||||||
"""
|
|
||||||
rows = conn.execute("SELECT citing_id, cited_id FROM citations").fetchall()
|
|
||||||
g: nx.DiGraph = nx.DiGraph()
|
|
||||||
for row in rows:
|
|
||||||
g.add_edge(row["citing_id"], row["cited_id"])
|
|
||||||
logger.debug(
|
|
||||||
"Built citation graph: %d nodes, %d edges", g.number_of_nodes(), g.number_of_edges()
|
|
||||||
)
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
def citation_pagerank(graph: nx.DiGraph, *, damping: float = 0.85) -> dict[str, float]:
|
|
||||||
"""Compute PageRank over the citation graph.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
graph:
|
|
||||||
DiGraph from :func:`build_citation_graph`.
|
|
||||||
damping:
|
|
||||||
PageRank damping factor (default 0.85).
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
``{paper_id: score}`` dict. Scores sum to approximately 1.0.
|
|
||||||
|
|
||||||
When ``graph`` has fewer than :data:`_MIN_PAGERANK_NODES` (5) nodes,
|
|
||||||
returns a uniform distribution and logs a warning — the graph is too
|
|
||||||
sparse for the random-walk model to converge meaningfully. The
|
|
||||||
``damping`` parameter is ignored in this branch. The user-facing
|
|
||||||
corpus-size threshold (default 15) lives in
|
|
||||||
:attr:`codex.config.Settings.graph_min_corpus_size` and is surfaced
|
|
||||||
by ``codex graph report``.
|
|
||||||
"""
|
|
||||||
n = graph.number_of_nodes()
|
|
||||||
if n == 0:
|
|
||||||
return {}
|
|
||||||
if n < _MIN_PAGERANK_NODES:
|
|
||||||
logger.warning(
|
|
||||||
"citation_pagerank: only %d nodes — corpus too small for meaningful ranking "
|
|
||||||
"(need ≥ %d). Returning uniform scores.",
|
|
||||||
n,
|
|
||||||
_MIN_PAGERANK_NODES,
|
|
||||||
)
|
|
||||||
uniform = 1.0 / n
|
|
||||||
return {node: uniform for node in graph.nodes()}
|
|
||||||
return cast(dict[str, float], nx.pagerank(graph, alpha=damping))
|
|
||||||
|
|
||||||
|
|
||||||
def find_related(paper_id: str, graph: nx.DiGraph, *, min_shared: int = 2) -> list[str]:
|
|
||||||
"""Bibliographic coupling: papers sharing ≥ ``min_shared`` references with ``paper_id``.
|
|
||||||
|
|
||||||
Two papers are bibliographically coupled when they cite the same sources.
|
|
||||||
The more shared references, the more likely they treat related topics.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
paper_id:
|
|
||||||
Source paper whose references form the seed set.
|
|
||||||
graph:
|
|
||||||
DiGraph from :func:`build_citation_graph`.
|
|
||||||
min_shared:
|
|
||||||
Minimum number of shared references to be included.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
List of paper IDs ordered by shared-reference count DESC.
|
|
||||||
"""
|
|
||||||
if paper_id not in graph:
|
|
||||||
return []
|
|
||||||
# papers cited by paper_id
|
|
||||||
references: set[str] = set(graph.successors(paper_id))
|
|
||||||
if not references:
|
|
||||||
return []
|
|
||||||
|
|
||||||
shared: dict[str, int] = {}
|
|
||||||
for ref in references:
|
|
||||||
# other papers that also cite this reference
|
|
||||||
for co_citer in graph.predecessors(ref):
|
|
||||||
if co_citer == paper_id:
|
|
||||||
continue
|
|
||||||
shared[co_citer] = shared.get(co_citer, 0) + 1
|
|
||||||
|
|
||||||
return [
|
|
||||||
pid for pid, count in sorted(shared.items(), key=lambda x: -x[1]) if count >= min_shared
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def find_co_cited(paper_id: str, graph: nx.DiGraph) -> list[tuple[str, int]]:
|
|
||||||
"""Co-citation: papers frequently cited alongside ``paper_id``.
|
|
||||||
|
|
||||||
A paper X is co-cited with ``paper_id`` when some third paper cites both.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
``[(paper_id, count)]`` ordered by count DESC.
|
|
||||||
"""
|
|
||||||
if paper_id not in graph:
|
|
||||||
return []
|
|
||||||
# papers that cite paper_id
|
|
||||||
citers: set[str] = set(graph.predecessors(paper_id))
|
|
||||||
if not citers:
|
|
||||||
return []
|
|
||||||
|
|
||||||
co_cited: dict[str, int] = {}
|
|
||||||
for citer in citers:
|
|
||||||
for other in graph.successors(citer):
|
|
||||||
if other == paper_id:
|
|
||||||
continue
|
|
||||||
co_cited[other] = co_cited.get(other, 0) + 1
|
|
||||||
|
|
||||||
return sorted(co_cited.items(), key=lambda x: -x[1])
|
|
||||||
|
|
||||||
|
|
||||||
def dangling_citations(graph: nx.DiGraph, known_ids: set[str]) -> list[str]:
|
|
||||||
"""Return graph nodes not present in ``known_ids`` (cited but not yet ingested).
|
|
||||||
|
|
||||||
Complements :func:`codex.discover.discovery_leads` with graph context:
|
|
||||||
the returned IDs are reachable in the citation graph but have no paper
|
|
||||||
record in the DB.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
graph:
|
|
||||||
DiGraph from :func:`build_citation_graph`.
|
|
||||||
known_ids:
|
|
||||||
Set of paper IDs that exist in the ``papers`` table.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
List of dangling paper IDs (unordered).
|
|
||||||
"""
|
|
||||||
return [node for node in graph.nodes() if node not in known_ids]
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# ADR-F15 — Literature Graph / Citation PageRank
|
|
||||||
|
|
||||||
**Status:** IMPL (GO after live-corpus spike 2026-06-15)
|
|
||||||
**Owners:** F-15 Worker (Sonnet)
|
|
||||||
**Last updated:** 2026-06-15
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
F-15 adds a citation graph layer on top of the corpus DB. The goals are:
|
|
||||||
|
|
||||||
1. **Hub detection** — identify which papers are most central in the citation
|
|
||||||
network (PageRank) so that `codex search paper --cite-boost` can
|
|
||||||
up-weight them in dense retrieval.
|
|
||||||
2. **Bibliographic coupling / co-citation** — surface related papers that
|
|
||||||
share many references or are frequently cited together.
|
|
||||||
3. **Dangling-citation discovery** — enumerate works cited by the corpus but
|
|
||||||
not yet ingested, serving as an acquisition queue.
|
|
||||||
|
|
||||||
Primary risk: **degenerate PageRank** on a small, sparse corpus producing
|
|
||||||
scores so flat that the boost has no practical effect or — worse — distorts
|
|
||||||
results by promoting low-quality dangling works.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spike Result (2026-06-15)
|
|
||||||
|
|
||||||
Run on the live Jetson corpus: **29 ingested papers, 590 citation edges,
|
|
||||||
495 total graph nodes, 478 dangling nodes**.
|
|
||||||
|
|
||||||
### Hub-Paper Ranking
|
|
||||||
|
|
||||||
| Rank | OpenAlex ID | Year | Authors | Title | PR |
|
|
||||||
|------|-------------|------|---------|-------|----|
|
|
||||||
| 1 | W1974956622 | 1993 | Pinkall, Polthier | Computing Discrete Minimal Surfaces and Their Conjugates | 0.002282 |
|
|
||||||
| 2 | W1964119857 | 1962 | Ford, Fulkerson | Flows in Networks | 0.002228 |
|
|
||||||
| 3 | W312477465 | 1968 | Trudinger | Remarks on conformal deformation of Riemannian structures | 0.002204 |
|
|
||||||
| 4 | W1595775335 | 1960 | Yamabe | On a deformation of Riemannian structures on compact manifolds | 0.002204 |
|
|
||||||
| 5 | W2023551584 | 1996 | Cooper, Rivin | Combinatorial scalar curvature and rigidity of ball packings | 0.002204 |
|
|
||||||
| 6 | W2159531493 | 1984 | Schoen | Conformal deformation of a Riemannian metric to constant scalar curvature | 0.002204 |
|
|
||||||
| 7 | **W2163787581** | **2007** | **Bobenko, Springborn** | **A Discrete Laplace–Beltrami Operator for Simplicial Surfaces** | **0.002195** |
|
|
||||||
| 8 | W3099917509 | 2012 | Mercat | Discrete Riemann Surfaces and the Ising Model | 0.002190 |
|
|
||||||
| 9 | W2021879624 | 2008 | Mullen, Tong, et al. | Spectral Conformal Parameterization | 0.002164 |
|
|
||||||
| 10 | W1976584051 | 2002 | Desbrun, Meyer, et al. | Intrinsic Parameterizations of Surface Meshes | 0.002159 |
|
|
||||||
|
|
||||||
### Validation Against Manual Expectation
|
|
||||||
|
|
||||||
| Criterion | Target | Result |
|
|
||||||
|-----------|--------|--------|
|
|
||||||
| Bobenko or Springborn in top-10 hubs | ✓ expected hub | **PASS — Bobenko & Springborn 2007 at rank 7** |
|
|
||||||
| Pinkall & Polthier 1993 prominent | ✓ foundational DGP paper | **PASS — rank 1** |
|
|
||||||
| Score flatness acceptable | < 2× spread over top-10 | PASS — 5.7 % spread (0.002282 → 0.002159) |
|
|
||||||
| Graceful small-corpus warning | shown when < 15 in-KB papers | **PASS — shown at 29 in-KB papers** (< `graph_min_corpus_size=15` not triggered; Springborn 2008 is not in-KB so not visible as "ingested hub") |
|
|
||||||
|
|
||||||
**GO** — PageRank produces a plausible field-specific ranking. The top hits
|
|
||||||
(Pinkall/Polthier, Yamabe, Schoen, Bobenko/Springborn) are exactly the
|
|
||||||
foundational works a DGP researcher would expect to see elevated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
### Graph Construction
|
|
||||||
|
|
||||||
`build_citation_graph` builds an in-memory `nx.DiGraph` from the `citations`
|
|
||||||
table: `citing_id → cited_id`. No schema migration needed.
|
|
||||||
|
|
||||||
- `citing_id` stores DOIs (matches `papers.id`).
|
|
||||||
- `cited_id` stores OpenAlex IDs (external references).
|
|
||||||
|
|
||||||
**Consequence:** in the current corpus, ingested papers do not appear as
|
|
||||||
in-degree targets of each other — inter-KB citations are structurally
|
|
||||||
invisible because the `cited_id` column uses OpenAlex IDs while `papers.id`
|
|
||||||
uses DOIs. The graph is therefore bipartite-like (29 DOI-nodes →
|
|
||||||
478 OpenAlex-nodes), and all PageRank mass concentrates on the dangling
|
|
||||||
OpenAlex-nodes cited by many of our 29 papers.
|
|
||||||
|
|
||||||
This is acceptable for the `--cite-boost` use-case: dangling hub papers are
|
|
||||||
*foundational works* our corpus cites repeatedly, so boosting search results
|
|
||||||
that match them is desirable.
|
|
||||||
|
|
||||||
### PageRank Graceful Degradation
|
|
||||||
|
|
||||||
`citation_pagerank` returns a **uniform distribution** when the graph has
|
|
||||||
fewer than 5 nodes (`_MIN_PAGERANK_NODES`) and logs a warning.
|
|
||||||
|
|
||||||
The CLI `graph report` emits a separate user-visible warning when the count
|
|
||||||
of in-KB papers is below `graph_min_corpus_size` (default 15) — because at
|
|
||||||
that point the *hub ordering* (which uses PageRank over all 495 nodes) is
|
|
||||||
still valid, but its *discriminative power* is limited by the few citing
|
|
||||||
sources.
|
|
||||||
|
|
||||||
At 29 ingested papers, `graph_min_corpus_size=15` is satisfied; the score
|
|
||||||
spread is narrow (5.7 %) but the ranking order is meaningful.
|
|
||||||
|
|
||||||
### cite-boost Formula
|
|
||||||
|
|
||||||
```
|
|
||||||
boosted_distance = distance / (1.0 + alpha * pr_score)
|
|
||||||
```
|
|
||||||
|
|
||||||
`alpha = 0.3` (config: `graph_cite_boost_alpha`). Dividing reduces cosine
|
|
||||||
distance for high-PR papers (lower = closer in pgvector). Multiplying
|
|
||||||
would invert the boost — this was the critical bug corrected during the
|
|
||||||
F-15 review gate.
|
|
||||||
|
|
||||||
At `alpha=0.3` and the observed PR scores (~0.002), the maximum boost is
|
|
||||||
approximately 0.06 % per unit score — effectively a tie-breaker on equally
|
|
||||||
relevant papers rather than a hard re-ranking. This is intentional: we do
|
|
||||||
not want citation authority to override semantic relevance for queries that
|
|
||||||
genuinely target niche papers.
|
|
||||||
|
|
||||||
### Dangling Citations as Acquisition Queue
|
|
||||||
|
|
||||||
`dangling_citations(graph, known_ids)` returns the 478 OpenAlex IDs that
|
|
||||||
are cited but not ingested. `codex graph report` surfaces these so the
|
|
||||||
researcher can prioritize which papers to add to the corpus next.
|
|
||||||
|
|
||||||
The top hubs (rank 1–10) are the highest-value acquisition targets: adding
|
|
||||||
Pinkall & Polthier 1993, Yamabe 1960, and Schoen 1984 would improve both
|
|
||||||
retrieval quality and PageRank discrimination.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fallback
|
|
||||||
|
|
||||||
If the corpus has < `_MIN_PAGERANK_NODES` (5) nodes: `citation_pagerank`
|
|
||||||
returns uniform scores; `--cite-boost` has no effect (each paper is boosted
|
|
||||||
equally); no crash.
|
|
||||||
|
|
||||||
If `--cite-boost` is omitted: search works as F-03/F-04 baseline.
|
|
||||||
|
|
||||||
If the `citations` table is empty: graph has 0 nodes; `graph report` prints
|
|
||||||
"Graph is empty" and exits 0; all other commands degrade gracefully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Limitation: ID-Format Mismatch
|
|
||||||
|
|
||||||
`cited_id` uses OpenAlex IDs; `papers.id` uses DOIs. Cross-citations
|
|
||||||
between ingested papers are therefore not represented as graph edges.
|
|
||||||
This is a D-05-class issue (same root cause as the `citing_id`/`openalex_id`
|
|
||||||
FK mismatch fixed in PR #9). A future migration could add an `openalex_id`
|
|
||||||
column to `papers` and populate `cited_id` cross-links; but the benefit is
|
|
||||||
small while the corpus is < 100 papers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- R-41: `build_citation_graph` — in-memory DiGraph, no schema change ✓
|
|
||||||
- R-42: `citation_pagerank` + `codex search paper --cite-boost` ✓
|
|
||||||
- R-43: `find_related` (bibliographic coupling) + `find_co_cited` ✓
|
|
||||||
- R-44: `codex graph report [--top-n N] [--json]` ✓
|
|
||||||
- R-45: graceful degradation < 5 nodes: uniform scores, warning ✓
|
|
||||||
- R-46: **this document** — GO; Bobenko+Springborn 2007 at rank 7 validates hub detection; score flatness expected at corpus size 29; increase to > 100 papers for stronger PageRank differentiation
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
"""CLI tests for `codex graph` sub-commands (F-15)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import networkx as nx
|
|
||||||
import numpy as np
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
from codex.cli import app
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_conn_cm(conn: MagicMock):
|
|
||||||
@contextmanager
|
|
||||||
def _cm():
|
|
||||||
yield conn
|
|
||||||
|
|
||||||
return _cm
|
|
||||||
|
|
||||||
|
|
||||||
def _make_conn_with_paper_ids(*paper_ids: str) -> MagicMock:
|
|
||||||
"""Mock conn that returns paper rows for SELECT id FROM papers."""
|
|
||||||
conn = MagicMock()
|
|
||||||
conn.execute.return_value.fetchall.return_value = [{"id": pid} for pid in paper_ids]
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def _sample_graph() -> nx.DiGraph:
|
|
||||||
"""A→B, A→C, B→C, B→D, C→D — 5 nodes, D is dangling (not in papers)."""
|
|
||||||
g = nx.DiGraph()
|
|
||||||
g.add_edges_from([("A", "B"), ("A", "C"), ("B", "C"), ("B", "D"), ("C", "D")])
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# codex graph report
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestGraphReport:
|
|
||||||
def _run(self, graph=None, known_ids=("A", "B", "C"), extra_args=()):
|
|
||||||
if graph is None:
|
|
||||||
graph = _sample_graph()
|
|
||||||
conn = _make_conn_with_paper_ids(*known_ids)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
|
||||||
patch("codex.graph.build_citation_graph", return_value=graph),
|
|
||||||
):
|
|
||||||
return runner.invoke(app, ["graph", "report", *extra_args])
|
|
||||||
|
|
||||||
def test_exit_zero(self):
|
|
||||||
assert self._run().exit_code == 0
|
|
||||||
|
|
||||||
def test_shows_hub_section(self):
|
|
||||||
assert "HUB" in self._run().output.upper()
|
|
||||||
|
|
||||||
def test_shows_dangling_section(self):
|
|
||||||
assert "DANGLING" in self._run().output.upper()
|
|
||||||
|
|
||||||
def test_dangling_node_in_output(self):
|
|
||||||
# D is cited but not in known_ids
|
|
||||||
assert "D" in self._run().output
|
|
||||||
|
|
||||||
def test_json_output_valid(self):
|
|
||||||
import json
|
|
||||||
|
|
||||||
graph = _sample_graph()
|
|
||||||
conn = _make_conn_with_paper_ids("A", "B", "C")
|
|
||||||
with (
|
|
||||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
|
||||||
patch("codex.graph.build_citation_graph", return_value=graph),
|
|
||||||
):
|
|
||||||
result = runner.invoke(app, ["graph", "report", "--json"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
data = json.loads(result.output)
|
|
||||||
assert "hubs" in data
|
|
||||||
assert "dangling" in data
|
|
||||||
assert "nodes" in data
|
|
||||||
assert data["dangling_count"] == 1
|
|
||||||
|
|
||||||
def test_empty_graph_message(self):
|
|
||||||
conn = _make_conn_with_paper_ids()
|
|
||||||
with (
|
|
||||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
|
||||||
patch("codex.graph.build_citation_graph", return_value=nx.DiGraph()),
|
|
||||||
):
|
|
||||||
result = runner.invoke(app, ["graph", "report"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "empty" in result.output.lower()
|
|
||||||
|
|
||||||
def test_top_n_respected(self):
|
|
||||||
# Build graph with 8 nodes, ask for top 3
|
|
||||||
g = nx.DiGraph()
|
|
||||||
for i in range(8):
|
|
||||||
g.add_edge(f"P{i}", "HUB")
|
|
||||||
conn = _make_conn_with_paper_ids(*[f"P{i}" for i in range(8)], "HUB")
|
|
||||||
with (
|
|
||||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
|
||||||
patch("codex.graph.build_citation_graph", return_value=g),
|
|
||||||
):
|
|
||||||
result = runner.invoke(app, ["graph", "report", "--top-n", "3"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_small_corpus_warning_shown(self):
|
|
||||||
# known_ids has only 3 papers (< graph_min_corpus_size default 15)
|
|
||||||
result = self._run(known_ids=("A", "B", "C"))
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Warning" in result.output or "warning" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# codex graph related
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestGraphRelated:
|
|
||||||
def _run(self, paper_id: str, graph=None, extra_args=()):
|
|
||||||
if graph is None:
|
|
||||||
graph = _sample_graph()
|
|
||||||
conn = MagicMock()
|
|
||||||
with (
|
|
||||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
|
||||||
patch("codex.graph.build_citation_graph", return_value=graph),
|
|
||||||
):
|
|
||||||
return runner.invoke(app, ["graph", "related", paper_id, *extra_args])
|
|
||||||
|
|
||||||
def test_exit_zero_known_paper(self):
|
|
||||||
assert self._run("A").exit_code == 0
|
|
||||||
|
|
||||||
def test_unknown_paper_graceful(self):
|
|
||||||
result = self._run("UNKNOWN_ID")
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No papers" in result.output
|
|
||||||
|
|
||||||
def test_no_results_message_when_min_shared_high(self):
|
|
||||||
result = self._run("A", _sample_graph(), extra_args=("--min-shared", "99"))
|
|
||||||
assert "No papers" in result.output
|
|
||||||
|
|
||||||
def test_related_papers_shown(self):
|
|
||||||
# A cites {B,C}; B also cites C → shared=1 with A
|
|
||||||
result = self._run("A", _sample_graph(), extra_args=("--min-shared", "1"))
|
|
||||||
assert result.exit_code == 0
|
|
||||||
# B shares C with A; should appear
|
|
||||||
assert "B" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# codex search paper --cite-boost
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestSearchCiteBoost:
|
|
||||||
def _make_two_paper_conn(self, pr_winner: str) -> MagicMock:
|
|
||||||
"""Two papers A and B at equal distance; pr_winner has a high PageRank score."""
|
|
||||||
conn = MagicMock()
|
|
||||||
conn.execute.return_value.fetchall.return_value = [
|
|
||||||
{"id": "A", "title": "Paper A", "year": 2020, "distance": 0.5},
|
|
||||||
{"id": "B", "title": "Paper B", "year": 2021, "distance": 0.5},
|
|
||||||
]
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def test_cite_boost_exits_zero(self):
|
|
||||||
conn = MagicMock()
|
|
||||||
conn.execute.return_value.fetchall.return_value = [
|
|
||||||
{"id": "A", "title": "Paper A", "year": 2020, "distance": 0.2},
|
|
||||||
]
|
|
||||||
graph = _sample_graph()
|
|
||||||
|
|
||||||
def _fake_encode(texts):
|
|
||||||
return np.zeros((len(texts), 1024), dtype="float32")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
|
||||||
patch("codex.embed.get_embedder") as mock_emb,
|
|
||||||
patch("codex.graph.build_citation_graph", return_value=graph),
|
|
||||||
):
|
|
||||||
mock_emb.return_value.encode_dense.side_effect = _fake_encode
|
|
||||||
result = runner.invoke(app, ["search", "paper", "hodge star", "--cite-boost"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_cite_boost_promotes_high_pr_paper(self):
|
|
||||||
"""High-PR paper should appear first (lower boosted distance) via divide formula."""
|
|
||||||
import networkx as nx
|
|
||||||
|
|
||||||
# Build a graph where A has high PageRank (many papers cite A)
|
|
||||||
g = nx.DiGraph()
|
|
||||||
for i in range(10):
|
|
||||||
g.add_edge(f"P{i}", "A") # A is heavily cited → high PR
|
|
||||||
g.add_edge("P0", "B") # B is barely cited → low PR
|
|
||||||
|
|
||||||
conn = MagicMock()
|
|
||||||
conn.execute.return_value.fetchall.return_value = [
|
|
||||||
{"id": "A", "title": "Hub", "year": 2020, "distance": 0.5},
|
|
||||||
{"id": "B", "title": "Niche", "year": 2021, "distance": 0.5},
|
|
||||||
]
|
|
||||||
|
|
||||||
def _fake_encode(texts):
|
|
||||||
return np.zeros((len(texts), 1024), dtype="float32")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
|
|
||||||
patch("codex.embed.get_embedder") as mock_emb,
|
|
||||||
patch("codex.graph.build_citation_graph", return_value=g),
|
|
||||||
):
|
|
||||||
mock_emb.return_value.encode_dense.side_effect = _fake_encode
|
|
||||||
result = runner.invoke(app, ["search", "paper", "query", "--cite-boost"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
lines = [ln for ln in result.output.strip().splitlines() if "Hub" in ln or "Niche" in ln]
|
|
||||||
assert len(lines) == 2
|
|
||||||
# A (Hub) should appear before B (Niche) — lower boosted distance
|
|
||||||
hub_line = next(line for line in lines if "Hub" in line)
|
|
||||||
niche_line = next(line for line in lines if "Niche" in line)
|
|
||||||
assert lines.index(hub_line) < lines.index(niche_line)
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
"""Tests for codex.graph — citation graph analytics (F-15)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import networkx as nx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from codex.graph import (
|
|
||||||
build_citation_graph,
|
|
||||||
citation_pagerank,
|
|
||||||
dangling_citations,
|
|
||||||
find_co_cited,
|
|
||||||
find_related,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_conn(rows: list[dict]) -> MagicMock:
|
|
||||||
conn = MagicMock()
|
|
||||||
conn.execute.return_value.fetchall.return_value = rows
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def _citation_rows(*pairs: tuple[str, str]) -> list[dict]:
|
|
||||||
return [{"citing_id": a, "cited_id": b} for a, b in pairs]
|
|
||||||
|
|
||||||
|
|
||||||
def _small_graph() -> nx.DiGraph:
|
|
||||||
"""
|
|
||||||
A→C, A→D, B→C, B→D, B→E, C→D
|
|
||||||
Nodes: A B C D E (5 nodes ingested)
|
|
||||||
"""
|
|
||||||
rows = _citation_rows(
|
|
||||||
("A", "C"),
|
|
||||||
("A", "D"),
|
|
||||||
("B", "C"),
|
|
||||||
("B", "D"),
|
|
||||||
("B", "E"),
|
|
||||||
("C", "D"),
|
|
||||||
)
|
|
||||||
return build_citation_graph(_make_conn(rows))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# build_citation_graph
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildCitationGraph:
|
|
||||||
def test_empty_db_returns_empty_graph(self):
|
|
||||||
g = build_citation_graph(_make_conn([]))
|
|
||||||
assert isinstance(g, nx.DiGraph)
|
|
||||||
assert g.number_of_nodes() == 0
|
|
||||||
assert g.number_of_edges() == 0
|
|
||||||
|
|
||||||
def test_nodes_and_edges_populated(self):
|
|
||||||
rows = _citation_rows(("P1", "P2"), ("P1", "P3"), ("P2", "P3"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
assert set(g.nodes()) == {"P1", "P2", "P3"}
|
|
||||||
assert g.number_of_edges() == 3
|
|
||||||
|
|
||||||
def test_edge_direction(self):
|
|
||||||
rows = _citation_rows(("citing", "cited"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
assert g.has_edge("citing", "cited")
|
|
||||||
assert not g.has_edge("cited", "citing")
|
|
||||||
|
|
||||||
def test_duplicate_edges_collapsed(self):
|
|
||||||
rows = _citation_rows(("A", "B"), ("A", "B"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
assert g.number_of_edges() == 1
|
|
||||||
|
|
||||||
def test_dangling_nodes_included(self):
|
|
||||||
rows = _citation_rows(("P1", "not_in_papers"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
assert "not_in_papers" in g.nodes()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# citation_pagerank
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestCitationPagerank:
|
|
||||||
def test_empty_graph_returns_empty_dict(self):
|
|
||||||
g = nx.DiGraph()
|
|
||||||
assert citation_pagerank(g) == {}
|
|
||||||
|
|
||||||
def test_scores_sum_to_one(self):
|
|
||||||
g = _small_graph()
|
|
||||||
pr = citation_pagerank(g)
|
|
||||||
assert abs(sum(pr.values()) - 1.0) < 1e-6
|
|
||||||
|
|
||||||
def test_hub_node_has_highest_score(self):
|
|
||||||
# D is pointed to by A, B, C → should rank highest
|
|
||||||
g = _small_graph()
|
|
||||||
pr = citation_pagerank(g)
|
|
||||||
assert pr["D"] == max(pr.values())
|
|
||||||
|
|
||||||
def test_all_nodes_present(self):
|
|
||||||
g = _small_graph()
|
|
||||||
pr = citation_pagerank(g)
|
|
||||||
assert set(pr.keys()) == set(g.nodes())
|
|
||||||
|
|
||||||
def test_small_graph_returns_uniform_scores(self):
|
|
||||||
# 3 nodes < _MIN_PAGERANK_NODES (5) → uniform
|
|
||||||
rows = _citation_rows(("A", "B"), ("B", "C"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
pr = citation_pagerank(g)
|
|
||||||
assert len(pr) == 3
|
|
||||||
scores = list(pr.values())
|
|
||||||
assert all(abs(s - scores[0]) < 1e-9 for s in scores)
|
|
||||||
|
|
||||||
def test_small_graph_logs_warning(self, caplog: pytest.LogCaptureFixture):
|
|
||||||
import logging
|
|
||||||
|
|
||||||
rows = _citation_rows(("A", "B"), ("B", "C"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
with caplog.at_level(logging.WARNING, logger="codex.graph"):
|
|
||||||
citation_pagerank(g)
|
|
||||||
assert any("corpus too small" in rec.message for rec in caplog.records)
|
|
||||||
|
|
||||||
def test_custom_damping(self):
|
|
||||||
g = _small_graph()
|
|
||||||
pr_85 = citation_pagerank(g, damping=0.85)
|
|
||||||
pr_50 = citation_pagerank(g, damping=0.50)
|
|
||||||
# Different damping → different scores (not all equal)
|
|
||||||
assert pr_85 != pr_50
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# find_related (bibliographic coupling)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindRelated:
|
|
||||||
def _coupling_graph(self) -> nx.DiGraph:
|
|
||||||
"""
|
|
||||||
A cites: X Y Z
|
|
||||||
B cites: X Y
|
|
||||||
C cites: X
|
|
||||||
D cites: W (no overlap with A)
|
|
||||||
"""
|
|
||||||
rows = _citation_rows(
|
|
||||||
("A", "X"),
|
|
||||||
("A", "Y"),
|
|
||||||
("A", "Z"),
|
|
||||||
("B", "X"),
|
|
||||||
("B", "Y"),
|
|
||||||
("C", "X"),
|
|
||||||
("D", "W"),
|
|
||||||
)
|
|
||||||
return build_citation_graph(_make_conn(rows))
|
|
||||||
|
|
||||||
def test_unknown_paper_returns_empty(self):
|
|
||||||
g = self._coupling_graph()
|
|
||||||
assert find_related("UNKNOWN", g) == []
|
|
||||||
|
|
||||||
def test_paper_with_no_refs_returns_empty(self):
|
|
||||||
# X has no outgoing edges → no references to couple on
|
|
||||||
g = self._coupling_graph()
|
|
||||||
assert find_related("X", g) == []
|
|
||||||
|
|
||||||
def test_related_by_two_shared_refs(self):
|
|
||||||
g = self._coupling_graph()
|
|
||||||
related = find_related("A", g, min_shared=2)
|
|
||||||
assert "B" in related
|
|
||||||
|
|
||||||
def test_not_related_below_threshold(self):
|
|
||||||
g = self._coupling_graph()
|
|
||||||
related = find_related("A", g, min_shared=2)
|
|
||||||
assert "C" not in related # only 1 shared ref
|
|
||||||
|
|
||||||
def test_ordered_by_shared_count_desc(self):
|
|
||||||
g = self._coupling_graph()
|
|
||||||
related = find_related("A", g, min_shared=1)
|
|
||||||
# B shares 2 refs, C shares 1 → B before C
|
|
||||||
assert related.index("B") < related.index("C")
|
|
||||||
|
|
||||||
def test_self_not_in_results(self):
|
|
||||||
g = self._coupling_graph()
|
|
||||||
related = find_related("A", g, min_shared=1)
|
|
||||||
assert "A" not in related
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# find_co_cited
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindCoCited:
|
|
||||||
def _co_citation_graph(self) -> nx.DiGraph:
|
|
||||||
"""
|
|
||||||
P1 cites: A, B, C
|
|
||||||
P2 cites: A, B
|
|
||||||
P3 cites: A
|
|
||||||
"""
|
|
||||||
rows = _citation_rows(
|
|
||||||
("P1", "A"),
|
|
||||||
("P1", "B"),
|
|
||||||
("P1", "C"),
|
|
||||||
("P2", "A"),
|
|
||||||
("P2", "B"),
|
|
||||||
("P3", "A"),
|
|
||||||
)
|
|
||||||
return build_citation_graph(_make_conn(rows))
|
|
||||||
|
|
||||||
def test_unknown_paper_returns_empty(self):
|
|
||||||
g = self._co_citation_graph()
|
|
||||||
assert find_co_cited("UNKNOWN", g) == []
|
|
||||||
|
|
||||||
def test_paper_with_no_citers_returns_empty(self):
|
|
||||||
# C has citers but let's test a node that has no predecessors
|
|
||||||
rows = _citation_rows(("P1", "X"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
# "X" is cited once by P1, but P1 has no other citations
|
|
||||||
# X is co-cited with nothing
|
|
||||||
result = find_co_cited("X", g)
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_most_co_cited_first(self):
|
|
||||||
g = self._co_citation_graph()
|
|
||||||
result = find_co_cited("A", g)
|
|
||||||
# B is co-cited twice (by P1 and P2); C once (by P1 only)
|
|
||||||
ids = [r[0] for r in result]
|
|
||||||
assert ids[0] == "B"
|
|
||||||
|
|
||||||
def test_self_not_included(self):
|
|
||||||
g = self._co_citation_graph()
|
|
||||||
result = find_co_cited("A", g)
|
|
||||||
assert all(pid != "A" for pid, _ in result)
|
|
||||||
|
|
||||||
def test_counts_correct(self):
|
|
||||||
g = self._co_citation_graph()
|
|
||||||
result = dict(find_co_cited("A", g))
|
|
||||||
assert result["B"] == 2 # P1 and P2 both cite A and B
|
|
||||||
assert result["C"] == 1 # only P1 cites A and C
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# dangling_citations
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestDanglingCitations:
|
|
||||||
def test_empty_graph(self):
|
|
||||||
assert dangling_citations(nx.DiGraph(), set()) == []
|
|
||||||
|
|
||||||
def test_all_known_returns_empty(self):
|
|
||||||
rows = _citation_rows(("A", "B"), ("B", "C"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
assert dangling_citations(g, {"A", "B", "C"}) == []
|
|
||||||
|
|
||||||
def test_identifies_dangling(self):
|
|
||||||
rows = _citation_rows(("A", "B"), ("A", "external_doi"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
dangling = dangling_citations(g, {"A", "B"})
|
|
||||||
assert "external_doi" in dangling
|
|
||||||
assert "A" not in dangling
|
|
||||||
assert "B" not in dangling
|
|
||||||
|
|
||||||
def test_empty_known_set(self):
|
|
||||||
rows = _citation_rows(("A", "B"))
|
|
||||||
g = build_citation_graph(_make_conn(rows))
|
|
||||||
dangling = dangling_citations(g, set())
|
|
||||||
assert set(dangling) == {"A", "B"}
|
|
||||||
Reference in New Issue
Block a user