codegraph-network 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codegraph/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.3.0"
codegraph/graphalgo.py ADDED
@@ -0,0 +1,272 @@
1
+ """Graph-theory algorithms behind the UI lenses. Pure stdlib, deterministic, no I/O.
2
+
3
+ Nodes are any sortable hashables (symbol ids). Graphs are plain dicts so callers can build them from SQL
4
+ rows without an adapter: `edges` is a list of (a, b, weight) for the undirected algorithms and `out`
5
+ is {node: iterable of successors} for the directed ones.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import heapq
10
+ from collections import defaultdict, deque
11
+
12
+ EPS = 1e-12
13
+
14
+
15
+ def _renumber(label: dict, by_size: bool = True) -> dict:
16
+ """Relabel communities 0..k-1, biggest first, so ids are stable and the largest is always 0."""
17
+ size: dict = defaultdict(int)
18
+ for lb in label.values():
19
+ size[lb] += 1
20
+ order = sorted(size, key=lambda lb: (-size[lb], lb) if by_size else (lb,))
21
+ ix = {lb: i for i, lb in enumerate(order)}
22
+ return {n: ix[lb] for n, lb in label.items()}
23
+
24
+
25
+ def louvain(nodes, edges, resolution: float = 1.0, max_levels: int = 10) -> list[dict]:
26
+ """Louvain modularity optimisation (Blondel et al. 2008) with a resolution parameter.
27
+
28
+ Returns one partition per level, finest first: [{node: community}, ...]. Every level maps the
29
+ *original* nodes, so levels[-1] is the coarsest. Nodes without edges are left out. Resolution > 1
30
+ favours smaller communities, < 1 larger ones. Visiting order is sorted, so the result is reproducible.
31
+ """
32
+ adj: dict = defaultdict(lambda: defaultdict(float))
33
+ loops: dict = defaultdict(float)
34
+ for a, b, w in edges:
35
+ if a == b:
36
+ loops[a] += w
37
+ else:
38
+ adj[a][b] += w
39
+ adj[b][a] += w
40
+ cur = sorted(n for n in set(nodes) if adj.get(n) or loops.get(n))
41
+ member = {n: n for n in cur} # original node -> node of the current (aggregated) graph
42
+ levels: list[dict] = []
43
+ for _ in range(max_levels):
44
+ deg = {i: sum(adj[i].values()) + 2 * loops.get(i, 0.0) for i in cur}
45
+ m2 = sum(deg.values())
46
+ if m2 <= 0:
47
+ break
48
+ comm = {i: i for i in cur}
49
+ tot = dict(deg)
50
+ improved = False
51
+ for _sweep in range(50):
52
+ moved = 0
53
+ for i in cur:
54
+ ci, ki = comm[i], deg[i]
55
+ wc: dict = defaultdict(float)
56
+ for j, w in adj[i].items():
57
+ wc[comm[j]] += w
58
+ tot[ci] -= ki
59
+ best = ci
60
+ best_gain = wc.get(ci, 0.0) - resolution * tot[ci] * ki / m2
61
+ for c in sorted(wc):
62
+ g = wc[c] - resolution * tot[c] * ki / m2
63
+ if g > best_gain + EPS:
64
+ best, best_gain = c, g
65
+ tot[best] += ki
66
+ if best != ci:
67
+ comm[i] = best
68
+ moved += 1
69
+ improved = True
70
+ if not moved:
71
+ break
72
+ if not improved:
73
+ break
74
+ comm = _renumber(comm)
75
+ member = {o: comm[member[o]] for o in member}
76
+ levels.append(dict(member))
77
+ k = len(set(comm.values()))
78
+ if k == len(cur):
79
+ break
80
+ nadj: dict = defaultdict(lambda: defaultdict(float))
81
+ nloops: dict = defaultdict(float)
82
+ for i in cur:
83
+ ci = comm[i]
84
+ nloops[ci] += loops.get(i, 0.0)
85
+ for j, w in adj[i].items():
86
+ cj = comm[j]
87
+ if ci == cj:
88
+ nloops[ci] += w / 2 # an internal edge is seen from both ends
89
+ else:
90
+ nadj[ci][cj] += w
91
+ adj, loops, cur = nadj, nloops, list(range(k))
92
+ return levels
93
+
94
+
95
+ def modularity(part: dict, edges, resolution: float = 1.0) -> float:
96
+ """Newman modularity of a partition; used to compare runs and in tests."""
97
+ deg: dict = defaultdict(float)
98
+ inside = 0.0
99
+ total = 0.0
100
+ for a, b, w in edges:
101
+ total += w
102
+ deg[a] += w
103
+ deg[b] += w
104
+ if part.get(a) is not None and part.get(a) == part.get(b):
105
+ inside += w
106
+ if total <= 0:
107
+ return 0.0
108
+ tot: dict = defaultdict(float)
109
+ for n, d in deg.items():
110
+ if n in part:
111
+ tot[part[n]] += d
112
+ return inside / total - resolution * sum(t * t for t in tot.values()) / (4 * total * total)
113
+
114
+
115
+ def fold_small(part: dict, edges, min_size: int = 3) -> dict:
116
+ """Merge communities smaller than `min_size` into the neighbour they are most connected to."""
117
+ adj: dict = defaultdict(lambda: defaultdict(float))
118
+ for a, b, w in edges:
119
+ if a != b:
120
+ adj[a][b] += w
121
+ adj[b][a] += w
122
+ part = dict(part)
123
+ size: dict = defaultdict(int)
124
+ for c in part.values():
125
+ size[c] += 1
126
+ for n in sorted(part):
127
+ if size[part[n]] >= min_size:
128
+ continue
129
+ votes: dict = defaultdict(float)
130
+ for m, w in adj.get(n, {}).items():
131
+ if m in part and size[part[m]] >= min_size:
132
+ votes[part[m]] += w
133
+ if votes:
134
+ size[part[n]] -= 1
135
+ part[n] = min(votes, key=lambda c: (-votes[c], c))
136
+ size[part[n]] += 1
137
+ return _renumber(part)
138
+
139
+
140
+ def sccs(nodes, out) -> list[list]:
141
+ """Strongly connected components (iterative Tarjan). `out` maps node -> iterable of successors."""
142
+ index: dict = {}
143
+ low: dict = {}
144
+ on: set = set()
145
+ stack: list = []
146
+ comps: list = []
147
+ n = 0
148
+ for root in sorted(nodes):
149
+ if root in index:
150
+ continue
151
+ index[root] = low[root] = n
152
+ n += 1
153
+ stack.append(root)
154
+ on.add(root)
155
+ work = [(root, iter(sorted(out.get(root, ()))))]
156
+ while work:
157
+ v, it = work[-1]
158
+ for w in it:
159
+ if w not in index:
160
+ index[w] = low[w] = n
161
+ n += 1
162
+ stack.append(w)
163
+ on.add(w)
164
+ work.append((w, iter(sorted(out.get(w, ())))))
165
+ break
166
+ if w in on:
167
+ low[v] = min(low[v], index[w])
168
+ else:
169
+ work.pop()
170
+ if work:
171
+ u = work[-1][0]
172
+ low[u] = min(low[u], low[v])
173
+ if low[v] == index[v]:
174
+ comp = []
175
+ while True:
176
+ w = stack.pop()
177
+ on.discard(w)
178
+ comp.append(w)
179
+ if w == v:
180
+ break
181
+ comps.append(comp)
182
+ return comps
183
+
184
+
185
+ def betweenness(adj, pivots: int | None = None) -> dict:
186
+ """Betweenness centrality (Brandes) on an unweighted undirected graph {node: iterable of neighbours}.
187
+
188
+ With `pivots` set, only that many evenly spaced sources are used and the sum is scaled up: the
189
+ standard sampling estimate, which keeps big graphs affordable while ranking hubs the same way.
190
+ """
191
+ nodes = sorted(adj)
192
+ nbr = {v: sorted(set(adj[v]) - {v}) for v in nodes}
193
+ sources = nodes
194
+ if pivots and pivots < len(nodes):
195
+ step = len(nodes) / pivots
196
+ sources = [nodes[int(i * step)] for i in range(pivots)]
197
+ bc = dict.fromkeys(nodes, 0.0)
198
+ for s in sources:
199
+ order = []
200
+ preds: dict = defaultdict(list)
201
+ sigma = dict.fromkeys(nodes, 0)
202
+ dist = dict.fromkeys(nodes, -1)
203
+ sigma[s], dist[s] = 1, 0
204
+ q = deque([s])
205
+ while q:
206
+ v = q.popleft()
207
+ order.append(v)
208
+ for w in nbr[v]:
209
+ if dist[w] < 0:
210
+ dist[w] = dist[v] + 1
211
+ q.append(w)
212
+ if dist[w] == dist[v] + 1:
213
+ sigma[w] += sigma[v]
214
+ preds[w].append(v)
215
+ delta = dict.fromkeys(nodes, 0.0)
216
+ for w in reversed(order):
217
+ for v in preds[w]:
218
+ delta[v] += sigma[v] / sigma[w] * (1 + delta[w])
219
+ if w != s:
220
+ bc[w] += delta[w]
221
+ scale = len(nodes) / len(sources) if sources else 1.0
222
+ return {v: b * scale / 2 for v, b in bc.items()} # undirected: each pair was counted twice
223
+
224
+
225
+ def kcore(adj) -> dict:
226
+ """Core number of every node (Batagelj-Zaversnik, heap variant): the largest k such that the node
227
+ survives repeatedly peeling off nodes with fewer than k neighbours."""
228
+ nbr = {v: set(ns) - {v} for v, ns in adj.items()}
229
+ deg = {v: len(ns) for v, ns in nbr.items()}
230
+ heap = [(d, v) for v, d in deg.items()]
231
+ heapq.heapify(heap)
232
+ core: dict = {}
233
+ k = 0
234
+ while heap:
235
+ d, v = heapq.heappop(heap)
236
+ if v in core or d != deg[v]:
237
+ continue
238
+ k = max(k, d)
239
+ core[v] = k
240
+ for u in nbr[v]:
241
+ if u not in core:
242
+ deg[u] -= 1
243
+ heapq.heappush(heap, (deg[u], u))
244
+ return core
245
+
246
+
247
+ def shortest_path(out, inn, a, b, directed_first: bool = True) -> list:
248
+ """Fewest-hop path a -> b. Follows edge direction first (a calls ... calls b) and only if that fails
249
+ treats the graph as undirected. Returns [] when b is unreachable."""
250
+ def bfs(step):
251
+ prev = {a: None}
252
+ q = deque([a])
253
+ while q:
254
+ v = q.popleft()
255
+ if v == b:
256
+ path = []
257
+ while v is not None:
258
+ path.append(v)
259
+ v = prev[v]
260
+ return path[::-1]
261
+ for w in step(v):
262
+ if w not in prev:
263
+ prev[w] = v
264
+ q.append(w)
265
+ return []
266
+ if a == b:
267
+ return [a]
268
+ if directed_first:
269
+ p = bfs(lambda v: sorted(out.get(v, ())))
270
+ if p:
271
+ return p
272
+ return bfs(lambda v: sorted(set(out.get(v, ())) | set(inn.get(v, ()))))