diff --git a/codex/cli.py b/codex/cli.py index 0816d6e..33ab2c8 100644 --- a/codex/cli.py +++ b/codex/cli.py @@ -103,7 +103,8 @@ def search_paper( results = [] for row in rows: pr_score = pr.get(row["id"], 0.0) - boosted = row["distance"] * (1.0 + alpha * pr_score) + # 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: @@ -535,20 +536,23 @@ def graph_report( 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.") - raise typer.Exit(0) + return + n_papers = len(known_ids) pr = citation_pagerank(graph) hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n] - dangling = dangling_citations(graph, known_ids) + dangling = sorted(dangling_citations(graph, known_ids)) if output_json: typer.echo( @@ -565,6 +569,12 @@ def graph_report( ) 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)") diff --git a/codex/graph.py b/codex/graph.py index 1d1e01d..78e694c 100644 --- a/codex/graph.py +++ b/codex/graph.py @@ -64,9 +64,13 @@ def citation_pagerank(graph: nx.DiGraph, *, damping: float = 0.85) -> dict[str, ------- ``{paper_id: score}`` dict. Scores sum to approximately 1.0. - When ``graph`` has fewer than :data:`_MIN_PAGERANK_NODES` nodes, - returns a uniform distribution and logs a warning — the signal is - too thin to be meaningful. + 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: diff --git a/tests/graph/test_cli.py b/tests/graph/test_cli.py index b2460c0..32ee60c 100644 --- a/tests/graph/test_cli.py +++ b/tests/graph/test_cli.py @@ -47,7 +47,7 @@ def _sample_graph() -> nx.DiGraph: class TestGraphReport: - def _run(self, graph=None, known_ids=("A", "B", "C"), *extra_args): + 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) @@ -56,7 +56,7 @@ class TestGraphReport: 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"]) + return runner.invoke(app, ["graph", "report", *extra_args]) def test_exit_zero(self): assert self._run().exit_code == 0 @@ -111,6 +111,12 @@ class TestGraphReport: 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 @@ -118,7 +124,7 @@ class TestGraphReport: class TestGraphRelated: - def _run(self, paper_id: str, graph=None, *extra_args): + def _run(self, paper_id: str, graph=None, extra_args=()): if graph is None: graph = _sample_graph() conn = MagicMock() @@ -137,12 +143,12 @@ class TestGraphRelated: assert "No papers" in result.output def test_no_results_message_when_min_shared_high(self): - result = self._run("A", _sample_graph(), "--min-shared", "99") + 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(), "--min-shared", "1") + 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 @@ -154,15 +160,22 @@ class TestGraphRelated: 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() - paper_rows = [ + conn.execute.return_value.fetchall.return_value = [ {"id": "A", "title": "Paper A", "year": 2020, "distance": 0.2}, ] graph = _sample_graph() - conn.execute.return_value.fetchall.return_value = paper_rows - def _fake_encode(texts): return np.zeros((len(texts), 1024), dtype="float32") @@ -174,3 +187,38 @@ class TestSearchCiteBoost: 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)