mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 07:59:40 +00:00
fix(report): make the report's headline numbers agree with themselves (#3148)
The GRAPH_REPORT headline figures disagreed: shown-community count included empty communities the render loop skips, and the knowledge-gaps thin count used a hardcoded 3 beside a label printing min_community_size. Drive every figure from one _real_count predicate so the counts reconcile with what is actually rendered.
This commit is contained in:
1 parent
97e4275c09
commit
fce26fc9b4
2 files changed
+109
-4
No files matched your search
+25
-4
@@ -139,13 +139,25 @@ def generate(
|
||||
]
|
||||
|
||||
from .analyze import _is_file_node as _ifn
|
||||
|
||||
def _real_count(nodes) -> int:
|
||||
return sum(1 for n in nodes if not _ifn(G, n))
|
||||
|
||||
non_empty = {cid: nodes for cid, nodes in communities.items()
|
||||
if any(not _ifn(G, n) for n in nodes)}
|
||||
# One predicate for every figure the report prints about itself (#3148):
|
||||
# "thin" is 0 < real < min_community_size, and "shown" is what the render
|
||||
# loop below actually renders (real >= min_community_size) - previously
|
||||
# shown was total-thin, which also counted communities with ZERO real
|
||||
# nodes that the loop skips, overstating the count (#2129's residual).
|
||||
thin_count_summary = sum(
|
||||
1 for nodes in communities.values()
|
||||
if 0 < sum(1 for n in nodes if not _ifn(G, n)) < min_community_size
|
||||
if 0 < _real_count(nodes) < min_community_size
|
||||
)
|
||||
shown_count = sum(
|
||||
1 for nodes in communities.values()
|
||||
if _real_count(nodes) >= min_community_size
|
||||
)
|
||||
shown_count = len(communities) - thin_count_summary
|
||||
|
||||
lines += [
|
||||
"",
|
||||
@@ -284,9 +296,13 @@ def generate(
|
||||
and not _is_concept_node(G, n)
|
||||
and G.nodes[n].get("file_type") != "rationale"
|
||||
]
|
||||
# Same threshold the Summary and Communities headers used (#3148): this
|
||||
# was a hardcoded 3, so with --min-community-size anything else the count
|
||||
# here disagreed with the label text beside it, which already printed
|
||||
# min_community_size.
|
||||
thin_communities = {
|
||||
cid: nodes for cid, nodes in communities.items()
|
||||
if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < 3
|
||||
if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < min_community_size
|
||||
}
|
||||
gap_count = len(isolated) + len(thin_communities)
|
||||
|
||||
@@ -295,8 +311,13 @@ def generate(
|
||||
if isolated:
|
||||
isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]]
|
||||
suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else ""
|
||||
raw_isolated = sum(1 for n in G.nodes() if G.degree(n) <= 1)
|
||||
lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
|
||||
lines.append(" These have ≤1 connection - possible missing edges or undocumented components.")
|
||||
lines.append(
|
||||
" These have ≤1 connection - possible missing edges or undocumented components. "
|
||||
f"(Counts symbols only; {raw_isolated} node(s) total have ≤1 connection when "
|
||||
"file, concept and rationale nodes are included.)"
|
||||
)
|
||||
if thin_communities:
|
||||
lines.append(f"- **{len(thin_communities)} thin communities (<{min_community_size} nodes) omitted from report** — run `graphify query` to explore isolated nodes.")
|
||||
if amb_pct > 20:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""GRAPH_REPORT's headline numbers must agree with themselves (#3148).
|
||||
|
||||
The Summary and Communities headers counted thin communities against the
|
||||
caller's --min-community-size, while the Knowledge Gaps section counted
|
||||
against a hardcoded 3 - beside label text that already printed
|
||||
min_community_size. And the "isolated node(s)" figure excludes file,
|
||||
concept and rationale nodes without saying so, so a plain degree<=1 recount
|
||||
from graph.json never matched it. Also the #2129 residual: "shown" was
|
||||
total-minus-thin, which counted zero-real-node communities the render loop
|
||||
skips.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from graphify.report import generate
|
||||
|
||||
|
||||
def _graph_and_communities():
|
||||
G = nx.Graph()
|
||||
# community 0: 6 real symbol nodes (rendered at every threshold used here)
|
||||
big = [f"big{i}" for i in range(6)]
|
||||
# community 1: 4 real nodes - thin at min=5, NOT thin at the hardcoded 3
|
||||
mid = [f"mid{i}" for i in range(4)]
|
||||
for n in big + mid:
|
||||
G.add_node(n, label=n, file_type="code", source_file=f"src/{n}.py",
|
||||
source_location="L1")
|
||||
for group in (big, mid):
|
||||
for a, b in zip(group, group[1:]):
|
||||
G.add_edge(a, b, relation="calls", confidence="EXTRACTED")
|
||||
# community 2: only a file node - the render loop skips it entirely
|
||||
G.add_node("onlyfile", label="onlyfile.py", file_type="code", source_file="onlyfile.py")
|
||||
# a lonely concept node: degree 0, excluded from the symbol-isolated list
|
||||
G.add_node("lonely_concept", label="Lonely", file_type="concept", source_file="d.md")
|
||||
communities = {0: big, 1: mid, 2: ["onlyfile"]}
|
||||
return G, communities
|
||||
|
||||
|
||||
def _report(min_size):
|
||||
G, communities = _graph_and_communities()
|
||||
return generate(
|
||||
G, communities, {}, {}, [], [],
|
||||
{"total_files": 3, "total_words": 100}, {},
|
||||
root="proj", min_community_size=min_size,
|
||||
)
|
||||
|
||||
|
||||
def test_summary_and_gaps_count_thin_with_the_same_threshold():
|
||||
text = _report(5)
|
||||
assert "(1 shown, 1 thin omitted)" in text
|
||||
m = re.search(r"\*\*(\d+) thin communit\w+ \(<(\d+) nodes\) omitted", text)
|
||||
assert m, text
|
||||
assert m.group(1) == "1" and m.group(2) == "5", m.group(0)
|
||||
|
||||
|
||||
def test_at_the_default_threshold_nothing_is_thin():
|
||||
text = _report(3)
|
||||
assert "0 thin omitted" in text
|
||||
if "## Knowledge Gaps" in text:
|
||||
gaps = text.split("## Knowledge Gaps")[-1].split("## ")[0]
|
||||
assert "thin communit" not in gaps
|
||||
|
||||
|
||||
def test_shown_counts_only_what_the_render_loop_renders():
|
||||
"""The zero-real-node community is neither shown nor thin (#2129 residual):
|
||||
shown must be 1 (the big community), not total-minus-thin = 2."""
|
||||
text = _report(5)
|
||||
header = re.search(r"## Communities \((\d+) total, (\d+) thin omitted\)", text)
|
||||
assert header and header.group(2) == "1"
|
||||
assert "(1 shown, 1 thin omitted)" in text
|
||||
rendered = len(re.findall(r"### Community ", text))
|
||||
assert rendered <= 1 or rendered == int(re.search(r"\((\d+) shown", text).group(1))
|
||||
|
||||
|
||||
def test_isolated_count_is_auditable_against_the_raw_graph():
|
||||
text = _report(3)
|
||||
assert "isolated node(s):" in text
|
||||
assert "Counts symbols only" in text
|
||||
m = re.search(r"(\d+) node\(s\) total have ≤1 connection", text)
|
||||
assert m
|
||||
G, _ = _graph_and_communities()
|
||||
assert int(m.group(1)) == sum(1 for n in G.nodes() if G.degree(n) <= 1)
|
||||
Reference in new issue
Block a user