diff --git a/src/Core/Graph.fs b/src/Core/Graph.fs index 7add1f8c..64f4ab3c 100644 --- a/src/Core/Graph.fs +++ b/src/Core/Graph.fs @@ -358,3 +358,93 @@ module Graph = if community i = community j then q <- q + (sym.[i, j] - (k.[i] * k.[j]) / twoM) Some (q / twoM) + + /// **Label propagation community detector.** + /// + /// A simple, non-spectral community detection algorithm. Each + /// node starts in its own community. Each iteration, every + /// node adopts the label that appears with greatest weighted + /// frequency among its neighbors (ties broken by lowest + /// community id for determinism). The algorithm stops when + /// no node changes label in a pass, or when `maxIterations` + /// is reached. + /// + /// Returns `Map<'N, int>` — node → community label. Empty + /// map on empty graph. + /// + /// **Trade-offs (documented to calibrate expectations):** + /// * Fast: O(iterations × edges), works without dense matrix. + /// * Quality: below Louvain / spectral methods for complex + /// structures, but catches obvious dense cliques reliably — + /// exactly the trivial-cartel-detect case. + /// * Determinism: tie-break by lowest community id (stable + /// across runs given same input). + /// * NOT a replacement for Louvain; a dependency-free first + /// pass. Future graduation: `Graph.louvain` using the + /// full modularity-optimizing procedure. + /// + /// Provenance: 12th ferry §5 + 13th ferry §2 "community + /// detection" + 14th ferry alert row "Modularity Q jump > + /// 0.1 or Q > 0.4 (community-detection-based)". + let labelPropagation + (maxIterations: int) + (g: Graph<'N>) + : Map<'N, int> = + let nodeList = nodes g |> Set.toList + let n = nodeList.Length + if n = 0 then Map.empty + else + let nodeArr = List.toArray nodeList + let idx = + nodeList + |> List.mapi (fun i node -> node, i) + |> Map.ofList + // Initial labels: each node in its own community + let labels = Array.init n id + // Pre-compute neighbor-list (combined in+out, weighted + // sum). For cartel detection we symmetrize. + let neighbors = Array.init n (fun _ -> ResizeArray()) + let span = g.Edges.AsSpan() + for k in 0 .. span.Length - 1 do + let entry = span.[k] + let (s, t) = entry.Key + let si = idx.[s] + let ti = idx.[t] + if entry.Weight <> 0L && si <> ti then + neighbors.[si].Add(ti, entry.Weight) + neighbors.[ti].Add(si, entry.Weight) + let mutable iter = 0 + let mutable stable = false + while not stable && iter < maxIterations do + stable <- true + // Iterate nodes in fixed order for determinism + for i in 0 .. n - 1 do + let nbrs = neighbors.[i] + if nbrs.Count > 0 then + // Count weighted votes per label + let votes = System.Collections.Generic.Dictionary() + for (ni, w) in nbrs do + let lbl = labels.[ni] + let cur = + match votes.TryGetValue(lbl) with + | true, v -> v + | false, _ -> 0L + votes.[lbl] <- cur + (if w > 0L then w else 0L) + // Pick label with highest vote; tie-break by + // lowest id for determinism + let mutable bestLbl = labels.[i] + let mutable bestVote = -1L + for kvp in votes do + if kvp.Value > bestVote || + (kvp.Value = bestVote && kvp.Key < bestLbl) then + bestLbl <- kvp.Key + bestVote <- kvp.Value + if labels.[i] <> bestLbl then + labels.[i] <- bestLbl + stable <- false + iter <- iter + 1 + // Build result map + let mutable result = Map.empty + for i in 0 .. n - 1 do + result <- Map.add nodeArr.[i] labels.[i] result + result diff --git a/tests/Tests.FSharp/Algebra/Graph.Tests.fs b/tests/Tests.FSharp/Algebra/Graph.Tests.fs index 5b7ce808..e5ea473c 100644 --- a/tests/Tests.FSharp/Algebra/Graph.Tests.fs +++ b/tests/Tests.FSharp/Algebra/Graph.Tests.fs @@ -293,3 +293,45 @@ let ``modularityScore for single-community is 0`` () = let p = Map.ofList [ (1,0); (2,0); (3,0) ] let q = Graph.modularityScore p g |> Option.defaultValue nan abs q |> should (be lessThan) 1e-9 + + +// ─── labelPropagation ───────── + +[] +let ``labelPropagation returns empty map for empty graph`` () = + (Graph.empty : Graph) |> Graph.labelPropagation 10 |> Map.count |> should equal 0 + +[] +let ``labelPropagation converges two dense cliques to two labels`` () = + // Two K3 cliques bridged by one thin edge. Label propagation + // should settle with nodes {1,2,3} sharing one label and + // nodes {4,5,6} sharing another. + let edges = [ + (1, 2, 10L); (2, 1, 10L); (2, 3, 10L); (3, 2, 10L); (3, 1, 10L); (1, 3, 10L) + (4, 5, 10L); (5, 4, 10L); (5, 6, 10L); (6, 5, 10L); (6, 4, 10L); (4, 6, 10L) + (3, 4, 1L); (4, 3, 1L) + ] + let g = Graph.fromEdgeSeq edges + let partition = Graph.labelPropagation 50 g + let labelA = partition.[1] + let labelB = partition.[4] + // Both cliques share a label within themselves + partition.[2] |> should equal labelA + partition.[3] |> should equal labelA + partition.[5] |> should equal labelB + partition.[6] |> should equal labelB + +[] +let ``labelPropagation produces partition consumable by modularityScore`` () = + // The composition that enables a full cartel detector: LP + // produces a partition, modularityScore evaluates it. High + // modularity means LP found real community structure. + let edges = [ + (1, 2, 10L); (2, 1, 10L); (2, 3, 10L); (3, 2, 10L); (3, 1, 10L); (1, 3, 10L) + (4, 5, 10L); (5, 4, 10L); (5, 6, 10L); (6, 5, 10L); (6, 4, 10L); (4, 6, 10L) + (3, 4, 1L); (4, 3, 1L) + ] + let g = Graph.fromEdgeSeq edges + let partition = Graph.labelPropagation 50 g + let q = Graph.modularityScore partition g |> Option.defaultValue 0.0 + q |> should (be greaterThan) 0.3