feat(graph): warn on low citing-paper coverage in graph report (R-D)

Add graph.citing_coverage() (papers with >=1 out-edge / total) and a graph_min_citing_coverage setting (default 0.8). codex graph report now surfaces 'Citing coverage: N/M papers with out-edges (P%)' in text and JSON, and warns when the share is below threshold -- low citing coverage starves PageRank/coupling even when the paper count looks healthy (the DQ-1 sub-item). Separate from the existing total-count graph_min_corpus_size warning.

Tests: citing_coverage unit tests + CLI tests (line shown, warning fires/suppressed, JSON field). Full suite green; ruff + mypy clean. Live: graph report shows 29/29 (100%), no warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tarik Moussa
2026-06-18 07:59:25 +02:00
parent 1516684bbb
commit 5672358bbe
6 changed files with 118 additions and 2 deletions

View File

@@ -538,7 +538,12 @@ def graph_report(
"""Show citation graph report: top hub papers, dangling citations, cluster summary.""" """Show citation graph report: top hub papers, dangling citations, cluster summary."""
from codex.config import get_settings from codex.config import get_settings
from codex.db import get_conn 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() settings = get_settings()
with get_conn() as conn: with get_conn() as conn:
@@ -553,6 +558,8 @@ def graph_report(
pr = citation_pagerank(graph) pr = citation_pagerank(graph)
hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n] hubs = sorted(pr.items(), key=lambda x: -x[1])[:top_n]
dangling = sorted(dangling_citations(graph, known_ids)) 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: if output_json:
typer.echo( typer.echo(
@@ -560,6 +567,11 @@ def graph_report(
{ {
"nodes": graph.number_of_nodes(), "nodes": graph.number_of_nodes(),
"edges": graph.number_of_edges(), "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], "hubs": [{"paper_id": pid, "pagerank": score} for pid, score in hubs],
"dangling_count": len(dangling), "dangling_count": len(dangling),
"dangling": dangling, "dangling": dangling,
@@ -575,7 +587,17 @@ def graph_report(
f"(recommended ≥ {settings.graph_min_corpus_size} for meaningful ranking).", f"(recommended ≥ {settings.graph_min_corpus_size} for meaningful ranking).",
err=True, 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"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("")
typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)") typer.echo(f"TOP-{top_n} HUB PAPERS (PageRank)")
typer.echo("-" * 48) typer.echo("-" * 48)

View File

@@ -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) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -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)) 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]: 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``. """Bibliographic coupling: papers sharing ≥ ``min_shared`` references with ``paper_id``.

View File

@@ -408,7 +408,23 @@ is self-contained so a cold session can pick it up.
- **Acceptance:** `section` column gains signal (fewer `body`-only); DQ-3 coverage - **Acceptance:** `section` column gains signal (fewer `body`-only); DQ-3 coverage
vs source improves; no regression in the F-16 quality gate. 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 - **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. 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. < 80%), since that is what actually starves PageRank/coupling.

View File

@@ -117,6 +117,36 @@ class TestGraphReport:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Warning" in result.output or "warning" in result.output 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 # codex graph related

View File

@@ -10,6 +10,7 @@ import pytest
from codex.graph import ( from codex.graph import (
build_citation_graph, build_citation_graph,
citation_pagerank, citation_pagerank,
citing_coverage,
dangling_citations, dangling_citations,
find_co_cited, find_co_cited,
find_related, find_related,
@@ -287,3 +288,24 @@ class TestDanglingCitations:
g = build_citation_graph(_make_conn(rows)) g = build_citation_graph(_make_conn(rows))
dangling = dangling_citations(g, set()) dangling = dangling_citations(g, set())
assert set(dangling) == {"A", "B"} 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)