Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/Core/Graph.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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)".
Comment on lines +386 to +388
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment’s provenance references (“12th ferry §5”, “13th ferry §2”, “14th ferry alert row …”) don’t appear to resolve to any docs in-tree (searching docs/**/*.md only finds generic ferry mentions in the ADR, not these sections). Please either link to a concrete file path/anchor that exists in the repo, or remove/adjust the references so readers can actually follow them.

Suggested change
/// 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)".
/// Intended as a simple first-pass community-detection
/// primitive for obvious dense-clique cases and modularity-
/// oriented alerting heuristics.

Copilot uses AI. Check for mistakes.
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<int * int64>())
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
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Graph<'N> supports signed edge weights (retraction-native), but labelPropagation currently adds neighbors even when entry.Weight is negative. Because negative weights are later treated as 0 votes, nodes connected only via negative edges can still change label arbitrarily (all votes tie at 0, then tie-break picks lowest label). Consider filtering neighbors to entry.Weight > 0L (or otherwise handling signed weights explicitly) so anti-edges don’t influence community assignment unexpectedly.

Suggested change
if entry.Weight <> 0L && si <> ti then
if entry.Weight > 0L && si <> ti then

Copilot uses AI. Check for mistakes.
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<int, int64>()
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
42 changes: 42 additions & 0 deletions tests/Tests.FSharp/Algebra/Graph.Tests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────

[<Fact>]
let ``labelPropagation returns empty map for empty graph`` () =
(Graph.empty : Graph<int>) |> Graph.labelPropagation 10 |> Map.count |> should equal 0

[<Fact>]
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
Comment on lines +316 to +322
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test claims “two labels” but never asserts that labelA and labelB differ. As written, it would still pass if label propagation collapses the whole graph into a single community. Add an explicit assertion that labelA <> labelB (or equivalent) to ensure the intended behavior is actually verified.

Copilot uses AI. Check for mistakes.

[<Fact>]
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
Loading