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
188 lines
7.6 KiB
Python
188 lines
7.6 KiB
Python
"""Unit tests for clustering + NN precompute — deterministic, offline, no LLM.
|
|
|
|
Covers seeded k-means determinism and k bounds, cosine nearest-neighbor
|
|
correctness, the deterministic top-entity label path, and the optional
|
|
(mocked) summary seam.
|
|
"""
|
|
|
|
from unittest.mock import Mock
|
|
|
|
import numpy as np
|
|
|
|
from cognee.modules.visualization.semantic_clusters import (
|
|
compute_clusters,
|
|
default_k,
|
|
kmeans,
|
|
)
|
|
|
|
# Two well-separated blobs in 3-D.
|
|
BLOB = {
|
|
"a": [10.0, 0.0, 0.0],
|
|
"b": [10.1, 0.1, 0.0],
|
|
"c": [10.0, -0.1, 0.1],
|
|
"d": [-10.0, 0.0, 0.0],
|
|
"e": [-10.1, 0.1, 0.0],
|
|
"f": [-10.0, -0.1, 0.1],
|
|
}
|
|
|
|
|
|
def _nodes(ids, degree=None):
|
|
degree = degree or {}
|
|
return [{"id": i, "type": "Entity", "name": f"N{i}", "degree": degree.get(i, 0)} for i in ids]
|
|
|
|
|
|
def test_default_k_bounds():
|
|
assert default_k(0) == 1
|
|
assert default_k(1) == 1
|
|
assert default_k(2) == 2 # max(2, ...)
|
|
assert default_k(10_000) == 12 # capped at 12
|
|
assert default_k(8) == 2 # round(sqrt(4)) == 2
|
|
|
|
|
|
def test_kmeans_deterministic_and_separates_blobs():
|
|
x = np.array([BLOB[i] for i in ["a", "b", "c", "d", "e", "f"]])
|
|
l1 = kmeans(x, 2, seed=42)
|
|
l2 = kmeans(x, 2, seed=42)
|
|
assert np.array_equal(l1, l2) # identical across runs
|
|
# First three (positive blob) share a label; last three share the other.
|
|
assert l1[0] == l1[1] == l1[2]
|
|
assert l1[3] == l1[4] == l1[5]
|
|
assert l1[0] != l1[3]
|
|
|
|
|
|
def test_compute_clusters_two_groups():
|
|
nodes = _nodes(["a", "b", "c", "d", "e", "f"])
|
|
result = compute_clusters(nodes, BLOB, k=2, seed=42)
|
|
assert len(result["clusters"]) == 2
|
|
# Every embedded node is assigned.
|
|
assert set(result["node_cluster"]) == set(BLOB)
|
|
# The two positive-blob members land in the same cluster.
|
|
assert result["node_cluster"]["a"] == result["node_cluster"]["b"]
|
|
assert result["node_cluster"]["a"] != result["node_cluster"]["d"]
|
|
|
|
|
|
def test_nearest_neighbors_cosine_topk():
|
|
nodes = _nodes(["a", "b", "c", "d", "e", "f"])
|
|
result = compute_clusters(nodes, BLOB, k=2, seed=42)
|
|
# 'a' neighbors must be its blob-mates (b, c) ahead of the far blob.
|
|
nbrs = result["neighbors"]["a"]
|
|
assert len(nbrs) == 5 # top-5, self excluded
|
|
assert nbrs[0] in {"b", "c"}
|
|
assert nbrs[1] in {"b", "c"}
|
|
assert "a" not in nbrs
|
|
|
|
|
|
def test_nearest_neighbors_excludes_self_on_small_graphs():
|
|
# With <= TOP_NEIGHBORS (5) embedded nodes the self index would fall inside
|
|
# the top-k slice unless it is explicitly dropped. Every node's neighbor list
|
|
# must exclude itself regardless of graph size.
|
|
ids = ["a", "b", "c"] # 3 nodes < top-5
|
|
result = compute_clusters(_nodes(ids), {i: BLOB[i] for i in ids}, k=2, seed=42)
|
|
for nid in ids:
|
|
nbrs = result["neighbors"][nid]
|
|
assert nid not in nbrs
|
|
assert len(nbrs) == len(ids) - 1 # every other node, self excluded
|
|
|
|
|
|
def test_labels_use_top_degree_entities():
|
|
# 'b' has the highest degree in the positive blob -> leads its cluster label.
|
|
nodes = _nodes(["a", "b", "c", "d", "e", "f"], degree={"b": 9, "a": 1, "d": 9})
|
|
result = compute_clusters(nodes, BLOB, k=2, seed=42)
|
|
labels = {c["id"]: c["label"] for c in result["clusters"]}
|
|
pos_cluster = result["node_cluster"]["b"]
|
|
assert labels[pos_cluster].startswith("Nb") # name of highest-degree member
|
|
|
|
|
|
def test_label_prefers_entities_over_chunk_text():
|
|
# One blob mixes a low-degree Entity with a high-degree DocumentChunk whose
|
|
# "name" is a long text blob. The label must use the entity, never the chunk.
|
|
chunk_text = "This is a long document chunk sentence that should never become a label."
|
|
nodes = [
|
|
{"id": "a", "type": "DocumentChunk", "name": chunk_text, "degree": 99},
|
|
{"id": "b", "type": "Entity", "name": "Ada Lovelace", "degree": 1},
|
|
{"id": "c", "type": "Entity", "name": "deadbeefdeadbeefdeadbeefdeadbeef", "degree": 5},
|
|
{"id": "d", "type": "Entity", "name": "Turing", "degree": 2},
|
|
{"id": "e", "type": "Entity", "name": "Babbage", "degree": 2},
|
|
{"id": "f", "type": "Entity", "name": "London", "degree": 2},
|
|
]
|
|
result = compute_clusters(nodes, BLOB, k=2, seed=42)
|
|
labels = [c["label"] for c in result["clusters"]]
|
|
pos_label = labels[result["node_cluster"]["a"]]
|
|
assert "document chunk" not in pos_label.lower() # chunk text excluded
|
|
assert "deadbeef" not in pos_label # identifier-shaped name skipped
|
|
assert "Ada Lovelace" in pos_label # real entity used despite low degree
|
|
|
|
|
|
def test_label_skips_preprocessor_flagged_unnamed_nodes():
|
|
# The preprocessor renames UUID-named nodes to "Unnamed Entity (ab12cd34)"
|
|
# and flags them is_unnamed; those placeholders must never become labels.
|
|
nodes = [
|
|
{
|
|
"id": "a",
|
|
"type": "Entity",
|
|
"name": "Unnamed Entity (ab12cd34)",
|
|
"is_unnamed": True,
|
|
"degree": 99,
|
|
},
|
|
{"id": "b", "type": "Entity", "name": "Ada Lovelace", "degree": 1},
|
|
{"id": "c", "type": "Entity", "name": "Turing", "degree": 1},
|
|
{
|
|
"id": "d",
|
|
"type": "Entity",
|
|
"name": "Unnamed Entity (ee55ff66)",
|
|
"is_unnamed": True,
|
|
"degree": 99,
|
|
},
|
|
{"id": "e", "type": "Entity", "name": "Babbage", "degree": 1},
|
|
{"id": "f", "type": "Entity", "name": "London", "degree": 1},
|
|
]
|
|
result = compute_clusters(nodes, BLOB, k=2, seed=42)
|
|
for cluster in result["clusters"]:
|
|
assert "Unnamed" not in cluster["label"]
|
|
|
|
|
|
def test_label_falls_back_to_dominant_type_when_no_names():
|
|
# A cluster of nameless chunks/summaries: no usable name, so the label is the
|
|
# dominant node type rather than the generic "cluster".
|
|
nodes = [
|
|
{"id": "a", "type": "TextSummary", "text": "summary one"},
|
|
{"id": "b", "type": "TextSummary", "text": "summary two"},
|
|
{"id": "c", "type": "DocumentChunk", "text": "a chunk"},
|
|
{"id": "d", "type": "TextSummary", "text": "summary three"},
|
|
{"id": "e", "type": "TextSummary", "text": "summary four"},
|
|
{"id": "f", "type": "DocumentChunk", "text": "another chunk"},
|
|
]
|
|
result = compute_clusters(nodes, BLOB, k=2, seed=42)
|
|
labels = [c["label"] for c in result["clusters"]]
|
|
assert labels # every cluster is labelled
|
|
assert all(lbl in {"TextSummary", "DocumentChunk"} for lbl in labels)
|
|
assert "cluster" not in labels # generic fallback no longer reached
|
|
|
|
|
|
def test_label_fn_seam_overrides_and_receives_member_nodes():
|
|
# A custom label_fn (e.g. an LLM summarizer) fully replaces the default and is
|
|
# called once per cluster with that cluster's member nodes — no discarded
|
|
# default label is computed first.
|
|
nodes = _nodes(["a", "b", "c", "d", "e", "f"])
|
|
label_fn = Mock(return_value="SUMMARY")
|
|
result = compute_clusters(nodes, BLOB, k=2, seed=42, label_fn=label_fn)
|
|
assert all(c["label"] == "SUMMARY" for c in result["clusters"])
|
|
assert label_fn.call_count == 2 # once per cluster
|
|
# Each call gets a list of member-node dicts, not the default label string.
|
|
for call in label_fn.call_args_list:
|
|
(member_nodes,) = call.args
|
|
assert isinstance(member_nodes, list)
|
|
assert all(isinstance(nd, dict) and "id" in nd for nd in member_nodes)
|
|
|
|
|
|
def test_nodes_without_vectors_are_absent():
|
|
nodes = _nodes(["a", "b", "c", "d", "e", "f", "novec"])
|
|
result = compute_clusters(nodes, BLOB, k=2, seed=42)
|
|
assert "novec" not in result["node_cluster"]
|
|
assert "novec" not in result["neighbors"]
|
|
|
|
|
|
def test_empty_embeddings():
|
|
result = compute_clusters(_nodes(["a"]), {}, seed=42)
|
|
assert result == {"clusters": [], "node_cluster": {}, "neighbors": {}}
|