From 38c0f79e1c9e45bff4756cb412699e47703b4b0e Mon Sep 17 00:00:00 2001 From: Manvel Ghazaryan Date: Wed, 3 Dec 2025 15:22:40 +0100 Subject: [PATCH 1/2] Delete Cache benchmark, as it is not adding any value --- .../Cache/MemoryPipelineCacheBenchmarks.cs | 99 ------------------- 1 file changed, 99 deletions(-) delete mode 100644 benchmarks/ManagedCode.GraphRag.Benchmarks/Cache/MemoryPipelineCacheBenchmarks.cs diff --git a/benchmarks/ManagedCode.GraphRag.Benchmarks/Cache/MemoryPipelineCacheBenchmarks.cs b/benchmarks/ManagedCode.GraphRag.Benchmarks/Cache/MemoryPipelineCacheBenchmarks.cs deleted file mode 100644 index 728b3764b..000000000 --- a/benchmarks/ManagedCode.GraphRag.Benchmarks/Cache/MemoryPipelineCacheBenchmarks.cs +++ /dev/null @@ -1,99 +0,0 @@ -using BenchmarkDotNet.Attributes; -using GraphRag.Cache; -using Microsoft.Extensions.Caching.Memory; - -namespace ManagedCode.GraphRag.Benchmarks.Cache; - -[MemoryDiagnoser] -public class MemoryPipelineCacheBenchmarks -{ - private IMemoryCache _memoryCache = null!; - private MemoryPipelineCache _cache = null!; - private string[] _keys = null!; - private object[] _values = null!; - - [Params(1_000, 10_000, 100_000)] - public int EntryCount { get; set; } - - [GlobalSetup] - public void Setup() - { - _memoryCache = new MemoryCache(new MemoryCacheOptions()); - _cache = new MemoryPipelineCache(_memoryCache); - - _keys = new string[EntryCount]; - _values = new object[EntryCount]; - - for (var i = 0; i < EntryCount; i++) - { - _keys[i] = $"key-{i:D8}"; - _values[i] = new { Id = i, Name = $"Value-{i}", Data = new byte[100] }; - } - } - - [GlobalCleanup] - public void Cleanup() - { - _memoryCache.Dispose(); - } - - [Benchmark] - public async Task SetEntries() - { - for (var i = 0; i < EntryCount; i++) - { - await _cache.SetAsync(_keys[i], _values[i]); - } - } - - [Benchmark] - public async Task GetEntries() - { - // Pre-populate - for (var i = 0; i < EntryCount; i++) - { - await _cache.SetAsync(_keys[i], _values[i]); - } - - // Measure gets - for (var i = 0; i < EntryCount; i++) - { - _ = await _cache.GetAsync(_keys[i]); - } - } - - [Benchmark] - public async Task HasEntries() - { - // Pre-populate - for (var i = 0; i < EntryCount; i++) - { - await _cache.SetAsync(_keys[i], _values[i]); - } - - // Measure has checks - for (var i = 0; i < EntryCount; i++) - { - _ = await _cache.HasAsync(_keys[i]); - } - } - - [Benchmark] - public async Task ClearCache() - { - // Pre-populate - for (var i = 0; i < EntryCount; i++) - { - await _cache.SetAsync(_keys[i], _values[i]); - } - - // Measure clear - await _cache.ClearAsync(); - } - - [Benchmark] - public IPipelineCache CreateChildScope() - { - return _cache.CreateChild("child-scope"); - } -} From 1d3cb8e2e3a9eb7fba9a0258a46a7b0b8fe5e820 Mon Sep 17 00:00:00 2001 From: Manvel Ghazaryan Date: Thu, 4 Dec 2025 08:56:09 +0100 Subject: [PATCH 2/2] Adding test coverage for FastLabelPropagationCommunityDetector.AssignLabels. Adding perf/allocation optimizations --- .../FastLabelPropagationBenchmarks.cs | 3 +- .../FastLabelPropagationCommunityDetector.cs | 12 +- ...tLabelPropagationCommunityDetectorTests.cs | 344 ++++++++++++++++++ 3 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 tests/ManagedCode.GraphRag.Tests/Community/FastLabelPropagationCommunityDetectorTests.cs diff --git a/benchmarks/ManagedCode.GraphRag.Benchmarks/Community/FastLabelPropagationBenchmarks.cs b/benchmarks/ManagedCode.GraphRag.Benchmarks/Community/FastLabelPropagationBenchmarks.cs index 416b84818..de60c2f65 100644 --- a/benchmarks/ManagedCode.GraphRag.Benchmarks/Community/FastLabelPropagationBenchmarks.cs +++ b/benchmarks/ManagedCode.GraphRag.Benchmarks/Community/FastLabelPropagationBenchmarks.cs @@ -8,6 +8,7 @@ namespace ManagedCode.GraphRag.Benchmarks.Community; [MemoryDiagnoser] +[HideColumns("Error", "StdDev", "RatioSD")] public class FastLabelPropagationBenchmarks { private EntityRecord[] _smallGraphEntities = null!; @@ -41,7 +42,7 @@ public void Setup() (_largeGraphEntities, _largeGraphRelationships) = GenerateGraph(10_000, 5); } - [Benchmark] + [Benchmark(Baseline = true)] public IReadOnlyDictionary SmallGraph() { return FastLabelPropagationCommunityDetector.AssignLabels( diff --git a/src/ManagedCode.GraphRag/Community/FastLabelPropagationCommunityDetector.cs b/src/ManagedCode.GraphRag/Community/FastLabelPropagationCommunityDetector.cs index 8187c0560..518d28061 100644 --- a/src/ManagedCode.GraphRag/Community/FastLabelPropagationCommunityDetector.cs +++ b/src/ManagedCode.GraphRag/Community/FastLabelPropagationCommunityDetector.cs @@ -23,15 +23,16 @@ public static IReadOnlyDictionary AssignLabels( var random = new Random(config.Seed); var labels = adjacency.Keys.ToDictionary(node => node, node => node, StringComparer.OrdinalIgnoreCase); - var nodes = adjacency.Keys.ToList(); + var nodes = adjacency.Keys.ToArray(); var maxIterations = Math.Max(1, config.MaxIterations); for (var iteration = 0; iteration < maxIterations; iteration++) { - var shuffled = nodes.OrderBy(_ => random.Next()).ToList(); + random.Shuffle(nodes); var changed = false; - foreach (var node in shuffled) + var labelWeights = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var node in nodes) { var neighbors = adjacency[node]; if (neighbors.Count == 0) @@ -39,7 +40,6 @@ public static IReadOnlyDictionary AssignLabels( continue; } - var labelWeights = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (neighbor, weight) in neighbors) { if (!labels.TryGetValue(neighbor, out var neighborLabel)) @@ -57,7 +57,7 @@ public static IReadOnlyDictionary AssignLabels( var maxWeight = labelWeights.Values.Max(); var candidates = labelWeights - .Where(pair => Math.Abs(pair.Value - maxWeight) < 1e-6) + .Where(pair => Math.Abs(pair.Value - maxWeight) < double.Epsilon) .Select(pair => pair.Key) .ToList(); @@ -70,6 +70,8 @@ public static IReadOnlyDictionary AssignLabels( labels[node] = chosen; changed = true; } + + labelWeights.Clear(); } if (!changed) diff --git a/tests/ManagedCode.GraphRag.Tests/Community/FastLabelPropagationCommunityDetectorTests.cs b/tests/ManagedCode.GraphRag.Tests/Community/FastLabelPropagationCommunityDetectorTests.cs new file mode 100644 index 000000000..5569139df --- /dev/null +++ b/tests/ManagedCode.GraphRag.Tests/Community/FastLabelPropagationCommunityDetectorTests.cs @@ -0,0 +1,344 @@ +using GraphRag.Community; +using GraphRag.Config; +using GraphRag.Entities; +using GraphRag.Relationships; + +namespace ManagedCode.GraphRag.Tests.Community; + +public sealed class FastLabelPropagationCommunityDetectorTests +{ + #region Helper Methods + + private static EntityRecord CreateEntity(string title) => + new(title, 0, title, "test", null, [], 1, 0, 0, 0); + + private static RelationshipRecord CreateRelationship(string source, string target, double weight = 1.0) => + new($"{source}-{target}", 0, source, target, "related", null, weight, 0, [], false); + + private static ClusterGraphConfig CreateConfig(int seed = 42, int maxIterations = 20) => + new() { Seed = seed, MaxIterations = maxIterations }; + + #endregion + + #region Edge Cases + + [Fact] + public void AssignLabels_EmptyEntities_ReturnsEmptyDictionary() + { + var entities = Array.Empty(); + var relationships = Array.Empty(); + var config = CreateConfig(); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Empty(result); + } + + [Fact] + public void AssignLabels_EmptyRelationships_ReturnsNodesSelfLabeled() + { + var entities = new[] { CreateEntity("A"), CreateEntity("B"), CreateEntity("C") }; + var relationships = Array.Empty(); + var config = CreateConfig(); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Equal(3, result.Count); + Assert.Equal("A", result["A"]); + Assert.Equal("B", result["B"]); + Assert.Equal("C", result["C"]); + } + + [Fact] + public void AssignLabels_SingleNode_ReturnsSelfLabel() + { + var entities = new[] { CreateEntity("Solo") }; + var relationships = Array.Empty(); + var config = CreateConfig(); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Single(result); + Assert.Equal("Solo", result["Solo"]); + } + + [Fact] + public void AssignLabels_NullEntities_ThrowsArgumentNullException() + { + var relationships = Array.Empty(); + var config = CreateConfig(); + + Assert.Throws(() => + FastLabelPropagationCommunityDetector.AssignLabels(null!, relationships, config)); + } + + [Fact] + public void AssignLabels_NullRelationships_ThrowsArgumentNullException() + { + var entities = Array.Empty(); + var config = CreateConfig(); + + Assert.Throws(() => + FastLabelPropagationCommunityDetector.AssignLabels(entities, null!, config)); + } + + [Fact] + public void AssignLabels_NullConfig_ThrowsArgumentNullException() + { + var entities = Array.Empty(); + var relationships = Array.Empty(); + + Assert.Throws(() => + FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, null!)); + } + + #endregion + + #region Deterministic Outcome Tests + + [Fact] + public void AssignLabels_TwoConnectedNodes_AssignsSameLabel() + { + var entities = new[] { CreateEntity("A"), CreateEntity("B") }; + var relationships = new[] { CreateRelationship("A", "B") }; + var config = CreateConfig(seed: 42); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Equal(2, result.Count); + Assert.Equal(result["A"], result["B"]); + } + + [Fact] + public void AssignLabels_ThreeNodeChain_ConvergesToSameLabel() + { + // A -- B -- C (chain topology) + var entities = new[] { CreateEntity("A"), CreateEntity("B"), CreateEntity("C") }; + var relationships = new[] + { + CreateRelationship("A", "B"), + CreateRelationship("B", "C") + }; + var config = CreateConfig(seed: 42); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Equal(3, result.Count); + var uniqueLabels = result.Values.Distinct().ToList(); + Assert.Single(uniqueLabels); + } + + [Fact] + public void AssignLabels_TwoDisconnectedComponents_AssignsDifferentLabels() + { + // Component 1: A -- B + // Component 2: C -- D + var entities = new[] + { + CreateEntity("A"), CreateEntity("B"), + CreateEntity("C"), CreateEntity("D") + }; + var relationships = new[] + { + CreateRelationship("A", "B"), + CreateRelationship("C", "D") + }; + var config = CreateConfig(seed: 42); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Equal(4, result.Count); + + // Nodes in same component should have same label + Assert.Equal(result["A"], result["B"]); + Assert.Equal(result["C"], result["D"]); + + // Nodes in different components should have different labels + Assert.NotEqual(result["A"], result["C"]); + } + + [Fact] + public void AssignLabels_StarTopology_AllNodesGetCenterLabel() + { + // Star: Center connected to Leaf1, Leaf2, Leaf3 + var entities = new[] + { + CreateEntity("Center"), + CreateEntity("Leaf1"), + CreateEntity("Leaf2"), + CreateEntity("Leaf3") + }; + var relationships = new[] + { + CreateRelationship("Center", "Leaf1"), + CreateRelationship("Center", "Leaf2"), + CreateRelationship("Center", "Leaf3") + }; + var config = CreateConfig(seed: 42); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Equal(4, result.Count); + var uniqueLabels = result.Values.Distinct().ToList(); + Assert.Single(uniqueLabels); + } + + [Fact] + public void AssignLabels_WeightedEdges_HigherWeightInfluencesResult() + { + // B connects to A with high weight, C connects to A with low weight + // B -- A -- C + // 10 1 + var entities = new[] + { + CreateEntity("A"), + CreateEntity("B"), + CreateEntity("C") + }; + var relationships = new[] + { + CreateRelationship("A", "B", weight: 10.0), + CreateRelationship("A", "C", weight: 1.0) + }; + var config = CreateConfig(seed: 42); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Equal(3, result.Count); + // All should converge to same community + var uniqueLabels = result.Values.Distinct().ToList(); + Assert.Single(uniqueLabels); + } + + #endregion + + #region Property-Based Invariant Tests + + [Fact] + public void AssignLabels_AllNodesGetLabel() + { + var entities = new[] + { + CreateEntity("Node1"), + CreateEntity("Node2"), + CreateEntity("Node3"), + CreateEntity("Node4"), + CreateEntity("Node5") + }; + var relationships = new[] + { + CreateRelationship("Node1", "Node2"), + CreateRelationship("Node3", "Node4") + }; + var config = CreateConfig(seed: 123); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + foreach (var entity in entities) + { + Assert.True(result.ContainsKey(entity.Title), $"Entity '{entity.Title}' should have a label"); + } + } + + [Fact] + public void AssignLabels_LabelsAreValidNodeIds() + { + var entities = new[] + { + CreateEntity("Alpha"), + CreateEntity("Beta"), + CreateEntity("Gamma"), + CreateEntity("Delta") + }; + var relationships = new[] + { + CreateRelationship("Alpha", "Beta"), + CreateRelationship("Gamma", "Delta") + }; + var config = CreateConfig(seed: 456); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + var validTitles = entities.Select(e => e.Title).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var label in result.Values) + { + Assert.True(validTitles.Contains(label), $"Label '{label}' should be a valid node title"); + } + } + + [Fact] + public void AssignLabels_ConnectedNodesConverge_WithSufficientIterations() + { + // Triangle: A -- B -- C -- A (all connected) + var entities = new[] + { + CreateEntity("A"), + CreateEntity("B"), + CreateEntity("C") + }; + var relationships = new[] + { + CreateRelationship("A", "B"), + CreateRelationship("B", "C"), + CreateRelationship("C", "A") + }; + var config = CreateConfig(seed: 789, maxIterations: 50); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + var uniqueLabels = result.Values.Distinct().ToList(); + Assert.Single(uniqueLabels); + } + + #endregion + + #region Convergence Tests + + [Fact] + public void AssignLabels_ConvergesBeforeMaxIterations_WhenStable() + { + // Simple connected graph that should converge quickly + var entities = new[] { CreateEntity("X"), CreateEntity("Y") }; + var relationships = new[] { CreateRelationship("X", "Y") }; + var config = CreateConfig(seed: 42, maxIterations: 1000); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + Assert.Equal(2, result.Count); + Assert.Equal(result["X"], result["Y"]); + } + + [Fact] + public void AssignLabels_MaxIterationsOne_StillProducesValidOutput() + { + var entities = new[] + { + CreateEntity("P"), + CreateEntity("Q"), + CreateEntity("R") + }; + var relationships = new[] + { + CreateRelationship("P", "Q"), + CreateRelationship("Q", "R") + }; + var config = CreateConfig(seed: 42, maxIterations: 1); + + var result = FastLabelPropagationCommunityDetector.AssignLabels(entities, relationships, config); + + // Even with 1 iteration, all nodes should have labels + Assert.Equal(3, result.Count); + Assert.True(result.ContainsKey("P")); + Assert.True(result.ContainsKey("Q")); + Assert.True(result.ContainsKey("R")); + + // Labels should be valid node titles + var validTitles = new HashSet(["P", "Q", "R"], StringComparer.OrdinalIgnoreCase); + foreach (var label in result.Values) + { + Assert.Contains(label, validTitles); + } + } + + #endregion +}