Skip to content
Closed
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
12 changes: 11 additions & 1 deletion graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions tests/test_path_cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading