Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
pip install .
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
Expand Down
39 changes: 33 additions & 6 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3190,7 +3190,7 @@ def main() -> None:
if len(sys.argv) < 3:
print('Usage: graphify explain "<node>" [--graph path]', file=sys.stderr)
sys.exit(1)
from graphify.serve import _find_node
from graphify.serve import _find_node, _score_nodes
from networkx.readwrite import json_graph

label = sys.argv[2]
Expand All @@ -3213,11 +3213,38 @@ def main() -> None:
G = json_graph.node_link_graph(_raw, edges="links")
except TypeError:
G = json_graph.node_link_graph(_raw)
matches = _find_node(G, label)
if not matches:
print(f"No node matching '{label}' found.")
sys.exit(0)
nid = matches[0]
# Prefer an exact node-id match (explicit deterministic bypass of fuzzy
# resolution). This mirrors the user's workaround: passing an exact node
# id should always resolve deterministically to that node.
if label in G:
nid = label
else:
# Use the same scorer as `path` for consistent resolution across CLI
# commands. `_score_nodes` returns a sorted list (score, node_id).
scored = _score_nodes(G, [t.lower() for t in label.split()])
if not scored:
print(f"No node matching '{label}' found.")
sys.exit(0)
# Ambiguity detection: if multiple nodes share the top score, list
# them instead of silently choosing one. This prevents explain from
# returning an apparently authoritative explanation that was actually
# a coin-flip among tied candidates (issue #1969).
top_score = scored[0][0]
top_matches = [s for s in scored if abs(s[0] - top_score) < 1e-12]
if len(top_matches) > 1:
print(
f"'{label}' is ambiguous: {len(top_matches)} nodes matched with tied score {top_score}. Use a more specific label or the exact node ID.",
file=sys.stderr,
)
for score, mid in top_matches[:20]:
d = G.nodes[mid]
print(
f" {mid}: {d.get('label','')} ({d.get('source_file','')}) degree={G.degree(mid)}",
file=sys.stderr,
)
# Exit non-zero so calling scripts know the result was ambiguous.
sys.exit(2)
nid = scored[0][1]
d = G.nodes[nid]
print(f"Node: {d.get('label', nid)}")
print(f" ID: {nid}")
Expand Down
3 changes: 2 additions & 1 deletion tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,8 @@ def test_semantic_prune_removes_orphan_entries(tmp_path):
h_a = file_hash(f, tmp_path)
save_cached(f, {"nodes": [{"id": "a"}], "edges": []}, root=tmp_path, kind="semantic")

f.write_text("# B\n\nContent B.\n")
# Use a different file size to bypass the stat fastpath mtime resolution limit
f.write_text("# B\n\nContent B with different length.\n")
h_b = file_hash(f, tmp_path)
save_cached(f, {"nodes": [{"id": "b"}], "edges": []}, root=tmp_path, kind="semantic")

Expand Down
14 changes: 14 additions & 0 deletions tests/test_explain_ambiguity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import networkx as nx
from graphify.serve import _score_nodes


def test_explain_ambiguity_tied_top_scores():
# Two nodes that tie for the simple query "dup"
G = nx.DiGraph()
G.add_node("a", label="dup", norm_label="dup", source_file="pkg/a.py")
G.add_node("b", label="dup", norm_label="dup", source_file="pkg/b.py")

scored = _score_nodes(G, ["dup"])
assert len(scored) >= 2
# top two scores should be equal (tie)
assert abs(scored[0][0] - scored[1][0]) < 1e-12
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading