diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a3b1b5530..89404760f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -93,6 +93,12 @@ See `SECURITY.md` for the full threat model. ## Testing +`analyze.suggest_questions()` interleaves candidates across question types before +applying its result limit (seven by default). This keeps ambiguous relationships +from hiding bridge, inferred-relationship, isolation, and low-cohesion signals. +The existing candidate order within each type is preserved; if the limit is smaller +than the number of available types, their generation order determines priority. + One test file per module under `tests/`. Run with: ```bash diff --git a/graphify/analyze.py b/graphify/analyze.py index 99d27ef21..aa56e03fb 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -468,6 +468,9 @@ def suggest_questions( Generate questions the graph is uniquely positioned to answer. Based on: AMBIGUOUS edges, bridge nodes, underexplored god nodes, isolated nodes. Each question has a 'type', 'question', and 'why' field. + Interleave question types before applying top_n so an abundant early type + cannot crowd out later signals. Preserve the existing order within each type + and category generation order when the limit is smaller than the type count. """ if community_labels: community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()} @@ -583,7 +586,16 @@ def suggest_questions( ), }] - return questions[:top_n] + from itertools import zip_longest + + by_type: dict[str, list[dict]] = {} + for question in questions: + by_type.setdefault(question["type"], []).append(question) + diversified = [ + question for row in zip_longest(*by_type.values()) + for question in row if question is not None + ] + return diversified[:top_n] def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict: diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 6eee72a9a..518739d05 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -603,6 +603,60 @@ def test_god_nodes_filter_is_case_insensitive(): assert variant not in labels, f"`{variant}` should be filtered as JSON-key noise" +def _question_diversity_graph(): + G = nx.Graph() + ambiguous = [f"uncertain_{i}" for i in range(10)] + inferred = ["inferred_a", "inferred_b"] + disconnected = [f"disconnected_{i}" for i in range(5)] + for node in ["hub", *ambiguous, *inferred, *disconnected]: + G.add_node(node, label=node, source_file="src/example.py", file_type="code") + for node in ambiguous: + G.add_edge("hub", node, confidence="AMBIGUOUS", relation="uses") + for node in inferred: + G.add_edge("hub", node, confidence="INFERRED", relation="uses") + communities = {0: ["hub"], 1: ambiguous + inferred, 2: disconnected} + return G, communities + + +def test_suggest_questions_does_not_starve_later_categories(): + """#3849: ten ambiguous edges must not hide all other question types.""" + G, communities = _question_diversity_graph() + questions = suggest_questions(G, communities, {}) + assert len(questions) == 7 + assert [q["type"] for q in questions[:5]] == [ + "ambiguous_edge", "bridge_node", "verify_inferred", "isolated_nodes", "low_cohesion", + ] + assert questions == suggest_questions(G, communities, {}) + + +@pytest.mark.parametrize("top_n", [1, 3, 7, 100]) +def test_suggest_questions_diversity_preserves_limit_and_candidates(top_n): + G, communities = _question_diversity_graph() + all_questions = suggest_questions(G, communities, {}, top_n=100) + limited = suggest_questions(G, communities, {}, top_n=top_n) + assert limited == all_questions[:top_n] + assert sum(q["type"] == "ambiguous_edge" for q in all_questions) == 10 + assert all(set(q) == {"type", "question", "why"} for q in all_questions) + + +def test_suggest_questions_single_category_uses_available_slots(): + G = nx.complete_graph(5) + for node in G: + G.nodes[node].update(label=str(node), source_file="example.py", file_type="code") + for u, v in G.edges: + G.edges[u, v].update(confidence="AMBIGUOUS", relation="uses") + questions = suggest_questions(G, {0: list(G)}, {}) + assert len(questions) == 7 + assert all(q["type"] == "ambiguous_edge" for q in questions) + + +def test_suggest_questions_empty_graph_keeps_no_signal(): + questions = suggest_questions(nx.Graph(), {}, {}) + assert len(questions) == 1 + assert questions[0]["type"] == "no_signal" + assert questions[0]["question"] is None + + def test_suggest_questions_excludes_rationale_nodes_from_isolated_count(): G = nx.Graph() G.add_node("service", label="Service", file_type="code", source_file="service.py")