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 +1 -1
- codegraph/graphalgo.py +272 -0
- codegraph/ui.html +449 -180
- codegraph/ui.py +454 -118
- {codegraph_network-0.2.0.dist-info → codegraph_network-0.3.0.dist-info}/METADATA +27 -6
- {codegraph_network-0.2.0.dist-info → codegraph_network-0.3.0.dist-info}/RECORD +10 -9
- {codegraph_network-0.2.0.dist-info → codegraph_network-0.3.0.dist-info}/WHEEL +0 -0
- {codegraph_network-0.2.0.dist-info → codegraph_network-0.3.0.dist-info}/entry_points.txt +0 -0
- {codegraph_network-0.2.0.dist-info → codegraph_network-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_network-0.2.0.dist-info → codegraph_network-0.3.0.dist-info}/top_level.txt +0 -0
codegraph/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "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, ()))))
|