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."""
|
"""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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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``.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user