diff --git a/graphify/cli.py b/graphify/cli.py index fb4d04ae4..e2e0d2f07 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1130,7 +1130,17 @@ def dispatch_command(cmd: str) -> None: file=sys.stderr, ) try: - path_nodes = _nx.shortest_path(G.to_undirected(as_view=True), src_nid, tgt_nid) + # Deterministic route: among equal-length shortest paths, pick the + # canonical one (lexicographically smallest by node-id sequence) so + # identical calls on an unchanged graph always return the same path. + # nx.shortest_path alone tie-breaks by adjacency-iteration order, which + # Python's per-process hash randomization shuffles — same graph.json, + # a different equal-length route each run (#2074). + path_nodes = min( + _nx.all_shortest_paths( + G.to_undirected(as_view=True), src_nid, tgt_nid), + key=lambda p: [str(n) for n in p], + ) except (_nx.NetworkXNoPath, _nx.NodeNotFound): print(f"No path found between '{source_label}' and '{target_label}'.") sys.exit(0) diff --git a/tests/test_path_cli.py b/tests/test_path_cli.py index 8b979e3a6..0a07a412a 100644 --- a/tests/test_path_cli.py +++ b/tests/test_path_cli.py @@ -1,6 +1,9 @@ """Regression tests for `graphify path` arrow direction (#849).""" from __future__ import annotations import json +import os +import subprocess +import sys import networkx as nx import pytest from networkx.readwrite import json_graph @@ -103,3 +106,51 @@ def test_endpoint_falls_back_to_score_head(monkeypatch, tmp_path, capsys): mainmod.main() assert exc_info.value.code == 0 assert "No path found" in capsys.readouterr().out + + +def _write_diamond_graph(tmp_path): + """Five equal-length routes Alpha->Bravo (one via each Mid node), so the + shortest-path tiebreak is exercised. graph.json omits ``directed``/``multigraph`` + and uses ``edges`` — exactly what ``graphify extract`` writes — so ``path`` loads + it as a MultiDiGraph, the condition under which the chosen route was + hash-seed-dependent (#2074).""" + graph_data = { + "nodes": [{"id": "A", "label": "Alpha"}, {"id": "B", "label": "Bravo"}] + + [{"id": f"m{i}", "label": f"Mid{i}"} for i in range(1, 6)], + "edges": [ + {"source": "A", "target": f"m{i}", "relation": "calls", + "confidence": "EXTRACTED"} for i in range(1, 6) + ] + [ + {"source": f"m{i}", "target": "B", "relation": "calls", + "confidence": "EXTRACTED"} for i in range(1, 6) + ], + } + p = tmp_path / "graph.json" + p.write_text(json.dumps(graph_data)) + return p + + +def _path_route(graph_path, seed): + """Run `graphify path Alpha Bravo` in a fresh process at a fixed hash seed and + return just the rendered route line.""" + env = {**os.environ, "PYTHONHASHSEED": str(seed)} + r = subprocess.run( + [sys.executable, "-m", "graphify", "path", "Alpha", "Bravo", + "--graph", str(graph_path)], + capture_output=True, text=True, env=env, + ) + for line in r.stdout.splitlines(): + if "-->" in line or "<--" in line: + return line.strip() + return r.stdout.strip() + + +def test_path_is_deterministic_across_hash_seeds(tmp_path): + """#2074: identical `graphify path` calls on an unchanged graph must return the + same route. Five equal-length routes exist; nx.shortest_path tie-broke by + hash-shuffled adjacency order, so the route changed between processes.""" + p = _write_diamond_graph(tmp_path) + routes = {_path_route(p, s) for s in range(12)} + assert len(routes) == 1, f"route varies across hash seeds: {sorted(routes)}" + # Deterministic canonical pick = smallest intermediate node id (m1 -> Mid1). + assert "Mid1" in next(iter(routes)), routes