diff --git a/codex/cli.py b/codex/cli.py index 28130b7..9128de1 100644 --- a/codex/cli.py +++ b/codex/cli.py @@ -538,7 +538,12 @@ def graph_report( """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 + from codex.graph import ( + build_citation_graph, + citation_pagerank, + citing_coverage, + dangling_citations, + ) settings = get_settings() with get_conn() as conn: @@ -553,6 +558,8 @@ def graph_report( pr = citation_pagerank(graph) hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n] dangling = sorted(dangling_citations(graph, known_ids)) + n_citing, _ = citing_coverage(graph, known_ids) + coverage = n_citing / n_papers if n_papers else 0.0 if output_json: typer.echo( @@ -560,6 +567,11 @@ def graph_report( { "nodes": graph.number_of_nodes(), "edges": graph.number_of_edges(), + "citing_coverage": { + "citing": n_citing, + "papers": n_papers, + "fraction": round(coverage, 4), + }, "hubs": [{"paper_id": pid, "pagerank": score} for pid, score in hubs], "dangling_count": len(dangling), "dangling": dangling, @@ -575,7 +587,17 @@ def graph_report( f"(recommended ≥ {settings.graph_min_corpus_size} for meaningful ranking).", err=True, ) + # R-D: low citing-paper coverage starves PageRank/coupling even at a healthy + # paper count — warn on the share of papers that actually have out-edges. + if coverage < settings.graph_min_citing_coverage: + typer.echo( + f"Warning: only {n_citing}/{n_papers} papers ({coverage:.0%}) have out-edges " + f"(recommended ≥ {settings.graph_min_citing_coverage:.0%}); " + f"low citing coverage weakens PageRank/coupling.", + err=True, + ) typer.echo(f"Graph: {graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges") + typer.echo(f"Citing coverage: {n_citing}/{n_papers} papers with out-edges ({coverage:.0%})") typer.echo("") typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)") typer.echo("-" * 48) diff --git a/codex/config.py b/codex/config.py index dfe27f2..fbe59e4 100644 --- a/codex/config.py +++ b/codex/config.py @@ -310,6 +310,18 @@ class Settings(BaseSettings): ), ) + graph_min_citing_coverage: float = Field( + default=0.8, + ge=0.0, + le=1.0, + description=( + "Minimum share of ingested papers that must have ≥1 out-edge (i.e. " + "cite something) before `codex graph report` warns. Low citing " + "coverage starves PageRank/coupling even when the paper count looks " + "healthy — the share of *citing* papers is what matters (R-D)." + ), + ) + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/codex/graph.py b/codex/graph.py index 981662d..5f4a5a3 100644 --- a/codex/graph.py +++ b/codex/graph.py @@ -100,6 +100,20 @@ def citation_pagerank(graph: nx.DiGraph, *, damping: float = 0.85) -> dict[str, return cast(dict[str, float], nx.pagerank(graph, alpha=damping)) +def citing_coverage(graph: nx.DiGraph, known_ids: set[str]) -> tuple[int, int]: + """Return ``(papers_with_out_edges, total_papers)`` — the citing-paper coverage. + + A paper "covers" the citation graph when it has ≥1 out-edge (it cites at least + one work). Low citing coverage starves the F-15 layer (PageRank / coupling / + co-citation) even when the total paper count looks healthy, because those + metrics are driven by out-edges, not by node count. Surfaced by + ``codex graph report`` (R-D); the warning threshold is + :attr:`codex.config.Settings.graph_min_citing_coverage`. + """ + n_citing = sum(1 for pid in known_ids if pid in graph and graph.out_degree(pid) > 0) + return n_citing, len(known_ids) + + 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``. diff --git a/docs/audit/DATA-QUALITY-2026-06-15.md b/docs/audit/DATA-QUALITY-2026-06-15.md index acfac60..e5f3b5c 100644 --- a/docs/audit/DATA-QUALITY-2026-06-15.md +++ b/docs/audit/DATA-QUALITY-2026-06-15.md @@ -462,7 +462,23 @@ is self-contained so a cold session can pick it up. - **Acceptance:** `section` column gains signal (fewer `body`-only); DQ-3 coverage vs source improves; no regression in the F-16 quality gate. -### R-D — F-15: warn on low *citing-paper* coverage, not just paper count · **SMALL** +### R-D — F-15: warn on low *citing-paper* coverage, not just paper count · **DONE 2026-06-17** + +**Resolution (what was done):** +- Added `graph.citing_coverage(graph, known_ids)` → `(papers_with_out_edges, + total)`, a `graph_min_citing_coverage` setting (default 0.8), and wired both into + `codex graph report`: it now prints `Citing coverage: N/M papers with out-edges + (P%)` (and in `--json`), and emits a `Warning` when the share is below the + threshold ("low citing coverage weakens PageRank/coupling"). Separate from the + existing `graph_min_corpus_size` (total-count) warning. +- Tests: `citing_coverage` unit tests + CLI tests (line shown; warning fires at + low coverage; suppressed at 100%; JSON field). Full suite green; ruff/mypy clean. +- **Live:** `codex graph report` on the corpus prints `Citing coverage: 29/29 + papers with out-edges (100%)` with no warning (post-R-A). Branch + `feat/graph-coverage-warning`. Files: `codex/config.py`, `codex/graph.py`, + `codex/cli.py`, + tests. + +**Original plan (for context):** - **What:** the open DQ-1 sub-item: `graph_min_corpus_size` only flags total paper count. Add a warning when the share of papers with ≥1 out-edge is low (e.g. < 80%), since that is what actually starves PageRank/coupling. diff --git a/tests/graph/test_cli.py b/tests/graph/test_cli.py index 32ee60c..2dfb449 100644 --- a/tests/graph/test_cli.py +++ b/tests/graph/test_cli.py @@ -117,6 +117,36 @@ class TestGraphReport: assert result.exit_code == 0 assert "Warning" in result.output or "warning" in result.output + def test_shows_citing_coverage_line(self): + # _sample_graph: A, B, C all cite → 3/3 = 100%, so the line shows but no warning. + out = self._run(known_ids=("A", "B", "C")).output + assert "Citing coverage" in out + assert "weakens" not in out # 100% coverage → no R-D warning + + def test_low_citing_coverage_warning(self): + # Only A has an out-edge; B and C are ingested but cite nothing → 1/3 = 33%. + g = nx.DiGraph() + g.add_edge("A", "X") + g.add_nodes_from(["B", "C"]) + result = self._run(graph=g, known_ids=("A", "B", "C")) + assert result.exit_code == 0 + assert "weakens PageRank" in result.output # the R-D coverage warning + + def test_json_includes_citing_coverage(self): + import json + + g = nx.DiGraph() + g.add_edge("A", "X") + g.add_nodes_from(["B", "C"]) + 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=g), + ): + result = runner.invoke(app, ["graph", "report", "--json"]) + data = json.loads(result.output) + assert data["citing_coverage"] == {"citing": 1, "papers": 3, "fraction": 0.3333} + # --------------------------------------------------------------------------- # codex graph related diff --git a/tests/graph/test_graph.py b/tests/graph/test_graph.py index c68bdfa..49c9abe 100644 --- a/tests/graph/test_graph.py +++ b/tests/graph/test_graph.py @@ -10,6 +10,7 @@ import pytest from codex.graph import ( build_citation_graph, citation_pagerank, + citing_coverage, dangling_citations, find_co_cited, find_related, @@ -287,3 +288,24 @@ class TestDanglingCitations: g = build_citation_graph(_make_conn(rows)) dangling = dangling_citations(g, set()) assert set(dangling) == {"A", "B"} + + +# --------------------------------------------------------------------------- +# citing_coverage (R-D) +# --------------------------------------------------------------------------- + + +class TestCitingCoverage: + def test_counts_papers_with_out_edges(self): + # _small_graph: A→.., B→.., C→D have out-edges; D, E have none. + g = _small_graph() + n_citing, n_papers = citing_coverage(g, {"A", "B", "C", "D", "E"}) + assert (n_citing, n_papers) == (3, 5) + + def test_paper_absent_from_graph_counts_as_no_out_edges(self): + # A cites; Z was ingested but has no chunks/edges, so it isn't a graph node. + g = _small_graph() + assert citing_coverage(g, {"A", "Z"}) == (1, 2) + + def test_empty(self): + assert citing_coverage(nx.DiGraph(), set()) == (0, 0)