Coverage for src/lilbee/retrieval/concepts/community.py: 100%
36 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Concept community dataclass and PMI / Leiden helpers."""
3from __future__ import annotations
5import math
6from collections import Counter
7from dataclasses import dataclass
8from typing import Any, NamedTuple
10_MIN_LEIDEN_WEIGHT = 0.01
11# Fixed seed so Leiden returns the same communities for the same edge set;
12# the algorithm is randomized and would otherwise drift between runs.
13_LEIDEN_SEED = 42
16@dataclass
17class Community:
18 """A cluster of related concepts from Leiden partitioning."""
20 cluster_id: int
21 size: int
22 concepts: list[str]
25def _compute_pmi(
26 cooccurrences: Counter[tuple[str, str]],
27 concept_counts: Counter[str],
28 total_chunks: int,
29) -> dict[tuple[str, str], float]:
30 """Compute PPMI (Positive PMI) weights for concept co-occurrence pairs.
31 Based on Church & Hanks 1990, "Word Association Norms, Mutual Information,
32 and Lexicography." Pairs with non-positive PMI (co-occurring at or below
33 chance) are dropped entirely: a stored 0.0 would later be floored to a
34 positive Leiden weight, turning anti-correlation into attraction.
35 """
36 pmi: dict[tuple[str, str], float] = {}
37 for (a, b), count in cooccurrences.items():
38 p_a = concept_counts[a] / total_chunks
39 p_b = concept_counts[b] / total_chunks
40 if p_a == 0 or p_b == 0:
41 continue
42 p_ab = count / total_chunks
43 value = math.log2(p_ab / (p_a * p_b))
44 if value > 0:
45 pmi[(a, b)] = value
46 return pmi
49class _LeidenResult(NamedTuple):
50 """Leiden output: node -> community id, and node -> incident-edge count.
52 ``degrees`` is an unweighted count of edges incident to each node (edge
53 weights are not summed), which is what label ranking consumes.
54 """
56 partition: dict[str, int]
57 degrees: dict[str, int]
60def _leiden_partition(
61 edge_rows: list[dict[str, Any]],
62) -> _LeidenResult:
63 """Run Leiden clustering on edge rows.
64 Uses graspologic-native's Rust implementation (Traag et al. 2019,
65 "From Louvain to Leiden: guaranteeing well-connected communities").
66 """
67 from graspologic_native import leiden
69 edges: list[tuple[str, str, float]] = [
70 (row["source"], row["target"], max(_MIN_LEIDEN_WEIGHT, row["weight"])) for row in edge_rows
71 ]
72 _modularity, partition = leiden(edges=edges, seed=_LEIDEN_SEED) # type: ignore[call-arg]
74 degree_map: Counter[str] = Counter()
75 for row in edge_rows:
76 degree_map[row["source"]] += 1
77 degree_map[row["target"]] += 1
78 return _LeidenResult(partition, dict(degree_map))