Files
vibrantlabsai--ragas/tests/unit/test_knowledge_graph_clusters.py
2026-07-13 13:35:10 +08:00

1078 lines
37 KiB
Python

import random
import time
import typing as t
import uuid
import pytest
from ragas.testset.graph import KnowledgeGraph, Node, NodeType, Relationship
class DebugUUID(uuid.UUID):
"""
A UUID subclass that displays a debug name instead of the UUID value.
Creates a more readable graph representation in logs/debuggers while maintaining UUID compatibility.
"""
def __init__(self, debug_name):
# Create a random UUID internally
self.debug = debug_name
super().__init__(hex=str(uuid.uuid4()))
def __str__(self):
return self.debug
def __repr__(self):
return f"DebugUUID('{self.debug}')"
def __setattr__(self, name, value):
object.__setattr__(self, name, value)
def create_document_node(name: str) -> Node:
"""Helper function to create a document node with proper structure."""
return Node(
id=DebugUUID(name),
type=NodeType.DOCUMENT,
properties={
"page_content": f"{name} content",
"summary": f"{name} summary",
"document_metadata": {},
"summary_embedding": [0.001, 0.002, 0.003],
"themes": [f"T_{name}"],
"entities": [f"E_d_{name}"],
},
)
def create_chunk_node(name: str) -> Node:
"""Helper function to create a chunk node with proper structure."""
return Node(
id=DebugUUID(name),
type=NodeType.CHUNK,
properties={
"page_content": f"{name} content",
"summary": f"{name} summary",
"summary_embedding": [0.001, 0.002, 0.003],
"themes": [f"T_{name}"],
"entities": [f"E_c_{name}"],
},
)
def create_chain_of_similarities(
starting_node: Node, node_count: int = 5, cycle: bool = False
) -> t.Tuple[list[Node], list[Relationship]]:
"""
Create a chain of document nodes with cosine similarity relationships.
Parameters
----------
starting_node : Node
Node to start the chain from. This will be the first node in the chain.
node_count : int
Number of nodes to create
cycle : bool
If True, add a relationship from the last node back to the first node
Returns
-------
tuple
(list of nodes, list of relationships)
"""
# Use starting_node as the first node
nodes: list[Node] = [starting_node]
# Create remaining nodes
for i in range(node_count - 1):
nodes.append(create_document_node(name=f"{starting_node.id}_{i + 1}"))
relationships = []
for i in range(node_count - 1):
rel = Relationship(
source=nodes[i],
target=nodes[i + 1],
type="cosine_similarity",
bidirectional=True,
properties={"summary_similarity": 0.9},
)
relationships.append(rel)
if cycle and node_count > 1:
# For the cycle, the last node should share an entity with the first node
cycle_rel = Relationship(
source=nodes[-1],
target=nodes[0],
type="cosine_similarity",
bidirectional=True,
properties={"summary_similarity": 0.9},
)
relationships.append(cycle_rel)
return nodes, relationships
def create_chain_of_overlaps(
starting_node: Node, node_count: int = 3, cycle: bool = False
) -> t.Tuple[list[Node], list[Relationship]]:
"""
Create a chain of nodes with entity overlap relationships.
Parameters
----------
starting_node : Node
Node to start the chain from. This will be the first node in the chain.
node_count : int
Number of nodes to create
cycle : bool
If True, add a relationship from the last node back to the first node
Returns
-------
tuple
(list of nodes, list of relationships)
"""
# Create nodes (mix of document and chunk nodes)
nodes: list[Node] = []
relationships: list[Relationship] = []
# Use starting_node as the first node and set its entity
first_entity = f"E_{starting_node.id}_1"
starting_node.properties["entities"] = [
first_entity,
*starting_node.properties["entities"],
]
nodes.append(starting_node)
# Create relationships and remaining node
prev_node = starting_node
for i in range(node_count - 1):
# Realistic entity assignment
prev_entity = f"E_{starting_node.id}_{i + 1}"
new_entity = f"E_{starting_node.id}_{i + 2}"
new_node = create_document_node(name=f"{starting_node.id}_{i + 1}")
# Add entities to the new node, including overlap w/ previous node
new_node.properties["entities"] = [prev_entity, new_entity]
nodes.append(new_node)
rel = Relationship(
source=prev_node,
target=new_node,
type="entities_overlap",
bidirectional=False,
properties={
"entities_overlap_score": 0.1,
"overlapped_items": [[prev_entity, prev_entity]],
},
)
relationships.append(rel)
prev_node = new_node
if cycle and node_count > 1:
# For the cycle, the last node should share an entity with the first node
nodes[-1].properties["entities"].append(first_entity)
cycle_rel = Relationship(
source=nodes[-1],
target=nodes[0],
type="entities_overlap",
bidirectional=False,
properties={
"entities_overlap_score": 0.1,
"overlapped_items": [[first_entity, first_entity]],
},
)
relationships.append(cycle_rel)
return nodes, relationships
def create_web_of_similarities(
node_count=4, similarity_score=0.9
) -> t.Tuple[list[Node], list[Relationship]]:
"""
Create a web of document nodes with cosine similarity relationships between them.
This represents the worst case scenario knowledge graph for the node_count in terms
of time complexity.
Parameters
----------
node_count : int
Number of nodes to create
similarity_score : float
Similarity score to use for all relationships
Returns
-------
tuple
(list of nodes, list of relationships)
"""
# Create nodes
nodes: list[Node] = []
for i in range(node_count):
nodes.append(create_document_node(name=str(i)))
# Create relationships
relationships: list[Relationship] = []
for i in range(node_count):
for j in range(node_count):
if i != j: # Don't connect node to itself
rel = Relationship(
source=nodes[i],
target=nodes[j],
type="cosine_similarity",
bidirectional=True,
properties={"summary_similarity": similarity_score},
)
relationships.append(rel)
return nodes, relationships
def create_document_and_child_nodes() -> t.Tuple[list[Node], list[Relationship]]:
"""
Create a document node and its child chunk nodes with the same structure as create_branched_graph.
Returns
-------
tuple
(list of nodes, list of relationships)
"""
# Create nodes - A is a document, the rest are chunks
doc_node = create_document_node("1")
chunk_b = create_chunk_node("2")
chunk_c = create_chunk_node("3")
chunk_d = create_chunk_node("4")
chunk_e = create_chunk_node("5")
nodes: list[Node] = [doc_node, chunk_b, chunk_c, chunk_d, chunk_e]
# Create "child" relationships from document to chunks
child_relationships = [
Relationship(
source=nodes[0],
target=nodes[1],
type="child",
bidirectional=False,
properties={},
),
Relationship(
source=nodes[0],
target=nodes[2],
type="child",
bidirectional=False,
properties={},
),
Relationship(
source=nodes[0],
target=nodes[3],
type="child",
bidirectional=False,
properties={},
),
Relationship(
source=nodes[0],
target=nodes[4],
type="child",
bidirectional=False,
properties={},
),
]
# Create "next" relationships between chunks
next_relationships = [
Relationship(
source=nodes[1],
target=nodes[2],
type="next",
bidirectional=False,
properties={},
),
Relationship(
source=nodes[2],
target=nodes[3],
type="next",
bidirectional=False,
properties={},
),
Relationship(
source=nodes[3],
target=nodes[4],
type="next",
bidirectional=False,
properties={},
),
]
# Combine all relationships
relationships = child_relationships + next_relationships
return nodes, relationships
def build_knowledge_graph(
nodes: list[Node], relationships: list[Relationship]
) -> KnowledgeGraph:
"""
Build a knowledge graph from nodes and relationships.
Parameters
----------
nodes : list or dict
Nodes to add to the graph
relationships : list
Relationships to add to the graph
Returns
-------
KnowledgeGraph
The constructed knowledge graph
"""
kg: KnowledgeGraph = KnowledgeGraph()
isolated_nodes: list[Node] = [
create_document_node("Iso_A"),
create_document_node("Iso_B"),
]
nodes = nodes + isolated_nodes
# Add nodes to the graph
if isinstance(nodes, dict):
for node in nodes.values():
kg.add(node)
else:
for node in nodes:
kg.add(node)
# Add relationships to the graph
for rel in relationships:
kg.add(rel)
return kg
def assert_clusters_equal(
actual_clusters: list[set[Node]], expected_clusters: list[set[Node]]
) -> None:
"""
Helper function to compare clusters with unordered comparison.
Args:
actual_clusters: List of sets representing the actual clusters
expected_clusters: List of sets representing the expected clusters
"""
# Convert both lists to sets of frozensets for unordered comparison
actual_clusters_set: set[frozenset[Node]] = {
frozenset(cluster) for cluster in actual_clusters
}
expected_clusters_set: set[frozenset[Node]] = {
frozenset(cluster) for cluster in expected_clusters
}
assert actual_clusters_set == expected_clusters_set, (
f"Expected clusters: {expected_clusters_set}\nActual clusters: {actual_clusters_set}"
)
def assert_n_clusters_with_varying_params(
kg: KnowledgeGraph, param_list: list[t.Tuple[int, int]]
) -> None:
"""
Helper function to test find_n_indirect_clusters with various combinations of n and depth_limit.
Assert that the number of clusters returned is equal to n.
Args:
kg: KnowledgeGraph instance to test
param_list: List of tuples (n, depth_limit) to test
"""
for n, depth_limit in param_list:
clusters: list[set[Node]] = kg.find_n_indirect_clusters(
n=n, depth_limit=depth_limit
)
if len(clusters) != n:
# Convert clusters to sets of node IDs for more readable error messages
cluster_ids = [{str(node.id) for node in cluster} for cluster in clusters]
pytest.fail(
f"Expected {n} clusters with params (n={n}, depth_limit={depth_limit}), "
f"but got {len(clusters)} clusters.\n"
f"Actual clusters: {cluster_ids}"
)
def test_find_indirect_clusters_with_document_and_children():
"""Test find_indirect_clusters for a document (A) and its child nodes (B, C, D, E)."""
nodes, relationships = create_document_and_child_nodes()
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
clusters: list[set[Node]] = kg.find_indirect_clusters(depth_limit=4)
assert_clusters_equal(
clusters,
[
{nodes[3], nodes[4]},
{nodes[0], nodes[1]},
{nodes[1], nodes[2]},
{nodes[0], nodes[1], nodes[2]},
{nodes[0], nodes[2]},
],
)
def test_find_n_indirect_clusters_with_document_and_children():
"""Test find_n_indirect_clusters for a document (A) and its child nodes (B, C, D, E)."""
nodes, relationships = create_document_and_child_nodes()
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
# It should not include subsets of found nodes
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=4, depth_limit=4)
assert_clusters_equal(
clusters,
[
{nodes[0], nodes[1], nodes[2], nodes[3]},
{nodes[0], nodes[2], nodes[3], nodes[4]},
{nodes[1], nodes[2], nodes[3], nodes[4]},
],
)
# Test different combinations of n and depth_limit parameters yield n clusters
assert_n_clusters_with_varying_params(
kg, [(3, 3), (3, 2), (2, 4), (2, 3), (2, 2), (1, 2)]
)
def test_find_indirect_clusters_with_similarity_relationships():
"""Test find_indirect_clusters with cosine similarity relationships between document nodes."""
nodes, relationships = create_chain_of_similarities(
create_document_node("A"), node_count=4
)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
clusters: list[set[Node]] = kg.find_indirect_clusters(depth_limit=4)
assert_clusters_equal(
clusters,
[
{nodes[0], nodes[1]},
{nodes[2], nodes[3]},
],
)
def test_find_n_indirect_clusters_with_similarity_relationships():
"""
Test find_n_indirect_clusters with bidirectional cosine similarity relationships between document nodes.
Test that we handle cycles and branches correctly.
"""
nodes, relationships = create_chain_of_similarities(
create_document_node("A"), node_count=4
)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=5, depth_limit=4)
# It should not include subsets of found nodes. Since for n=5 it will always find the four-node superset,
# it should only return that one cluster.
assert_clusters_equal(
clusters,
[
{nodes[0], nodes[1], nodes[2], nodes[3]},
],
)
# create 5 node cycle branching off node 2
five_node_cycle, fnc_relationships = create_chain_of_similarities(
nodes[2], node_count=5, cycle=True
)
# create independent 2 node cycle to cover edge case
two_node_cycle, tnc_relationships = create_chain_of_similarities(
create_document_node("C"), node_count=2, cycle=True
)
new_nodes = five_node_cycle[1:] + two_node_cycle
nodes.extend(new_nodes)
for item in new_nodes + fnc_relationships + tnc_relationships:
kg.add(item)
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=12, depth_limit=3)
assert_clusters_equal(
clusters,
[
{nodes[0], nodes[1], nodes[2]},
{nodes[1], nodes[2], nodes[3]},
{nodes[2], nodes[3], nodes[4]},
{nodes[1], nodes[2], nodes[4]},
{nodes[1], nodes[2], nodes[7]},
{nodes[2], nodes[4], nodes[5]},
{nodes[2], nodes[4], nodes[7]},
{nodes[2], nodes[3], nodes[7]},
{nodes[2], nodes[6], nodes[7]},
{nodes[4], nodes[5], nodes[6]},
{nodes[5], nodes[6], nodes[7]},
{nodes[8], nodes[9]}, # independent two node cycle
],
)
# Test different combinations of n and depth_limit parameters yield n clusters
assert_n_clusters_with_varying_params(
kg, [(4, 4), (4, 3), (4, 2), (3, 4), (3, 3), (3, 2), (2, 4), (2, 3), (2, 2)]
)
def test_find_indirect_clusters_with_overlap_relationships():
"""Test find_indirect_clusters with directional entity overlap relationships."""
nodes, relationships = create_chain_of_overlaps(
create_document_node("A"), node_count=4
)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
clusters: list[set[Node]] = kg.find_indirect_clusters(depth_limit=3)
assert_clusters_equal(
clusters,
[
{nodes[2], nodes[3]},
{nodes[0], nodes[1]},
],
)
def test_find_n_indirect_clusters_with_overlap_relationships():
"""
Test find_n_indirect_clusters with directional entity overlap relationships.
Test that we handle cycles and branches correctly.
"""
nodes, relationships = create_chain_of_overlaps(
create_document_node("A"), node_count=4
)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=5, depth_limit=3)
# Assert the two supersets from above are returned.
assert_clusters_equal(
clusters,
[
{nodes[0], nodes[1], nodes[2]},
{nodes[1], nodes[2], nodes[3]},
],
)
# create 5 node cycle branching off node[2]
five_node_cycle, fnc_relationships = create_chain_of_overlaps(
nodes[2], node_count=5, cycle=True
)
# create independent 2 node cycle to cover edge case
two_node_cycle, tnc_relationships = create_chain_of_overlaps(
create_document_node("C"), node_count=2, cycle=True
)
# Don't include the starting node twice.
new_nodes = five_node_cycle[1:] + two_node_cycle
nodes.extend(new_nodes)
for item in new_nodes + fnc_relationships + tnc_relationships:
kg.add(item)
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=15, depth_limit=3)
assert_clusters_equal(
clusters,
[
{nodes[0], nodes[1], nodes[2]},
{nodes[1], nodes[2], nodes[3]},
{nodes[1], nodes[2], nodes[4]},
{nodes[2], nodes[4], nodes[5]},
{nodes[4], nodes[5], nodes[6]},
{nodes[5], nodes[6], nodes[7]},
{nodes[6], nodes[7], nodes[2]},
{nodes[7], nodes[2], nodes[3]},
{nodes[7], nodes[2], nodes[4]},
{nodes[8], nodes[9]}, # independent two node cycle
],
)
# Test different combinations of n and depth_limit parameters yield n clusters
assert_n_clusters_with_varying_params(
kg, [(3, 4), (3, 4), (3, 3), (3, 2), (2, 4), (2, 3), (2, 2)]
)
def test_find_n_indirect_clusters_handles_worst_case_grouping():
"""
Test that the algorithm will always return n indirect clusters when all nodes are grouped into independent clusters
of `n` nodes. This is a worst-case scenario that can lead to significant under-sampling if not handled correctly.
"""
# The edge case is dependent on random.shuffle() so set a specific seed that exposes it deterministically.
# Otherwise it only fails 50% of the time (when the 2 starting nodes are from the same cluster).
original_state = random.getstate()
random.seed(5)
try:
nodes_A, relationships_A = create_chain_of_similarities(
create_document_node("A"), node_count=2
)
nodes_B, relationships_B = create_chain_of_similarities(
create_document_node("B"), node_count=2
)
kg: KnowledgeGraph = build_knowledge_graph(
nodes_A + nodes_B, relationships_A + relationships_B
)
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=2, depth_limit=2)
assert_clusters_equal(
clusters,
[
{nodes_A[0], nodes_A[1]},
{nodes_B[0], nodes_B[1]},
],
)
finally:
# Restore original random state to avoid affecting other tests
random.setstate(original_state)
def test_find_indirect_clusters_with_condition():
"""Test find_indirect_clusters with a relationship condition."""
nodes, relationships = create_document_and_child_nodes()
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
def condition(rel):
return rel.type == "next"
clusters: list[set[Node]] = kg.find_indirect_clusters(
relationship_condition=condition
)
# Only "next" relationships are considered, so we should only have paths between B, C, D, and E
assert_clusters_equal(
clusters,
[
{nodes[3], nodes[4]},
{nodes[1], nodes[2]},
],
)
def test_find_n_indirect_clusters_with_condition():
"""Test find_n_indirect_clusters with a relationship condition."""
nodes, relationships = create_document_and_child_nodes()
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
def condition(rel):
return rel.type == "next"
clusters: list[set[Node]] = kg.find_n_indirect_clusters(
n=5, relationship_condition=condition
)
# Only "next" relationships are considered, so we should only have paths between B, C, D, and E
assert_clusters_equal(
clusters,
[
{nodes[1], nodes[2], nodes[3]},
{nodes[2], nodes[3], nodes[4]},
],
)
assert_n_clusters_with_varying_params(kg, [(2, 3), (2, 2)])
# test cyclic relationships for bidirectional relationships
def test_find_indirect_clusters_with_cyclic_similarity_relationships():
"""Test find_indirect_clusters with cyclic cosine similarity relationships."""
nodes, relationships = create_chain_of_similarities(
create_document_node("A"), node_count=3, cycle=True
)
# branch off last node so it both cycles and branches
branched_nodes, branched_relationships = create_chain_of_similarities(
nodes[-1], node_count=2
)
nodes.extend(branched_nodes[1:])
relationships.extend(branched_relationships)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
clusters: list[set[Node]] = kg.find_indirect_clusters(depth_limit=10)
# With a cycle and branch, we should find meaningful indirect clusters
# The algorithm should find clusters that connect nodes through indirect paths
# Basic checks that the algorithm found something reasonable
assert len(clusters) >= 2, f"Expected at least 2 clusters, got {len(clusters)}"
assert len(clusters) <= 10, (
f"Expected at most 10 clusters, got {len(clusters)}"
) # Reasonable upper bound
# Check that all nodes are covered by at least one cluster
all_cluster_nodes = set()
for cluster in clusters:
all_cluster_nodes.update(cluster)
# At least the main cycle nodes should be in some cluster
cycle_nodes = {nodes[0], nodes[1], nodes[2]} # A, A_1, A_2
assert cycle_nodes.issubset(all_cluster_nodes), (
f"Cycle nodes {cycle_nodes} should be covered by clusters, "
f"but only found {all_cluster_nodes & cycle_nodes}"
)
# Each cluster should have at least 2 nodes (indirect connections)
for i, cluster in enumerate(clusters):
assert len(cluster) >= 2, (
f"Cluster {i} has only {len(cluster)} nodes: {cluster}"
)
# test cyclic relationships for bidirectional relationships
def test_find_n_indirect_clusters_with_cyclic_similarity_relationships():
"""Test find_n_indirect_clusters with cyclic cosine similarity relationships."""
nodes, relationships = create_chain_of_similarities(
create_document_node("A"), node_count=3, cycle=True
)
# branch off last node so it both cycles and branches
branched_nodes, branched_relationships = create_chain_of_similarities(
nodes[-1], node_count=2
)
nodes.extend(branched_nodes[1:])
relationships.extend(branched_relationships)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
# Using a depth limit of 3 which should yield the 5 clusters of three nodes from the previous test.
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=5, depth_limit=3)
# With a cycle, we expect additional clusters that include paths through the cycle. Using depth_limit=3
# here so it should yield the 5 3-node clusters from the previous test.
assert_clusters_equal(
clusters,
[
{nodes[0], nodes[1], nodes[2]},
{nodes[0], nodes[2], nodes[3]},
{nodes[1], nodes[2], nodes[0]},
{nodes[2], nodes[0], nodes[1]},
{nodes[1], nodes[2], nodes[3]},
],
)
assert_n_clusters_with_varying_params(kg, [(1, 4), (3, 3), (2, 3), (2, 2)])
def test_find_indirect_clusters_with_web_graph():
"""Test find_indirect_clusters with a spider web graph where all nodes connect to all other nodes."""
nodes, relationships = create_web_of_similarities(node_count=4)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
clusters: list[set[Node]] = kg.find_indirect_clusters(depth_limit=3)
assert_clusters_equal(
clusters,
[
{nodes[0], nodes[1], nodes[2]},
{nodes[0], nodes[3]},
{nodes[1], nodes[2]},
{nodes[0], nodes[1], nodes[2], nodes[3]},
{nodes[0], nodes[2], nodes[3]},
{nodes[1], nodes[2], nodes[3]},
{nodes[0], nodes[1], nodes[3]},
{nodes[0], nodes[1]},
{nodes[0], nodes[2]},
{nodes[1], nodes[3]},
{nodes[2], nodes[3]},
],
)
def test_find_n_indirect_clusters_with_web_graph():
"""Test find_n_indirect_clusters with a spider web graph where all nodes connect to all other nodes."""
nodes, relationships = create_web_of_similarities(node_count=4)
# Convert nodes list to dictionary for easier assertion
node_dict = {f"{i}": nodes[i] for i in range(len(nodes))}
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=10, depth_limit=3)
# Using a depth_limit=3 which should yield the 4 clusters of three nodes seen in the previous test.
# This method ignores the subsets.
assert_clusters_equal(
clusters,
[
{node_dict["0"], node_dict["1"], node_dict["2"]},
{node_dict["0"], node_dict["1"], node_dict["3"]},
{node_dict["0"], node_dict["2"], node_dict["3"]},
{node_dict["1"], node_dict["2"], node_dict["3"]},
],
)
assert_n_clusters_with_varying_params(
kg, [(4, 3), (3, 3), (3, 2), (2, 3), (2, 2), (1, 2)]
)
def test_performance_find_n_indirect_clusters_max_density():
"""
Test the time complexity performance of find_n_indirect_clusters with "web"graphs of maximum density.
Capping sampling relative to n should keep the time complexity <cubic.
"""
# List of graph sizes to test (number of nodes)
graph_sizes = [5, 10, 20, 80]
results: list[dict] = []
for size in graph_sizes:
nodes, relationships = create_web_of_similarities(node_count=size)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
# Measure execution time
start_time = time.time()
clusters: list[set[Node]] = kg.find_n_indirect_clusters(n=size, depth_limit=4)
end_time = time.time()
execution_time = end_time - start_time
# Store results
results.append(
{"size": size, "time": execution_time, "clusters": len(clusters)}
)
# Make sure we actually got the clusters
assert len(clusters) == size
print("\nPerformance test results:")
print("------------------------")
print("Size | Time (s)")
print("------------------------")
for result in results:
print(f"{result['size']:4d} | {result['time']:.6f}")
print("------------------------")
# Check if time complexity is reasonable
for i in range(1, len(results)):
size_ratio = results[i]["size"] / results[i - 1]["size"]
prev_time = results[i - 1]["time"]
curr_time = results[i]["time"]
# Skip performance check if previous time is too small to measure accurately
# Increased threshold to account for timing variance in different environments
if prev_time < 1e-4: # Less than 100 microseconds
print(
f"Skipping performance check for size {results[i]['size']} vs {results[i - 1]['size']}: "
f"previous time too small ({prev_time:.9f}s)"
)
continue
time_ratio = curr_time / prev_time
# Goal is better than cubic since relationships grow exponentially with n and graph_size for a worst-case "web" graph.
scaled_size_ratio = size_ratio**3
# Add tolerance factor for timing variance, especially in CI environments
# Complete graphs have inherent performance variance due to their exponential nature
# This test uses a "web of similarities" (complete graph) which is the worst-case scenario
# for the clustering algorithm, so we need significant tolerance for timing variance
if (
prev_time < 1e-3
): # Very fast operations are more susceptible to timing noise
tolerance_factor = 3.0 # Allow up to 3x the theoretical threshold
else:
tolerance_factor = 2.0 # Still generous for larger operations
tolerance_threshold = scaled_size_ratio * tolerance_factor
print(
f"Size ratio: {size_ratio:.2f}, Time ratio: {time_ratio:.2f}, Scaled ratio: {scaled_size_ratio:.2f}, Tolerance threshold: {tolerance_threshold:.2f}"
)
assert time_ratio < tolerance_threshold, (
f"Time complexity growing faster than expected: size {results[i]['size']} vs {results[i - 1]['size']}, "
f"time ratio {time_ratio:.2f} vs tolerance threshold {tolerance_threshold:.2f} "
f"(base threshold: {scaled_size_ratio:.2f})"
)
@pytest.fixture
def constant_n_knowledge_graphs():
"""Returns the three knowledge graphs of increasing size."""
graph_sizes = [10, 50, 500]
knowledge_graphs = []
for size in graph_sizes:
nodes, relationships = create_web_of_similarities(node_count=size)
kg: KnowledgeGraph = build_knowledge_graph(nodes, relationships)
knowledge_graphs.append((kg, size))
return knowledge_graphs
def test_performance_find_n_indirect_clusters_large_web_constant_n(
constant_n_knowledge_graphs: list[tuple[KnowledgeGraph, int]],
):
"""
Test the time complexity performance of find_n_indirect_clusters with a constant n=10
but dramatically increasing graph sizes. This tests how the algorithm scales when we're
only interested in sampling a fixed number of clusters but may have a big graph.
"""
constant_n = 10
results: list[dict] = []
for kg, size in constant_n_knowledge_graphs:
# Measure execution time
start_time = time.time()
clusters: list[set[Node]] = kg.find_n_indirect_clusters(
n=constant_n, depth_limit=3
)
end_time = time.time()
execution_time = end_time - start_time
# Store results
results.append(
{
"size": size,
"n": constant_n,
"time": execution_time,
"clusters": len(clusters),
}
)
# Make sure we got clusters (may be less than n if graph doesn't support that many)
assert len(clusters) <= constant_n, (
f"Expected at most {constant_n} clusters, got {len(clusters)}"
)
print("\nPerformance test results (constant n=10):")
print("----------------------------------")
print("Graph Size | n | Clusters | Time (s)")
print("----------------------------------")
for result in results:
print(
f"{result['size']:10d} | {result['n']:1d} | {result['clusters']:8d} | {result['time']:.6f}"
)
print("----------------------------------")
# Check if time complexity is reasonable
for i in range(1, len(results)):
size_ratio = results[i]["size"] / results[i - 1]["size"]
prev_time = results[i - 1]["time"]
curr_time = results[i]["time"]
# Skip performance check if previous time is too small to measure accurately
# Increased threshold to account for timing variance on CI (especially Windows)
if prev_time < 1e-4: # Less than 100 microseconds
print(
f"Skipping performance check for size {results[i]['size']} vs {results[i - 1]['size']}: "
f"previous time too small ({prev_time:.9f}s)"
)
continue
time_ratio = curr_time / prev_time
scaled_size_ratio = size_ratio**2.5
# Add tolerance for platform variance; operations can be noisy on Windows runners
if prev_time < 1e-3:
tolerance_factor = 3.0
else:
tolerance_factor = 2.0
tolerance_threshold = scaled_size_ratio * tolerance_factor
print(
f"Size ratio: {size_ratio:.2f}, (Scaled: {scaled_size_ratio:.2f}), Time ratio: {time_ratio:.2f}, Tolerance: {tolerance_threshold:.2f}"
)
assert time_ratio < tolerance_threshold, (
f"Time complexity growing faster than expected: size {results[i]['size']} vs {results[i - 1]['size']}, time ratio {time_ratio:.2f} vs {tolerance_threshold:.2f}"
)
def test_performance_find_n_indirect_clusters_independent_chains():
"""
Test the time complexity performance of find_n_indirect_clusters with independent chains of 4 nodes.
This uses the inflated sample size that is used when the nodes are isolated such that there are less edges than nodes.
"""
# List of total node counts to test
graph_sizes = [8, 16, 32, 128, 1024]
results: list[dict] = []
for size in graph_sizes:
# Calculate how many chains of 4 nodes we need
num_chains = size // 4
# Create independent chains of 4 nodes each
all_nodes = []
all_relationships = []
for i in range(num_chains):
chain_nodes, chain_relationships = create_chain_of_similarities(
create_document_node(f"{i}_start"), node_count=4, cycle=False
)
all_nodes.extend(chain_nodes)
all_relationships.extend(chain_relationships)
kg: KnowledgeGraph = build_knowledge_graph(all_nodes, all_relationships)
# Measure execution time
start_time = time.time()
clusters: list[set[Node]] = kg.find_n_indirect_clusters(
n=num_chains, depth_limit=3
)
end_time = time.time()
execution_time = end_time - start_time
# Store results
results.append(
{
"size": size,
"chains": num_chains,
"time": execution_time,
"clusters": len(clusters),
}
)
# Make sure we got the expected number of clusters (one per chain)
assert len(clusters) == num_chains, (
f"Expected {num_chains} clusters, got {len(clusters)}"
)
print("\nPerformance test results (independent chains):")
print("------------------------")
print("Size | Chains | Time (s)")
print("------------------------")
for result in results:
print(f"{result['size']:4d} | {result['chains']:6d} | {result['time']:.6f}")
print("------------------------")
for i in range(1, len(results)):
size_ratio = results[i]["size"] / results[i - 1]["size"]
prev_time = results[i - 1]["time"]
curr_time = results[i]["time"]
# Skip performance check if previous time is too small to measure accurately
# Increased threshold to account for timing variance in different environments
if prev_time < 1e-4: # Less than 100 microseconds
print(
f"Skipping performance check for size {results[i]['size']} vs {results[i - 1]['size']}: "
f"previous time too small ({prev_time:.9f}s)"
)
continue
time_ratio = curr_time / prev_time
# Goal is to be ~quadratic or better.
scaled_size_ratio = size_ratio**2
# Add tolerance factor for timing variance, especially in CI environments
# Independent chains can have performance variance due to sample size calculations
if (
prev_time < 1e-3
): # Very fast operations are more susceptible to timing noise
tolerance_factor = 2.5 # Allow up to 2.5x the theoretical threshold
else:
tolerance_factor = 2.0 # Still generous for larger operations
tolerance_threshold = scaled_size_ratio * tolerance_factor
print(
f"Size ratio: {size_ratio:.2f} (scaled: {scaled_size_ratio:.2f}), Time ratio: {time_ratio:.2f}, Tolerance threshold: {tolerance_threshold:.2f}"
)
assert time_ratio < tolerance_threshold, (
f"Time complexity growing faster than expected: size {results[i]['size']} vs {results[i - 1]['size']}, "
f"time ratio {time_ratio:.2f} vs tolerance threshold {tolerance_threshold:.2f} "
f"(base threshold: {scaled_size_ratio:.2f})"
)