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.
Files changed (47) hide show
  1. cbe/__init__.py +9 -0
  2. cbe/__main__.py +4 -0
  3. cbe/aggregation.py +183 -0
  4. cbe/api_completion.py +410 -0
  5. cbe/batching.py +283 -0
  6. cbe/catalog_query.py +376 -0
  7. cbe/checks.py +390 -0
  8. cbe/cli.py +434 -0
  9. cbe/concrete_facts.py +321 -0
  10. cbe/data/LICENSE.tiktoken +30 -0
  11. cbe/data/o200k_base.tiktoken +199998 -0
  12. cbe/declarations.py +537 -0
  13. cbe/docs.py +929 -0
  14. cbe/generate.py +316 -0
  15. cbe/grammars.py +741 -0
  16. cbe/graph.py +857 -0
  17. cbe/graph_backends/__init__.py +43 -0
  18. cbe/graph_backends/_calls.py +262 -0
  19. cbe/graph_backends/_paths.py +42 -0
  20. cbe/graph_backends/identity.py +93 -0
  21. cbe/graph_backends/imports.py +256 -0
  22. cbe/graph_backends/joern.py +1021 -0
  23. cbe/graph_backends/resolution.py +288 -0
  24. cbe/graph_backends/toolchain.py +396 -0
  25. cbe/grounding.py +57 -0
  26. cbe/host_run.py +1752 -0
  27. cbe/model_settings.py +80 -0
  28. cbe/projection.py +305 -0
  29. cbe/prompts.py +316 -0
  30. cbe/records.py +200 -0
  31. cbe/skeleton.py +474 -0
  32. cbe/source_context.py +709 -0
  33. cbe/symbols.py +136 -0
  34. cbe/token_budget.py +68 -0
  35. cbe/treename.py +398 -0
  36. cbe/usage.py +300 -0
  37. cbe_install/__init__.py +11 -0
  38. cbe_install/cli.py +434 -0
  39. cbe_install/resources/agents/cbe-writer.md +16 -0
  40. cbe_install/resources/codebase-explorer/SKILL.md +101 -0
  41. cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
  42. cbe_install/resources/codebase-explorer/references/codex.md +37 -0
  43. codebase_explorer-2.2.0.dist-info/METADATA +233 -0
  44. codebase_explorer-2.2.0.dist-info/RECORD +47 -0
  45. codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
  46. codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
  47. 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
+