feat(explorer): add node distance semantics to PathResponse (#472)

- 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
This commit is contained in:
KaifAhmad1
2026-04-16 17:59:50 +05:30
parent 17602812f9
commit 390152c78c
8 changed files with 172 additions and 20 deletions
+2
View File
@@ -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"` (01 hops), `"near"` (23), `"mid-range"` (46), `"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).
@@ -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 {
@@ -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 = {
+6
View File
@@ -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),
)
+2
View File
@@ -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):
+22
View File
@@ -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" 01 hops (single edge or self)
"near" 23 hops (closely related)
"mid-range" 46 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"
+81 -20
View File
@@ -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,
+55
View File
@@ -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"