codebase-explorer 2.2.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.
- cbe/__init__.py +9 -0
- cbe/__main__.py +4 -0
- cbe/aggregation.py +183 -0
- cbe/api_completion.py +410 -0
- cbe/batching.py +283 -0
- cbe/catalog_query.py +376 -0
- cbe/checks.py +390 -0
- cbe/cli.py +434 -0
- cbe/concrete_facts.py +321 -0
- cbe/data/LICENSE.tiktoken +30 -0
- cbe/data/o200k_base.tiktoken +199998 -0
- cbe/declarations.py +537 -0
- cbe/docs.py +929 -0
- cbe/generate.py +316 -0
- cbe/grammars.py +741 -0
- cbe/graph.py +857 -0
- cbe/graph_backends/__init__.py +43 -0
- cbe/graph_backends/_calls.py +262 -0
- cbe/graph_backends/_paths.py +42 -0
- cbe/graph_backends/identity.py +93 -0
- cbe/graph_backends/imports.py +256 -0
- cbe/graph_backends/joern.py +1021 -0
- cbe/graph_backends/resolution.py +288 -0
- cbe/graph_backends/toolchain.py +396 -0
- cbe/grounding.py +57 -0
- cbe/host_run.py +1752 -0
- cbe/model_settings.py +80 -0
- cbe/projection.py +305 -0
- cbe/prompts.py +316 -0
- cbe/records.py +200 -0
- cbe/skeleton.py +474 -0
- cbe/source_context.py +709 -0
- cbe/symbols.py +136 -0
- cbe/token_budget.py +68 -0
- cbe/treename.py +398 -0
- cbe/usage.py +300 -0
- cbe_install/__init__.py +11 -0
- cbe_install/cli.py +434 -0
- cbe_install/resources/agents/cbe-writer.md +16 -0
- cbe_install/resources/codebase-explorer/SKILL.md +101 -0
- cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
- cbe_install/resources/codebase-explorer/references/codex.md +37 -0
- codebase_explorer-2.2.0.dist-info/METADATA +233 -0
- codebase_explorer-2.2.0.dist-info/RECORD +47 -0
- codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
- codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
- codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
cbe/skeleton.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Program-computed module skeleton: units + edges in, a tree of modules out.
|
|
2
|
+
|
|
3
|
+
Pure function. It calls no model, reads and writes no file, and knows nothing
|
|
4
|
+
about the task state machine. A model later only names the modules (``treename``).
|
|
5
|
+
|
|
6
|
+
How the tree is computed
|
|
7
|
+
------------------------
|
|
8
|
+
1. One undirected weighted graph. Nodes are units plus "anchors" (one per file,
|
|
9
|
+
one per directory) that only pull units of the same file or directory
|
|
10
|
+
together; anchors are dropped from the result.
|
|
11
|
+
* dependency edges: ``1 + ln(count)``; model-inferred relations use
|
|
12
|
+
``Weights.relation``; an edge into a unit is divided by
|
|
13
|
+
``ln(e + in_degree(unit))`` so a helper that everybody calls does not glue
|
|
14
|
+
everybody together;
|
|
15
|
+
* position: unit - file ``w_file``, file - directory ``w_dir``,
|
|
16
|
+
directory - parent directory ``w_dir / 2``;
|
|
17
|
+
* wording: TF-IDF cosine over summary words and identifier words; each unit
|
|
18
|
+
keeps its 5 best neighbours whose cosine is at least ``text_floor``;
|
|
19
|
+
weight ``w_text * cosine``.
|
|
20
|
+
2. Leaf modules: Louvain with a fixed seed; a community above ``grain[1]`` units
|
|
21
|
+
is split again inside its own subgraph; a leaf below ``MIN_LEAF`` units joins
|
|
22
|
+
the neighbour it is tied to most strongly. Leaves that still miss the grain
|
|
23
|
+
carry a ``flag`` that says why.
|
|
24
|
+
3. Upper levels: modules become points, anchors stay, Louvain runs again, repeat
|
|
25
|
+
until at most ``TOP_MAX`` nodes are left. A group of one module is not a node.
|
|
26
|
+
|
|
27
|
+
Same input and seed give byte-identical output. Node ids are a hash of the
|
|
28
|
+
sorted member unit ids of the node's subtree.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import math
|
|
34
|
+
import posixpath
|
|
35
|
+
import re
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
|
|
39
|
+
import networkx as nx
|
|
40
|
+
from networkx.algorithms.community import louvain_communities
|
|
41
|
+
|
|
42
|
+
GRAIN = (5, 30) # units per leaf module
|
|
43
|
+
MIN_LEAF = 3 # a leaf with fewer units always tries to join a neighbour
|
|
44
|
+
TOP_MAX = 9 # stop stacking levels at this many top nodes
|
|
45
|
+
TEXT_NEIGHBORS = 5
|
|
46
|
+
RESOLUTIONS_UP = (1.0, 2.0, 4.0, 8.0, 16.0) # tried in order to split an oversized community
|
|
47
|
+
COARSEN_SHRINK = 0.6 # each level above the leaves keeps at most this share of the nodes
|
|
48
|
+
COARSEN_LADDER = (3.0, 2.0, 1.5, 1.0, 0.7, 0.5, 0.35, 0.25, 0.15, 0.08)
|
|
49
|
+
POOL_CHUNK = 10 # units per leaf when unrelated units are packed in path order
|
|
50
|
+
_ANCHOR_FILE = "\x00file:"
|
|
51
|
+
_ANCHOR_DIR = "\x00dir:"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Weights:
|
|
56
|
+
"""Defaults picked on ruby_llm and gson (evaluation/tree/grid.py); not user configuration."""
|
|
57
|
+
w_file: float = 1.0
|
|
58
|
+
w_dir: float = 0.5
|
|
59
|
+
w_text: float = 0.5
|
|
60
|
+
text_floor: float = 0.25
|
|
61
|
+
relation: float = 0.5
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------- graph ----
|
|
65
|
+
|
|
66
|
+
_STOP = frozenset(
|
|
67
|
+
"a an and are as at be but by for from has have if in into is it its of on or that the their then "
|
|
68
|
+
"this to was when which while with within without not can via per each also than so such these "
|
|
69
|
+
"those they them used use uses using returns return only any all one two both other over under "
|
|
70
|
+
"after before inside outside".split())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def word_tokens(text: str) -> list[str]:
|
|
74
|
+
text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
|
|
75
|
+
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", text)
|
|
76
|
+
out = []
|
|
77
|
+
for token in re.findall(r"[A-Za-z]+", text):
|
|
78
|
+
token = token.lower()
|
|
79
|
+
if len(token) < 3 or token in _STOP:
|
|
80
|
+
continue
|
|
81
|
+
if len(token) > 3 and token.endswith("s") and not token.endswith("ss"):
|
|
82
|
+
token = token[:-1]
|
|
83
|
+
out.append(token)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def symbol_words(ident: str) -> str:
|
|
88
|
+
"""Name part of ``path::kind::Name::offset`` ids; the slug of ``grp:`` ids."""
|
|
89
|
+
parts = ident.split("::")
|
|
90
|
+
if len(parts) >= 3:
|
|
91
|
+
return parts[2]
|
|
92
|
+
return re.sub(r"^[a-z]+:", "", ident).replace("-", " ").replace("_", " ")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unit_paths(unit: dict) -> list[str]:
|
|
96
|
+
seen: list[str] = []
|
|
97
|
+
for rng in unit.get("ranges") or []:
|
|
98
|
+
path = rng.get("path")
|
|
99
|
+
if path and path not in seen:
|
|
100
|
+
seen.append(path)
|
|
101
|
+
if not seen and (unit.get("primary_range") or {}).get("path"):
|
|
102
|
+
seen.append(unit["primary_range"]["path"])
|
|
103
|
+
return seen
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _unit_doc(uid: str, unit: dict) -> list[str]:
|
|
107
|
+
words = word_tokens(unit.get("summary") or "")
|
|
108
|
+
words += word_tokens(symbol_words(uid))
|
|
109
|
+
for member in unit.get("folded_members") or []:
|
|
110
|
+
if isinstance(member, str):
|
|
111
|
+
words += word_tokens(symbol_words(member))
|
|
112
|
+
for path in unit_paths(unit):
|
|
113
|
+
words += word_tokens(path.replace("/", " ").replace(".", " ").replace("_", " "))
|
|
114
|
+
return words
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _text_edges(units: dict, uids: list[str], floor: float) -> dict[tuple[str, str], float]:
|
|
118
|
+
"""Cosine of TF-IDF vectors; each unit keeps its best TEXT_NEIGHBORS at or above floor."""
|
|
119
|
+
docs = {uid: _unit_doc(uid, units[uid]) for uid in uids}
|
|
120
|
+
df: dict[str, int] = defaultdict(int)
|
|
121
|
+
for words in docs.values():
|
|
122
|
+
for word in set(words):
|
|
123
|
+
df[word] += 1
|
|
124
|
+
n = len(uids)
|
|
125
|
+
vectors: dict[str, dict[str, float]] = {}
|
|
126
|
+
for uid, words in docs.items():
|
|
127
|
+
tf: dict[str, int] = defaultdict(int)
|
|
128
|
+
for word in words:
|
|
129
|
+
tf[word] += 1
|
|
130
|
+
vec = {w: (1 + math.log(c)) * (math.log((1 + n) / (1 + df[w])) + 1) for w, c in tf.items()}
|
|
131
|
+
norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0
|
|
132
|
+
vectors[uid] = {w: v / norm for w, v in vec.items()}
|
|
133
|
+
postings: dict[str, list[str]] = defaultdict(list)
|
|
134
|
+
for uid in uids:
|
|
135
|
+
for word in vectors[uid]:
|
|
136
|
+
postings[word].append(uid)
|
|
137
|
+
out: dict[tuple[str, str], float] = {}
|
|
138
|
+
for uid in uids:
|
|
139
|
+
dots: dict[str, float] = defaultdict(float)
|
|
140
|
+
vec = vectors[uid]
|
|
141
|
+
for word, value in vec.items():
|
|
142
|
+
if len(postings[word]) > 0.5 * n and n > 20: # words found nearly everywhere carry no signal
|
|
143
|
+
continue
|
|
144
|
+
for other in postings[word]:
|
|
145
|
+
if other != uid:
|
|
146
|
+
dots[other] += value * vectors[other][word]
|
|
147
|
+
best = sorted(((c, o) for o, c in dots.items() if c >= floor), key=lambda t: (-t[0], t[1]))
|
|
148
|
+
for cos, other in best[:TEXT_NEIGHBORS]:
|
|
149
|
+
key = (uid, other) if uid < other else (other, uid)
|
|
150
|
+
out[key] = max(out.get(key, 0.0), cos)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _anchor_chain(path: str) -> list[str]:
|
|
155
|
+
"""Directory anchors from the file's directory up to (not including) the root."""
|
|
156
|
+
chain, d = [], posixpath.dirname(path)
|
|
157
|
+
while d and d != ".":
|
|
158
|
+
chain.append(d)
|
|
159
|
+
d = posixpath.dirname(d)
|
|
160
|
+
return chain
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_graph(units: dict, edges: list[dict], w: Weights):
|
|
164
|
+
uids = sorted(units)
|
|
165
|
+
G = nx.Graph()
|
|
166
|
+
G.add_nodes_from(uids)
|
|
167
|
+
indeg: dict[str, set] = defaultdict(set)
|
|
168
|
+
for e in edges:
|
|
169
|
+
a, b = e.get("from"), e.get("to")
|
|
170
|
+
if a in units and b in units and a != b:
|
|
171
|
+
indeg[b].add(a)
|
|
172
|
+
acc: dict[tuple[str, str], float] = defaultdict(float)
|
|
173
|
+
for e in sorted(edges, key=lambda e: (str(e.get("from")), str(e.get("to")), str(e.get("kind")))):
|
|
174
|
+
a, b = e.get("from"), e.get("to")
|
|
175
|
+
if a not in units or b not in units or a == b:
|
|
176
|
+
continue
|
|
177
|
+
base = w.relation if e.get("kind") == "relation" else 1.0
|
|
178
|
+
weight = base * (1 + math.log(max(int(e.get("count") or 1), 1))) / math.log(math.e + len(indeg[b]))
|
|
179
|
+
if weight > 0:
|
|
180
|
+
acc[(a, b) if a < b else (b, a)] += weight
|
|
181
|
+
dep = dict(acc)
|
|
182
|
+
for (a, b), weight in dep.items():
|
|
183
|
+
G.add_edge(a, b, weight=weight)
|
|
184
|
+
text = {}
|
|
185
|
+
if w.w_text > 0 and len(uids) > 1:
|
|
186
|
+
text = {k: w.w_text * c for k, c in _text_edges(units, uids, w.text_floor).items()}
|
|
187
|
+
for (a, b), weight in text.items():
|
|
188
|
+
G.add_edge(a, b, weight=G[a][b]["weight"] + weight if G.has_edge(a, b) else weight)
|
|
189
|
+
anchors_of: dict[str, list[str]] = {}
|
|
190
|
+
profile: dict[str, dict[str, float]] = {}
|
|
191
|
+
for uid in uids:
|
|
192
|
+
files = unit_paths(units[uid])
|
|
193
|
+
anchors: list[str] = []
|
|
194
|
+
prof: dict[str, float] = defaultdict(float)
|
|
195
|
+
for path in files:
|
|
196
|
+
share = 1.0 / len(files)
|
|
197
|
+
if w.w_file > 0:
|
|
198
|
+
fa = _ANCHOR_FILE + path
|
|
199
|
+
G.add_edge(uid, fa, weight=G[uid][fa]["weight"] + w.w_file * share if G.has_edge(uid, fa) else w.w_file * share)
|
|
200
|
+
anchors.append(fa)
|
|
201
|
+
prof[fa] += w.w_file * share
|
|
202
|
+
if w.w_dir > 0:
|
|
203
|
+
chain = _anchor_chain(path)
|
|
204
|
+
prev = fa
|
|
205
|
+
for k, d in enumerate(chain):
|
|
206
|
+
da = _ANCHOR_DIR + d
|
|
207
|
+
weight = w.w_dir if k == 0 else w.w_dir / 2
|
|
208
|
+
if not G.has_edge(prev, da):
|
|
209
|
+
G.add_edge(prev, da, weight=weight)
|
|
210
|
+
anchors.append(da)
|
|
211
|
+
prof[da] += w.w_dir / (2 ** k) * share
|
|
212
|
+
prev = da
|
|
213
|
+
anchors_of[uid] = anchors
|
|
214
|
+
profile[uid] = dict(prof)
|
|
215
|
+
return G, uids, dep, text, anchors_of, profile
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------ community ----
|
|
219
|
+
|
|
220
|
+
def _louvain(G: nx.Graph, resolution: float, seed: int) -> list[list[str]]:
|
|
221
|
+
comms = louvain_communities(G, weight="weight", resolution=resolution, seed=seed)
|
|
222
|
+
return sorted((sorted(c) for c in comms), key=lambda c: c[0])
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _is_anchor(name: str) -> bool:
|
|
226
|
+
return name.startswith("\x00")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _subgraph(G: nx.Graph, members: list[str], anchors_of: dict) -> nx.Graph:
|
|
230
|
+
names = set(members)
|
|
231
|
+
for uid in members:
|
|
232
|
+
names.update(anchors_of.get(uid, ()))
|
|
233
|
+
# Not G.subgraph(names): a view of a small node set iterates that set, in hash order, and
|
|
234
|
+
# Louvain's result depends on node order.
|
|
235
|
+
H = nx.Graph()
|
|
236
|
+
H.add_nodes_from(n for n in G if n in names)
|
|
237
|
+
for u in H:
|
|
238
|
+
for v, data in G[u].items():
|
|
239
|
+
if v in names and not H.has_edge(u, v):
|
|
240
|
+
H.add_edge(u, v, weight=data["weight"])
|
|
241
|
+
return H
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _split(G, members: list[str], anchors_of, grain, seed) -> list[tuple[list[str], str | None]]:
|
|
245
|
+
"""Split ``members`` until every part has at most grain[1] units."""
|
|
246
|
+
if len(members) <= grain[1]:
|
|
247
|
+
return [(members, None)]
|
|
248
|
+
sub = _subgraph(G, members, anchors_of)
|
|
249
|
+
parts: list[list[str]] = []
|
|
250
|
+
for r in RESOLUTIONS_UP:
|
|
251
|
+
parts = [[n for n in c if not _is_anchor(n)] for c in _louvain(sub, r, seed)]
|
|
252
|
+
parts = [p for p in parts if p]
|
|
253
|
+
if len(parts) >= 2:
|
|
254
|
+
break
|
|
255
|
+
if len(parts) < 2:
|
|
256
|
+
return [(members, f"Louvain does not split these units at resolution up to {RESOLUTIONS_UP[-1]:g}")]
|
|
257
|
+
out: list[tuple[list[str], str | None]] = []
|
|
258
|
+
for part in parts:
|
|
259
|
+
out.extend(_split(G, part, anchors_of, grain, seed))
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _leaf_ties(members: set[str], owner: dict, dep_text: nx.Graph) -> dict[int, float]:
|
|
264
|
+
"""Tie strength from a module to every other module (unit edges + shared files and directories)."""
|
|
265
|
+
ties: dict[int, float] = defaultdict(float)
|
|
266
|
+
for u in sorted(members):
|
|
267
|
+
if u in dep_text:
|
|
268
|
+
for v, data in dep_text[u].items():
|
|
269
|
+
o = owner.get(v)
|
|
270
|
+
if o is not None and v not in members:
|
|
271
|
+
ties[o] += data["weight"]
|
|
272
|
+
return ties
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _profile_of(members, profile) -> dict[str, float]:
|
|
276
|
+
total: dict[str, float] = defaultdict(float)
|
|
277
|
+
for u in sorted(members):
|
|
278
|
+
for a, v in profile.get(u, {}).items():
|
|
279
|
+
total[a] += v
|
|
280
|
+
return total
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _shape_leaves(parts, dep_text, profile, units, grain):
|
|
284
|
+
"""Merge small leaves into their tightest neighbour; pack unrelated stragglers in path order."""
|
|
285
|
+
leaves: dict[int, dict] = {}
|
|
286
|
+
for i, (members, flag) in enumerate(parts):
|
|
287
|
+
leaves[i] = {"m": set(members), "flag": None, "why": flag}
|
|
288
|
+
next_id = len(leaves)
|
|
289
|
+
|
|
290
|
+
def owner_map():
|
|
291
|
+
return {u: k for k, leaf in leaves.items() for u in leaf["m"]}
|
|
292
|
+
|
|
293
|
+
def ties_for(k, owner, profs):
|
|
294
|
+
t = _leaf_ties(leaves[k]["m"], owner, dep_text)
|
|
295
|
+
mine = profs[k]
|
|
296
|
+
if mine:
|
|
297
|
+
for j in leaves:
|
|
298
|
+
if j != k:
|
|
299
|
+
shared = sum(min(v, profs[j].get(a, 0.0)) for a, v in mine.items() if a in profs[j])
|
|
300
|
+
if shared > 0:
|
|
301
|
+
t[j] += shared
|
|
302
|
+
return t
|
|
303
|
+
|
|
304
|
+
isolated: list[int] = []
|
|
305
|
+
changed = True
|
|
306
|
+
while changed:
|
|
307
|
+
changed = False
|
|
308
|
+
owner = owner_map()
|
|
309
|
+
profs = {k: _profile_of(leaf["m"], profile) for k, leaf in leaves.items()}
|
|
310
|
+
for k in sorted(leaves, key=lambda k: (len(leaves[k]["m"]), min(leaves[k]["m"]))):
|
|
311
|
+
size = len(leaves[k]["m"])
|
|
312
|
+
if size >= grain[0] or k in isolated:
|
|
313
|
+
continue
|
|
314
|
+
ties = ties_for(k, owner, profs)
|
|
315
|
+
total = sum(ties.values())
|
|
316
|
+
fits = {j: v for j, v in ties.items() if size + len(leaves[j]["m"]) <= grain[1]}
|
|
317
|
+
pool = fits if (fits or size >= MIN_LEAF) else ties
|
|
318
|
+
if not pool or max(pool.values()) <= 0:
|
|
319
|
+
if size < MIN_LEAF:
|
|
320
|
+
isolated.append(k)
|
|
321
|
+
else:
|
|
322
|
+
leaves[k]["why"] = "no tie to any other module"
|
|
323
|
+
continue
|
|
324
|
+
best = max(sorted(pool), key=lambda j: pool[j])
|
|
325
|
+
if size >= MIN_LEAF and pool[best] < 0.3 * total:
|
|
326
|
+
leaves[k]["why"] = f"the strongest neighbour holds {pool[best] / total:.0%} of their outside ties (below 30%)"
|
|
327
|
+
continue
|
|
328
|
+
leaves[best]["m"] |= leaves[k]["m"]
|
|
329
|
+
if len(leaves[best]["m"]) > grain[1]:
|
|
330
|
+
leaves[best]["why"] = f"grew past the limit when a {size}-unit leaf with no better home joined"
|
|
331
|
+
del leaves[k]
|
|
332
|
+
changed = True
|
|
333
|
+
break
|
|
334
|
+
# straggler pool: no tie to anything, so only the path order is left
|
|
335
|
+
pool_units = sorted((u for k in isolated if k in leaves for u in leaves[k]["m"]),
|
|
336
|
+
key=lambda u: ((unit_paths(units[u]) or [""])[0], u))
|
|
337
|
+
for k in isolated:
|
|
338
|
+
leaves.pop(k, None)
|
|
339
|
+
chunks = [pool_units[i:i + POOL_CHUNK] for i in range(0, len(pool_units), POOL_CHUNK)]
|
|
340
|
+
if len(chunks) > 1 and len(chunks[-1]) < MIN_LEAF:
|
|
341
|
+
chunks[-2] += chunks.pop()
|
|
342
|
+
if len(chunks) == 1 and len(chunks[0]) < MIN_LEAF and leaves:
|
|
343
|
+
# too few stragglers for a leaf of their own: each joins the leaf that shares the longest path prefix
|
|
344
|
+
for u in chunks.pop():
|
|
345
|
+
mine = (unit_paths(units[u]) or [""])[0]
|
|
346
|
+
def common(k):
|
|
347
|
+
return max(len(posixpath.commonpath([mine, p]) if mine and p else "")
|
|
348
|
+
for v in leaves[k]["m"] for p in [(unit_paths(units[v]) or [""])[0]])
|
|
349
|
+
fits = [k for k in leaves if len(leaves[k]["m"]) < grain[1]] or list(leaves)
|
|
350
|
+
home = max(sorted(fits), key=lambda k: (common(k), -len(leaves[k]["m"])))
|
|
351
|
+
leaves[home]["m"].add(u)
|
|
352
|
+
for chunk in chunks:
|
|
353
|
+
flag = "packed_by_path: units with no edge, shared file or similar wording, grouped in path order"
|
|
354
|
+
leaves[next_id] = {"m": set(chunk), "flag": flag, "why": None}
|
|
355
|
+
next_id += 1
|
|
356
|
+
for leaf in leaves.values():
|
|
357
|
+
size = len(leaf["m"])
|
|
358
|
+
if str(leaf["flag"]).startswith("packed"):
|
|
359
|
+
continue
|
|
360
|
+
if size > grain[1]:
|
|
361
|
+
leaf["flag"] = f"oversize: {size} units; {leaf['why'] or 'no better split'}"
|
|
362
|
+
elif size < grain[0]:
|
|
363
|
+
leaf["flag"] = f"under_min: {size} units; {leaf['why'] or 'no neighbour to join'}"
|
|
364
|
+
else:
|
|
365
|
+
leaf["flag"] = None # inside the grain (a later merge may have fixed it)
|
|
366
|
+
return [(sorted(leaf["m"]), leaf["flag"]) for _, leaf in sorted(leaves.items(), key=lambda kv: min(kv[1]["m"]))]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _contract(G: nx.Graph, owner: dict[str, str]) -> nx.Graph:
|
|
370
|
+
acc: dict[tuple[str, str], float] = defaultdict(float)
|
|
371
|
+
for a, b, data in G.edges(data=True):
|
|
372
|
+
ma, mb = owner.get(a, a), owner.get(b, b)
|
|
373
|
+
acc[(ma, mb) if ma <= mb else (mb, ma)] += data["weight"]
|
|
374
|
+
C = nx.Graph()
|
|
375
|
+
C.add_nodes_from(sorted({owner.get(n, n) for n in G.nodes}))
|
|
376
|
+
for (a, b), weight in sorted(acc.items()):
|
|
377
|
+
C.add_edge(a, b, weight=weight)
|
|
378
|
+
return C
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _coarsen(C: nx.Graph, mods: list[str], seed: int) -> list[list[str]]:
|
|
382
|
+
"""Group modules so that the count falls to about 60% or less (never to one group)."""
|
|
383
|
+
limit = max(2, math.ceil(COARSEN_SHRINK * len(mods)))
|
|
384
|
+
best = None
|
|
385
|
+
for r in COARSEN_LADDER:
|
|
386
|
+
gs = [[n for n in c if not _is_anchor(n)] for c in _louvain(C, r, seed)]
|
|
387
|
+
gs = [g for g in gs if g]
|
|
388
|
+
if 2 <= len(gs) <= limit:
|
|
389
|
+
return gs
|
|
390
|
+
if 2 <= len(gs) < len(mods) and (best is None or len(gs) < len(best)):
|
|
391
|
+
best = gs
|
|
392
|
+
return best or [[m] for m in mods]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _node_id(unit_ids) -> str:
|
|
396
|
+
return "m" + hashlib.sha1("\n".join(sorted(unit_ids)).encode()).hexdigest()[:10]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ----------------------------------------------------------------- build ---
|
|
400
|
+
|
|
401
|
+
def build(units: dict[str, dict], edges: list[dict], *, seed: int = 20261111,
|
|
402
|
+
grain: tuple[int, int] = GRAIN, weights: Weights = Weights()) -> dict:
|
|
403
|
+
"""Return ``{"nodes": [{"id", "parent", "unit_ids", "level"[, "flag"]}]}``.
|
|
404
|
+
|
|
405
|
+
``level`` is the depth below the root (root 0). Only leaves hold units. A leaf outside
|
|
406
|
+
``grain`` carries ``flag``: why it is outside.
|
|
407
|
+
"""
|
|
408
|
+
if not units:
|
|
409
|
+
return {"nodes": []}
|
|
410
|
+
G, uids, dep, text, anchors_of, profile = _build_graph(units, edges, weights)
|
|
411
|
+
dep_text = nx.Graph()
|
|
412
|
+
dep_text.add_nodes_from(uids)
|
|
413
|
+
for (a, b), x in list(dep.items()) + list(text.items()):
|
|
414
|
+
dep_text.add_edge(a, b, weight=dep_text[a][b]["weight"] + x if dep_text.has_edge(a, b) else x)
|
|
415
|
+
|
|
416
|
+
parts = _split(G, uids, anchors_of, grain, seed)
|
|
417
|
+
leaves = _shape_leaves(parts, dep_text, profile, units, grain)
|
|
418
|
+
|
|
419
|
+
# tree under construction: temp name -> {"units", "children", "flag"}
|
|
420
|
+
tree: dict[str, dict] = {}
|
|
421
|
+
for i, (members, flag) in enumerate(leaves):
|
|
422
|
+
tree[f"L{i}"] = {"units": members, "children": [], "flag": flag}
|
|
423
|
+
top = list(tree)
|
|
424
|
+
n_level = 0
|
|
425
|
+
while len(top) > TOP_MAX:
|
|
426
|
+
owner: dict[str, str] = {}
|
|
427
|
+
for name in top:
|
|
428
|
+
for u in _subtree_units(tree, name):
|
|
429
|
+
owner[u] = name
|
|
430
|
+
C = _contract(G, owner)
|
|
431
|
+
groups = _coarsen(C, sorted(top), seed)
|
|
432
|
+
if len(groups) >= len(top):
|
|
433
|
+
break
|
|
434
|
+
n_level += 1
|
|
435
|
+
new_top = []
|
|
436
|
+
for gi, group in enumerate(sorted(groups, key=lambda g: g[0])):
|
|
437
|
+
if len(group) == 1:
|
|
438
|
+
new_top.append(group[0])
|
|
439
|
+
else:
|
|
440
|
+
name = f"U{n_level}_{gi}"
|
|
441
|
+
tree[name] = {"units": [], "children": sorted(group), "flag": None}
|
|
442
|
+
new_top.append(name)
|
|
443
|
+
top = new_top
|
|
444
|
+
if len(top) == 1:
|
|
445
|
+
root = top[0]
|
|
446
|
+
else:
|
|
447
|
+
root = "ROOT"
|
|
448
|
+
tree[root] = {"units": [], "children": sorted(top), "flag": None}
|
|
449
|
+
|
|
450
|
+
ids = {name: _node_id(_subtree_units(tree, name)) for name in tree}
|
|
451
|
+
nodes: list[dict] = []
|
|
452
|
+
|
|
453
|
+
def emit(name: str, parent: str | None, level: int):
|
|
454
|
+
entry = tree[name]
|
|
455
|
+
node = {"id": ids[name], "parent": parent, "unit_ids": sorted(entry["units"]), "level": level}
|
|
456
|
+
if entry["flag"]:
|
|
457
|
+
node["flag"] = entry["flag"]
|
|
458
|
+
nodes.append(node)
|
|
459
|
+
kids = sorted(entry["children"], key=lambda c: (-len(_subtree_units(tree, c)), ids[c]))
|
|
460
|
+
for kid in kids:
|
|
461
|
+
emit(kid, ids[name], level + 1)
|
|
462
|
+
|
|
463
|
+
emit(root, None, 0)
|
|
464
|
+
|
|
465
|
+
return {"nodes": nodes}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _subtree_units(tree: dict, name: str) -> list[str]:
|
|
469
|
+
entry = tree[name]
|
|
470
|
+
out = list(entry["units"])
|
|
471
|
+
for kid in entry["children"]:
|
|
472
|
+
out.extend(_subtree_units(tree, kid))
|
|
473
|
+
return out
|
|
474
|
+
|