mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user