From d36312aa94df76e12c94a43266fae10bbbd8644d Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Sun, 30 Aug 2026 00:37:58 +0530 Subject: [PATCH] fix(report): make the report's headline numbers agree with themselves (#3148) Three self-disagreements in GRAPH_REPORT.md, all in the same few lines: * the Knowledge Gaps thin-community count was gated by a hardcoded 3 while the label beside it printed min_community_size, and the Summary/ Communities headers counted with min_community_size - so any run with --min-community-size other than 3 published two different figures for one thing. One _real_count predicate now feeds every figure; * "shown" was total-minus-thin, which counted zero-real-node communities the render loop skips (#2129's residual) - it now counts exactly what renders (real >= min_community_size); * the isolated-node figure silently excludes file, concept and rationale nodes, so a plain degree<=1 recount from graph.json never matched it. The line now says it counts symbols only and prints the raw <=1 total beside it, auditable against the graph. --- graphify/report.py | 29 ++++++++-- tests/test_report_gap_thresholds.py | 84 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 tests/test_report_gap_thresholds.py diff --git a/graphify/report.py b/graphify/report.py index 69657f0d9d..673c2f2bb6 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -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: diff --git a/tests/test_report_gap_thresholds.py b/tests/test_report_gap_thresholds.py new file mode 100644 index 0000000000..1abb10ce66 --- /dev/null +++ b/tests/test_report_gap_thresholds.py @@ -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)