c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
207 lines
8.1 KiB
Python
207 lines
8.1 KiB
Python
"""Semantic layout: project node embeddings to 2-D pinned positions.
|
|
|
|
Given ``{node_id: vector}`` from ``embedding_join.fetch_node_embeddings`` and the
|
|
graph's links, this computes one 2-D coordinate per node so semantically similar
|
|
nodes land near each other. Positions are a deterministic pure function of the
|
|
inputs — no force simulation, no un-seeded randomness — so they can be pinned and
|
|
rendered layout-once (the repo's rule, see ``memory_map.js`` header).
|
|
|
|
Pipeline:
|
|
* PCA via numpy SVD (default), with a deterministic sign convention so snapshot
|
|
tests are stable — raw SVD sign is arbitrary. Optional UMAP via lazy import;
|
|
ImportError falls back to PCA.
|
|
* Normalize to [-1, 1] per axis, scaled by ``spread``.
|
|
* Nodes without a vector are placed at the seeded-jittered centroid of their
|
|
positioned neighbors; nodes with no positioned neighbor land on a
|
|
deterministic ring.
|
|
* A deterministic seeded de-overlap pass spreads coincident points so dense
|
|
clusters stay legible.
|
|
"""
|
|
|
|
import math
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import numpy as np
|
|
|
|
from cognee.shared.logging_utils import get_logger
|
|
|
|
logger = get_logger("semantic_layout")
|
|
|
|
# Half-width of the normalized coordinate box; the renderer scales this to canvas.
|
|
SPREAD = 1.0
|
|
# Minimum separation (in normalized units) enforced by the de-overlap pass.
|
|
MIN_SEPARATION = 0.02
|
|
LAYOUT_SEED = 42
|
|
|
|
|
|
def _pca_2d(matrix: np.ndarray) -> np.ndarray:
|
|
"""Project rows of ``matrix`` onto their first two principal components.
|
|
|
|
Sign convention: for each component, the feature loading with the largest
|
|
magnitude is forced positive. Raw SVD sign is arbitrary, so without this the
|
|
same input could flip across runs and break snapshot equality.
|
|
"""
|
|
centered = matrix - matrix.mean(axis=0, keepdims=True)
|
|
# full_matrices=False keeps Vt at (min(n, d), d); deterministic given input.
|
|
_, _, vt = np.linalg.svd(centered, full_matrices=False)
|
|
components = vt[:2]
|
|
if components.shape[0] < 2:
|
|
# Degenerate (single component): pad the second axis with zeros.
|
|
pad = np.zeros((2 - components.shape[0], components.shape[1]))
|
|
components = np.vstack([components, pad])
|
|
for i in range(2):
|
|
loading = components[i]
|
|
j = int(np.argmax(np.abs(loading)))
|
|
if loading[j] < 0:
|
|
components[i] = -loading
|
|
return centered @ components.T
|
|
|
|
|
|
def _umap_2d(matrix: np.ndarray, seed: int) -> Optional[np.ndarray]:
|
|
"""Optional UMAP projection. Returns None if UMAP isn't installed."""
|
|
try:
|
|
import umap # lazy: umap-learn is not a cognee dependency
|
|
except ImportError:
|
|
logger.info("UMAP not installed; falling back to PCA for semantic layout.")
|
|
return None
|
|
reducer = umap.UMAP(n_components=2, random_state=seed, n_jobs=1)
|
|
return reducer.fit_transform(matrix)
|
|
|
|
|
|
def _normalize(coords: np.ndarray, spread: float) -> np.ndarray:
|
|
"""Min-max normalize each axis into [-spread, spread]."""
|
|
out = np.zeros_like(coords, dtype=float)
|
|
for axis in range(coords.shape[1]):
|
|
col = coords[:, axis]
|
|
lo, hi = float(col.min()), float(col.max())
|
|
if hi > lo:
|
|
out[:, axis] = (2.0 * (col - lo) / (hi - lo) - 1.0) * spread
|
|
# else: constant axis -> leave at 0
|
|
return out
|
|
|
|
|
|
def _place_missing(
|
|
node_ids: List[str],
|
|
embedded_pos: Dict[str, np.ndarray],
|
|
adjacency: Dict[str, set],
|
|
spread: float,
|
|
rng: np.random.Generator,
|
|
) -> Dict[str, np.ndarray]:
|
|
"""Position nodes without a vector.
|
|
|
|
Neighbor-centroid with seeded jitter, iterated so chains of vector-less nodes
|
|
resolve; nodes with no positioned neighbor land on a deterministic ring.
|
|
"""
|
|
positioned = dict(embedded_pos)
|
|
missing = [nid for nid in node_ids if nid not in positioned]
|
|
|
|
changed = True
|
|
while changed and missing:
|
|
changed = False
|
|
still_missing = []
|
|
for nid in missing: # node_ids is pre-sorted -> deterministic order
|
|
neighbor_pts = [positioned[n] for n in adjacency.get(nid, ()) if n in positioned]
|
|
if neighbor_pts:
|
|
centroid = np.mean(neighbor_pts, axis=0)
|
|
jitter = rng.uniform(-0.03, 0.03, size=2) * spread
|
|
positioned[nid] = centroid + jitter
|
|
changed = True
|
|
else:
|
|
still_missing.append(nid)
|
|
missing = still_missing
|
|
|
|
# Whatever remains is disconnected from every positioned node: ring it.
|
|
for k, nid in enumerate(missing):
|
|
angle = 2.0 * math.pi * k / max(1, len(missing))
|
|
positioned[nid] = np.array(
|
|
[1.15 * spread * math.cos(angle), 1.15 * spread * math.sin(angle)]
|
|
)
|
|
return positioned
|
|
|
|
|
|
def _deoverlap(
|
|
ordered_ids: List[str],
|
|
positioned: Dict[str, np.ndarray],
|
|
min_dist: float,
|
|
rng: np.random.Generator,
|
|
iterations: int = 40,
|
|
) -> Dict[str, np.ndarray]:
|
|
"""Deterministic seeded relaxation: push apart points closer than ``min_dist``.
|
|
|
|
O(n²) per iteration — fine at ``SEMANTIC_NODE_CAP``, which bounds every
|
|
caller. Swap in a spatial grid if the cap ever grows.
|
|
"""
|
|
if len(ordered_ids) < 2:
|
|
return positioned
|
|
pts = np.array([positioned[nid] for nid in ordered_ids], dtype=float)
|
|
# Seeded tie-breaking nudge so exactly-coincident points separate deterministically.
|
|
pts = pts + rng.uniform(-min_dist / 4, min_dist / 4, size=pts.shape)
|
|
for _ in range(iterations):
|
|
diff = pts[:, None, :] - pts[None, :, :] # (n, n, 2)
|
|
dist = np.sqrt((diff**2).sum(axis=2)) # (n, n)
|
|
# A finite self-distance of exactly min_dist keeps the diagonal out of
|
|
# too_close without inf arithmetic (inf produced NaN warnings in push).
|
|
np.fill_diagonal(dist, min_dist)
|
|
too_close = dist < min_dist
|
|
if not too_close.any():
|
|
break
|
|
safe = np.where(dist == 0, 1.0, dist)
|
|
push = np.where(too_close, (min_dist - dist) / safe, 0.0)
|
|
shift = (diff * push[:, :, None]).sum(axis=1) * 0.5
|
|
pts = pts + shift
|
|
return {nid: pts[i] for i, nid in enumerate(ordered_ids)}
|
|
|
|
|
|
def compute_positions(
|
|
nodes: List[Dict[str, Any]],
|
|
links: List[Dict[str, Any]],
|
|
embeddings: Dict[str, List[float]],
|
|
*,
|
|
method: str = "pca",
|
|
seed: int = LAYOUT_SEED,
|
|
spread: float = SPREAD,
|
|
) -> Dict[str, Dict[str, float]]:
|
|
"""Return ``{node_id: {"x": float, "y": float}}`` for every node.
|
|
|
|
Deterministic given identical inputs. ``method`` is ``"pca"`` (default) or
|
|
``"umap"`` (falls back to PCA when umap-learn is absent).
|
|
"""
|
|
node_ids = sorted(str(n["id"]) for n in nodes)
|
|
rng = np.random.default_rng(seed)
|
|
|
|
adjacency: Dict[str, set] = {nid: set() for nid in node_ids}
|
|
for link in links:
|
|
s, t = str(link["source"]), str(link["target"])
|
|
if s in adjacency and t in adjacency:
|
|
adjacency[s].add(t)
|
|
adjacency[t].add(s)
|
|
|
|
# Embedded nodes with a consistent dimension.
|
|
embedded_ids = [nid for nid in node_ids if nid in embeddings]
|
|
embedded_pos: Dict[str, np.ndarray] = {}
|
|
if len(embedded_ids) >= 2:
|
|
matrix = np.array([embeddings[nid] for nid in embedded_ids], dtype=float)
|
|
coords = None
|
|
if method == "umap":
|
|
coords = _umap_2d(matrix, seed)
|
|
if coords is None:
|
|
coords = _pca_2d(matrix)
|
|
coords = _normalize(np.asarray(coords, dtype=float), spread)
|
|
embedded_pos = {nid: coords[i] for i, nid in enumerate(embedded_ids)}
|
|
elif len(embedded_ids) == 1:
|
|
embedded_pos = {embedded_ids[0]: np.zeros(2)}
|
|
|
|
positioned = _place_missing(node_ids, embedded_pos, adjacency, spread, rng)
|
|
positioned = _deoverlap(node_ids, positioned, MIN_SEPARATION * spread, rng)
|
|
|
|
return {nid: {"x": float(p[0]), "y": float(p[1])} for nid, p in positioned.items()}
|
|
|
|
|
|
def emit_js(_preprocessed=None) -> str:
|
|
"""Expose the pinned semantic positions to the renderer via a data token.
|
|
|
|
The orchestrator substitutes ``__SEMANTIC_POSITIONS__`` with the JSON produced
|
|
by ``compute_positions``; the semantic view reads ``window._semanticPositions``.
|
|
"""
|
|
return "window._semanticPositions = __SEMANTIC_POSITIONS__;"
|