merge: R-D (citing-coverage warning in graph report)
This commit is contained in:
24
codex/cli.py
24
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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``.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user