From 390152c78ceac35432da703c1b1b2b55739c91b8 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Thu, 16 Apr 2026 17:59:50 +0530 Subject: [PATCH] feat(explorer): add node distance semantics to PathResponse (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend PathResponse with hop_count (len(path)-1) and distance_band ("direct"|"near"|"mid-range"|"distant") as first-class API fields - Add classify_path_distance() to semantica/utils/helpers.py as the single source of truth for hop-count thresholds; both the route and the visualizer import from it, eliminating duplicate threshold logic - Populate hop_count and distance_band in find_path route via classify_path_distance(); remove local _classify_distance() copy - Add highlight_path: list[str] param to KGVisualizer.visualize_network; path edges rendered as a distance-aware orange trace (opacity and stroke width scale from direct→distant: 1.0/4px to 0.35/1.5px) - Fix bidirectional edge lookup: only forward pairs (A→B) along the path are added to path_edge_set; reverse back-edges in directed graphs are no longer incorrectly highlighted - Add logger.warning when highlight_path contains node IDs absent from the layout position map, surfacing silent no-op mismatches - Extend frontend PathResponse type in GraphInspectorPanel.tsx and GraphWorkspaceShell.tsx with hop_count: number and distance_band literal union to match the updated API contract - Add 10 new tests: 2 API-level and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops); 104 explorer tests pass, 0 failures introduced - Update CHANGELOG.md --- CHANGELOG.md | 2 + .../GraphWorkspace/GraphInspectorPanel.tsx | 2 + .../GraphWorkspace/GraphWorkspaceShell.tsx | 2 + semantica/explorer/routes/graph.py | 6 ++ semantica/explorer/schemas.py | 2 + semantica/utils/helpers.py | 22 ++++ semantica/visualization/kg_visualizer.py | 101 ++++++++++++++---- tests/explorer/test_explorer_api.py | 55 ++++++++++ 8 files changed, 172 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d00c2d11..fa1f43e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (0–1 hops), `"near"` (2–3), `"mid-range"` (4–6), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops). + - **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`). - **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability). diff --git a/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx index f9ff4b38..df30b0e5 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx @@ -14,6 +14,8 @@ export type PathResponse = { path: string[]; edge_ids?: string[]; total_weight: number; + hop_count: number; + distance_band: "direct" | "near" | "mid-range" | "distant"; }; export interface GraphInspectorPanelProps { diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx index 7e32fcca..14003ca9 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx @@ -33,6 +33,8 @@ type LinkPrediction = { type PathResponse = { path: GraphPath; total_weight: number; + hop_count: number; + distance_band: "direct" | "near" | "mid-range" | "distant"; }; type TemporalBounds = { diff --git a/semantica/explorer/routes/graph.py b/semantica/explorer/routes/graph.py index 4fe60b11..3ad458d2 100644 --- a/semantica/explorer/routes/graph.py +++ b/semantica/explorer/routes/graph.py @@ -8,6 +8,7 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query +from ...utils.helpers import classify_path_distance from ..dependencies import get_session from ..schemas import ( EdgeListResponse, @@ -141,6 +142,8 @@ class _PathAlgorithm(str, Enum): dijkstra = "dijkstra" + + @router.get("/node/{node_id}/path", response_model=PathResponse) async def find_path( node_id: str, @@ -171,6 +174,7 @@ async def find_path( total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0 edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes) + hop_count = len(path_nodes) - 1 if path_nodes else 0 return PathResponse( source=node_id, target=target, @@ -179,6 +183,8 @@ async def find_path( edge_ids=edge_ids, total_weight=total_weight, directed=directed, + hop_count=hop_count, + distance_band=classify_path_distance(hop_count), ) diff --git a/semantica/explorer/schemas.py b/semantica/explorer/schemas.py index 5b1f9778..c77badb1 100644 --- a/semantica/explorer/schemas.py +++ b/semantica/explorer/schemas.py @@ -68,6 +68,8 @@ class PathResponse(BaseModel): edge_ids: List[str] = Field(default_factory=list) total_weight: float = 0.0 directed: bool = True + hop_count: int = 0 + distance_band: str = "direct" class GraphStatsResponse(BaseModel): diff --git a/semantica/utils/helpers.py b/semantica/utils/helpers.py index 3a23f81b..21f6cfc2 100644 --- a/semantica/utils/helpers.py +++ b/semantica/utils/helpers.py @@ -562,3 +562,25 @@ def retry_on_error( return wrapper return decorator + + +def classify_path_distance(hop_count: int) -> str: + """Classify a path hop count into a human-readable distance band. + + Bands: + "direct" — 0–1 hops (single edge or self) + "near" — 2–3 hops (closely related) + "mid-range" — 4–6 hops (reachable but separated) + "distant" — 7+ hops (weakly coupled) + + This is the single source of truth for distance-band thresholds used by + both the Explorer API (PathResponse.distance_band) and the KGVisualizer + (highlight_path edge styling). + """ + if hop_count <= 1: + return "direct" + if hop_count <= 3: + return "near" + if hop_count <= 6: + return "mid-range" + return "distant" diff --git a/semantica/visualization/kg_visualizer.py b/semantica/visualization/kg_visualizer.py index fbebc752..b265df70 100644 --- a/semantica/visualization/kg_visualizer.py +++ b/semantica/visualization/kg_visualizer.py @@ -61,6 +61,7 @@ try: except Exception: # pragma: no cover _KnowledgeGraph = None # type: ignore[assignment,misc] +from ..utils.helpers import classify_path_distance from ..utils.progress_tracker import get_progress_tracker from .utils.color_schemes import ColorPalette, ColorScheme from .utils.export_formats import ( @@ -191,6 +192,7 @@ class KGVisualizer: node_color_by: str = "type", node_size_by: Optional[str] = None, hover_data: Optional[List[str]] = None, + highlight_path: Optional[List[str]] = None, **options, ) -> Optional[Any]: """ @@ -212,6 +214,9 @@ class KGVisualizer: node_color_by: Property to map to node color (default: "type") node_size_by: Property to map to node size (default: fixed) hover_data: List of properties to show in hover tooltip + highlight_path: Optional ordered list of node IDs forming a path to + highlight with distance-aware edge styling (opacity and stroke + weight reflect hop count along the path). **options: Additional visualization options Returns: @@ -261,13 +266,14 @@ class KGVisualizer: tracking_id, message="Generating visualization..." ) result = self._visualize_network_plotly( - nodes, - edges, - output, - file_path, + nodes, + edges, + output, + file_path, node_color_by=node_color_by, node_size_by=node_size_by, hover_data=hover_data, + highlight_path=highlight_path, **options ) @@ -564,6 +570,21 @@ class KGVisualizer: return edges + @staticmethod + def _path_edge_style(distance_band: str) -> Tuple[float, float]: + """Return (opacity, width) for a path edge based on its distance band. + + Bands come from ``classify_path_distance`` in ``utils.helpers`` — the + single source of truth for hop-count thresholds. + """ + if distance_band == "direct": + return (1.0, 4.0) + if distance_band == "near": + return (0.85, 3.0) + if distance_band == "mid-range": + return (0.6, 2.0) + return (0.35, 1.5) # "distant" + def _visualize_network_plotly( self, nodes: List[Dict[str, Any]], @@ -573,6 +594,7 @@ class KGVisualizer: node_color_by: str = "type", node_size_by: Optional[str] = None, hover_data: Optional[List[str]] = None, + highlight_path: Optional[List[str]] = None, **options, ) -> Optional[Any]: """Create Plotly network visualization.""" @@ -675,47 +697,73 @@ class KGVisualizer: node_text.append(text) - # Prepare edge traces - edge_x = [] - edge_y = [] - + # Build path edge lookup for highlight_path support + path_edge_set: set = set() + path_distance_band = "direct" + if highlight_path and len(highlight_path) >= 2: + path_hop_count = len(highlight_path) - 1 + path_distance_band = classify_path_distance(path_hop_count) + # Only add the directed edges that actually form the path (A→B, not B→A). + # Adding the reverse would incorrectly highlight unrelated back-edges. + for i in range(path_hop_count): + path_edge_set.add((highlight_path[i], highlight_path[i + 1])) + # Warn if any path node has no layout position (silent highlight failure). + missing = [n for n in highlight_path if n not in pos] + if missing: + self.logger.warning( + "highlight_path contains node IDs not found in the graph: %s", + missing, + ) + + path_opacity, path_width = self._path_edge_style(path_distance_band) + + # Prepare edge traces — split into background (non-path) and path edges + edge_x: List = [] + edge_y: List = [] + path_edge_x: List = [] + path_edge_y: List = [] + # Prepare edge label traces and annotations (for arrows) edge_label_x = [] edge_label_y = [] edge_label_text = [] annotations = [] - + # Limit detailed edge rendering for performance if graph is too large show_detailed_edges = len(edges) < 500 - + for edge in edges: source_pos = pos.get(edge["source"]) target_pos = pos.get(edge["target"]) if source_pos and target_pos: x0, y0 = source_pos x1, y1 = target_pos - edge_x.extend([x0, x1, None]) - edge_y.extend([y0, y1, None]) - + + is_path_edge = (edge["source"], edge["target"]) in path_edge_set + if is_path_edge: + path_edge_x.extend([x0, x1, None]) + path_edge_y.extend([y0, y1, None]) + else: + edge_x.extend([x0, x1, None]) + edge_y.extend([y0, y1, None]) + if show_detailed_edges: # Calculate midpoint for label mx, my = (x0 + x1) / 2, (y0 + y1) / 2 - + if edge.get("label"): edge_label_x.append(mx) edge_label_y.append(my) edge_label_text.append(edge["label"]) - + # Add arrow annotation - # Adjust arrow to point slightly before the node to avoid overlap with node marker - # This is approximate; precise calculation requires node size annotations.append( dict( ax=x0, ay=y0, axref='x', ayref='y', x=x1, y=y1, xref='x', yref='y', arrowhead=2, arrowsize=1, arrowwidth=1, arrowcolor="#888", opacity=0.6, - standoff=15 # Distance from target node + standoff=15 ) ) @@ -728,9 +776,22 @@ class KGVisualizer: showlegend=False, opacity=0.5 ) - + traces = [edge_trace] - + + # Overlay highlighted path edges with distance-aware styling + if path_edge_x: + path_trace = go.Scatter( + x=path_edge_x, + y=path_edge_y, + line=dict(width=path_width, color="#e05c00"), + hoverinfo="none", + mode="lines", + showlegend=False, + opacity=path_opacity, + ) + traces.append(path_trace) + if show_detailed_edges and edge_label_text: edge_label_trace = go.Scatter( x=edge_label_x, diff --git a/tests/explorer/test_explorer_api.py b/tests/explorer/test_explorer_api.py index 0be0c783..2dd33a13 100644 --- a/tests/explorer/test_explorer_api.py +++ b/tests/explorer/test_explorer_api.py @@ -758,3 +758,58 @@ class TestBidirectionalPathRoute: resp_false = path_client.get("/api/graph/node/A/path?target=B&directed=false") assert resp_true.json()["directed"] is True assert resp_false.json()["directed"] is False + + # ------------------------------------------------------------------ + # hop_count and distance_band — issue #472 + # ------------------------------------------------------------------ + + def test_response_includes_hop_count_and_distance_band(self, path_client): + """PathResponse must include hop_count and distance_band fields.""" + resp = path_client.get("/api/graph/node/A/path?target=B") + assert resp.status_code == 200 + body = resp.json() + assert "hop_count" in body + assert "distance_band" in body + + def test_one_hop_path_is_direct(self, path_client): + """A single-edge path (1 hop) must return distance_band='direct'.""" + resp = path_client.get("/api/graph/node/A/path?target=B") + assert resp.status_code == 200 + body = resp.json() + assert body["hop_count"] == 1 + assert body["distance_band"] == "direct" + + +# --------------------------------------------------------------------------- +# _classify_distance unit tests — issue #472 +# --------------------------------------------------------------------------- + +from semantica.utils.helpers import classify_path_distance + + +class TestClassifyDistance: + """Unit tests covering all four band boundaries.""" + + def test_zero_hops_is_direct(self): + assert classify_path_distance(0) == "direct" + + def test_one_hop_is_direct(self): + assert classify_path_distance(1) == "direct" + + def test_two_hops_is_near(self): + assert classify_path_distance(2) == "near" + + def test_three_hops_is_near(self): + assert classify_path_distance(3) == "near" + + def test_four_hops_is_mid_range(self): + assert classify_path_distance(4) == "mid-range" + + def test_six_hops_is_mid_range(self): + assert classify_path_distance(6) == "mid-range" + + def test_seven_hops_is_distant(self): + assert classify_path_distance(7) == "distant" + + def test_large_hop_count_is_distant(self): + assert classify_path_distance(20) == "distant"