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/symbols.py ADDED
@@ -0,0 +1,136 @@
1
+ """Unit inventory: the ids a detail author must explain, with program-derived line ranges.
2
+
3
+ Ids come from the frozen ``cbe.source_context`` chunks, so the detail stage,
4
+ the graph and the query layer name the same thing. A non-empty file the
5
+ chunker left without a chunk falls back to one whole-file region.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from pathlib import Path
12
+ from typing import Iterable
13
+
14
+ from cbe import grammars, source_context
15
+ from cbe.checks import UNIT_KINDS
16
+ from cbe.token_budget import count_text_tokens
17
+
18
+
19
+ class SemanticError(ValueError):
20
+ """The semantic stage cannot continue with the given input."""
21
+
22
+
23
+ def inventory_from_context(context: dict, paths: list[str] | None = None) -> dict[str, dict]:
24
+ """Build the same inventory shape from a ``cbe-context/1`` snapshot.
25
+
26
+ Chunk ids become unit ids, so the detail stage, the graph and the query
27
+ layer all name the same thing. A chunk without a name (whole-file region)
28
+ still yields one id. Diagnostics never turn a missing chunk into an empty
29
+ success: a listed path with no chunks raises."""
30
+ wanted = set(paths) if paths is not None else None
31
+ by_path: dict[str, dict] = {}
32
+ for chunk in context.get("chunks") or []:
33
+ path = chunk["path"]
34
+ if wanted is not None and path not in wanted:
35
+ continue
36
+ entry = by_path.setdefault(path, {"tokens": 0, "sha256": "", "nlines": 1,
37
+ "symbols": []})
38
+ if not (chunk.get("text") or "").strip():
39
+ # A blank chunk (an empty or whitespace-only file) carries no
40
+ # explainable detail; it must not become a unit with prose.
41
+ continue
42
+ name = chunk.get("name") or "<region>"
43
+ kind = chunk.get("kind") or "module_region"
44
+ if kind not in UNIT_KINDS:
45
+ kind = "module_region"
46
+ entry["symbols"].append({
47
+ "id": chunk["id"], "name": name, "kind": kind, "parent": None,
48
+ "lines": [[int(chunk["start_line"]), int(chunk["end_line"])]],
49
+ })
50
+ for row in context.get("files") or []:
51
+ path = row["path"]
52
+ if wanted is not None and path not in wanted:
53
+ continue
54
+ entry = by_path.setdefault(path, {"tokens": 0, "sha256": "", "nlines": 1,
55
+ "symbols": []})
56
+ entry["sha256"] = row.get("sha256", "")
57
+ entry["nlines"] = max((pair[1] for sym in entry["symbols"]
58
+ for pair in sym["lines"]), default=1)
59
+ entry["tokens"] = sum(count_text_tokens(chunk.get("text") or "")
60
+ for chunk in (context.get("chunks") or [])
61
+ if chunk.get("path") == path)
62
+ # No symbols after blank chunks means the file is known-empty; the
63
+ # catalogue still lists it, but no unit or disposition is invented.
64
+ entry["known_empty"] = not entry["symbols"]
65
+ if wanted is not None:
66
+ missing = sorted(path for path in wanted if path not in by_path)
67
+ if missing:
68
+ raise SemanticError("source context has no chunks or file entry for: "
69
+ + ", ".join(missing[:10]))
70
+ return by_path
71
+
72
+
73
+ def inventory_ids(inventory: dict[str, dict]) -> list[str]:
74
+ return [row["id"] for file in inventory.values() for row in file["symbols"]]
75
+
76
+
77
+ def ensure_chunk_coverage(context: dict, repo: Path, paths: list[str]) -> dict:
78
+ """Guarantee every non-empty requested path has a readable source chunk.
79
+
80
+ A genuinely empty file (no bytes, or whitespace only) is known-empty: it
81
+ gets a ``known_empty`` diagnostic and **no** chunk, so the inventory and
82
+ render never invent a missing-grammar region or force prose for it. A
83
+ non-empty path with no chunk is a real grammar/unsupported-language gap, so
84
+ one whole-file region supplies a chunk and a visible ``context_fallback``
85
+ diagnostic. Mutates and returns ``context``."""
86
+ chunks = context.setdefault("chunks", [])
87
+ files = context.setdefault("files", [])
88
+ diagnostics = context.setdefault("diagnostics", [])
89
+ present = {chunk["path"] for chunk in chunks}
90
+ listed = {row["path"] for row in files}
91
+ for path in paths:
92
+ if path in present:
93
+ continue
94
+ try:
95
+ raw = (repo / path).read_bytes()
96
+ text = raw.decode("utf-8")
97
+ except (OSError, UnicodeDecodeError) as exc:
98
+ diagnostics.append({"path": path, "code": "source_unavailable", "message": str(exc)})
99
+ continue
100
+ digest = hashlib.sha256(raw).hexdigest()
101
+ if path not in listed:
102
+ files.append({"path": path, "language": grammars.language_name(path), "sha256": digest})
103
+ # Fallback symbol chunks historically omitted line endings and gaps.
104
+ # Preserve exact bytes as decoded text so frozen source retrieval does
105
+ # not need a live repository (and can verify the whole-file digest).
106
+ file_row = next(row for row in files if row["path"] == path)
107
+ file_row["source"] = text
108
+ if text.strip() == "":
109
+ diagnostics.append({"path": path, "code": "known_empty",
110
+ "message": "file is empty; it has no source chunk, no inventory id "
111
+ "and needs no detail unit"})
112
+ continue
113
+ nlines = max(len(text.splitlines()), 1)
114
+ chunks.append({"id": f"{path}::<file>", "path": path, "language": grammars.language_name(path),
115
+ "kind": "module_region", "name": "<file>",
116
+ "start_line": 1, "end_line": nlines,
117
+ "start_byte": 0, "end_byte": 0, "text": text,
118
+ "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest()})
119
+ diagnostics.append({"path": path, "code": "context_fallback",
120
+ "message": "no chunk grammar output for this path; the whole-file "
121
+ "fallback supplied the reading chunk"})
122
+ return context
123
+
124
+
125
+ def batch_table(paths: Iterable[str], inventory: dict[str, dict]) -> list[dict]:
126
+ """The short-number table of one detail batch.
127
+
128
+ Rows are ``{n, id, path, kind, name, lines}``; ``n`` counts from 1 in
129
+ prompt order (files as given, symbols as inventoried), so the prompt and the
130
+ answer decoder derive the same numbers from the frozen inventory."""
131
+ rows: list[dict] = []
132
+ for path in paths:
133
+ for symbol in inventory[path]["symbols"]:
134
+ rows.append({"n": len(rows) + 1, "id": symbol["id"], "path": path, "kind": symbol["kind"],
135
+ "name": symbol["name"], "lines": symbol["lines"]})
136
+ return rows
cbe/token_budget.py ADDED
@@ -0,0 +1,68 @@
1
+ """Offline text token accounting shared by generation and its usage reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import base64
7
+ from functools import lru_cache
8
+ from importlib.resources import files
9
+
10
+ import tiktoken
11
+
12
+ TOKENIZER = "o200k_base"
13
+ # Official OpenAI encoding bytes; matches tiktoken's o200k_base expected_hash.
14
+ # Source: https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken
15
+ ENCODING_SHA256 = "446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d"
16
+ O200K_PATTERN = "|".join([
17
+ r"[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
18
+ r"[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]+[\p{Ll}\p{Lm}\p{Lo}\p{M}]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
19
+ r"\p{N}{1,3}", r" ?[^\s\p{L}\p{N}]+[\r\n/]*", r"\s*[\r\n]+",
20
+ r"\s+(?!\S)", r"\s+",
21
+ ])
22
+
23
+
24
+ @lru_cache(maxsize=1)
25
+ def _encoding() -> tiktoken.Encoding:
26
+ """Load the official BPE resource without network or user-cache writes.
27
+
28
+ Encoding and ordinary-token algorithms remain tiktoken's. The official
29
+ o200k regex, special token IDs and merge ranks are pinned together so an
30
+ empty cache in a network-restricted host gives the same frozen denominator.
31
+ """
32
+ raw = files("cbe").joinpath("data", "o200k_base.tiktoken").read_bytes()
33
+ if hashlib.sha256(raw).hexdigest() != ENCODING_SHA256:
34
+ raise ValueError("bundled o200k_base encoding data hash mismatch")
35
+ # This is the same base64/rank serialization consumed by
36
+ # tiktoken.load.load_tiktoken_bpe, with no implicit cache/network layer.
37
+ ranks = {base64.b64decode(token): int(rank)
38
+ for token, rank in (line.split() for line in raw.splitlines() if line)}
39
+ return tiktoken.Encoding(name=TOKENIZER, pat_str=O200K_PATTERN,
40
+ mergeable_ranks=ranks,
41
+ special_tokens={"<|endoftext|>": 199999, "<|endofprompt|>": 200018})
42
+
43
+
44
+ def count_section_tokens(sections: list[tuple[str, str]]) -> dict[str, int]:
45
+ """Partition full-text tokens by the section containing each token's first byte.
46
+
47
+ A boundary-spanning token belongs to its starting section. This preserves
48
+ the full-text BPE total, unlike independently tokenizing each line.
49
+ """
50
+ from bisect import bisect_right
51
+
52
+ boundaries, labels, offset = [], [], 0
53
+ for label, text in sections:
54
+ if text:
55
+ boundaries.append(offset)
56
+ labels.append(label)
57
+ offset += len(text.encode("utf-8"))
58
+ counts = dict.fromkeys(labels, 0)
59
+ offset = 0
60
+ encoding = _encoding()
61
+ for token in encoding.encode_ordinary("".join(text for _, text in sections)):
62
+ counts[labels[bisect_right(boundaries, offset) - 1]] += 1
63
+ offset += len(encoding.decode_single_token_bytes(token))
64
+ return counts
65
+
66
+
67
+ def count_text_tokens(text: str) -> int:
68
+ return len(_encoding().encode_ordinary(text))
cbe/treename.py ADDED
@@ -0,0 +1,398 @@
1
+ """Name the program-made module tree and put the finished tree together.
2
+
3
+ ``skeleton.build`` decides the structure. The model only names it, in two kinds
4
+ of task: ``tree_name_<k>`` names a batch of leaf modules and ``tree_top`` names
5
+ every internal node. This module holds what those tasks need and nothing about
6
+ the task state machine (see ``host_run``): batches, the facts shown to the
7
+ model, answer decoding with short numbers, program-made titles for nodes the
8
+ model could not name, and the final ``nodes`` list.
9
+
10
+ A model answer never changes the structure. Answers are decoded node by node: a
11
+ good node is kept and only the bad ones are asked again. A node that stays
12
+ unnamed gets a title made from its members (frequent words plus the main
13
+ directory), so the run always ends with a valid tree.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import math
19
+ import posixpath
20
+ import re
21
+ import unicodedata
22
+ from collections import Counter, defaultdict
23
+
24
+ from cbe import skeleton
25
+ from cbe.records import summary_sentence
26
+ from cbe.token_budget import count_text_tokens
27
+
28
+ BATCH_TOKENS = 12_000 # about this many prompt tokens of member lines per tree_name task
29
+ SUMMARY_CHARS = 200
30
+ TITLE_WORDS = 9
31
+ CAPABILITY_WORDS = 45 # the prompt asks for about 25; an answer above this is sent back
32
+ MIN_GROUP_CAPABILITY = 40 # a node with children needs a real sentence, not a fragment
33
+ _STOP_PATH = {"lib", "src", "main", "java", "com", "test", "tests", "include", "rb", "py", "js", "ts", "org"}
34
+
35
+
36
+ class Tree:
37
+ """Read view of a frozen skeleton (``skeleton.build()["nodes"]``)."""
38
+
39
+ def __init__(self, nodes: list[dict]):
40
+ self.nodes = {n["id"]: n for n in nodes}
41
+ self.order = [n["id"] for n in nodes]
42
+ self.kids: dict[str, list[str]] = defaultdict(list)
43
+ for n in nodes:
44
+ if n["parent"]:
45
+ self.kids[n["parent"]].append(n["id"])
46
+ self.root = next(i for i in self.order if self.nodes[i]["parent"] is None)
47
+ self._members: dict[str, list[str]] = {}
48
+ self.leaf_of = {u: n["id"] for n in nodes for u in n["unit_ids"]}
49
+
50
+ def leaves(self) -> list[str]:
51
+ return [i for i in self.order if not self.kids[i]]
52
+
53
+ def internals(self) -> list[str]:
54
+ return [i for i in self.order if self.kids[i]]
55
+
56
+ def members(self, node: str) -> list[str]:
57
+ if node not in self._members:
58
+ out = list(self.nodes[node]["unit_ids"])
59
+ for kid in self.kids[node]:
60
+ out += self.members(kid)
61
+ self._members[node] = out
62
+ return self._members[node]
63
+
64
+
65
+ def empty_tree() -> dict:
66
+ """The tree of a run with no units: one root node."""
67
+ return {"nodes": [{"id": "root", "parent": None, "title": "Codebase", "unit_ids": [], "evidence_units": [],
68
+ "capability": "This run has no explainable code units.",
69
+ "summary": "Every file read was empty, so there is nothing to map."}],
70
+ "depends_on": []}
71
+
72
+
73
+ def freeze(units: dict[str, dict], edges: list[dict]) -> dict:
74
+ """The skeleton and its leaf batches, as stored in the run state."""
75
+ nodes = skeleton.build(units, edges)["nodes"]
76
+ if not nodes:
77
+ return {"nodes": [], "batches": []}
78
+ return {"nodes": nodes, "batches": plan_batches(Tree(nodes), units)}
79
+
80
+
81
+ def _short(text: str) -> str:
82
+ text = " ".join((text or "").split())
83
+ return text if len(text) <= SUMMARY_CHARS else text[:SUMMARY_CHARS - 1].rstrip() + "…"
84
+
85
+
86
+ def plan_batches(tree: Tree, units: dict[str, dict]) -> list[list[str]]:
87
+ """Cut the leaves, in tree order, into batches of about ``BATCH_TOKENS`` tokens."""
88
+ batches: list[list[str]] = []
89
+ current: list[str] = []
90
+ used = 0
91
+ for leaf in tree.leaves():
92
+ size = sum(count_text_tokens(_short(summary_sentence(units[u]))) + 8
93
+ for u in tree.nodes[leaf]["unit_ids"]) + 60
94
+ if current and used + size > BATCH_TOKENS:
95
+ batches.append(current)
96
+ current, used = [], 0
97
+ current.append(leaf)
98
+ used += size
99
+ if current:
100
+ batches.append(current)
101
+ return batches
102
+
103
+
104
+ # ------------------------------------------------------------ tree_name ----
105
+
106
+ def name_context(batch: list[str], tree: Tree) -> tuple[dict[str, str], dict[str, str]]:
107
+ """``({label: leaf}, {unit: code})``: the short numbers of one batch, the same on every ask."""
108
+ labels = {f"M{i + 1}": leaf for i, leaf in enumerate(batch)}
109
+ codes: dict[str, str] = {}
110
+ for leaf in batch:
111
+ for unit in sorted(tree.nodes[leaf]["unit_ids"]):
112
+ codes[unit] = f"u{len(codes) + 1}"
113
+ return labels, codes
114
+
115
+
116
+ def _ties(pairs, owner_of):
117
+ out = defaultdict(lambda: defaultdict(int))
118
+ for edge in pairs:
119
+ a, b = owner_of(edge["from"]), owner_of(edge["to"])
120
+ if a and b and a != b:
121
+ out[a][b] += edge["count"]
122
+ return out
123
+
124
+
125
+ def _tie_line(prefix: str, table: dict, name_of) -> str:
126
+ items = sorted(((name_of(o), c) for o, c in table.items() if name_of(o)), key=lambda t: (-t[1], t[0]))
127
+ return f"{prefix}: " + ", ".join(f"{n} x{c}" for n, c in items) if items else ""
128
+
129
+
130
+ def name_blocks(batch: list[str], tree: Tree, units: dict[str, dict], edges: list[dict]) -> list[str]:
131
+ """One text block per leaf module: members by file, calls in and out within the batch."""
132
+ labels, codes = name_context(batch, tree)
133
+ label_of = {leaf: label for label, leaf in labels.items()}
134
+ out_ties = _ties(edges, lambda u: tree.leaf_of.get(u) if tree.leaf_of.get(u) in label_of else None)
135
+ in_ties = defaultdict(lambda: defaultdict(int))
136
+ for a, row in out_ties.items():
137
+ for b, count in row.items():
138
+ in_ties[b][a] += count
139
+ blocks = []
140
+ for leaf in batch:
141
+ members = tree.nodes[leaf]["unit_ids"]
142
+ by_file = defaultdict(list)
143
+ for unit in members:
144
+ by_file[(skeleton.unit_paths(units[unit]) or ["?"])[0]].append(unit)
145
+ lines = [f"## {label_of[leaf]} ({len(members)} units in {len(by_file)} files)"]
146
+ for path in sorted(by_file):
147
+ lines.append(f"file {path}")
148
+ lines += [f" {codes[u]}: {_short(summary_sentence(units[u]))}" for u in sorted(by_file[path], key=lambda u: int(codes[u][1:]))]
149
+ for prefix, table in (("calls into", out_ties.get(leaf, {})), ("called from", in_ties.get(leaf, {}))):
150
+ if line := _tie_line(prefix, table, label_of.get):
151
+ lines.append(line)
152
+ blocks.append("\n".join(lines))
153
+ return blocks
154
+
155
+
156
+ def normalize_title(text: str) -> str:
157
+ return re.sub(r"\W+", " ", text.lower()).strip()
158
+
159
+
160
+ def _text_problem(record: dict, label: str) -> list[str]:
161
+ problems = []
162
+ for key in ("title", "capability", "summary"):
163
+ if not isinstance(record.get(key), str) or not record[key].strip():
164
+ problems.append(f"{label}: {key} is empty")
165
+ if problems:
166
+ return problems
167
+ title = record["title"].strip()
168
+ if len(title) > 90 or len(title.split()) > TITLE_WORDS:
169
+ problems.append(f"{label}: title is too long (2 to 6 words)")
170
+ if len(record["capability"].split()) > CAPABILITY_WORDS:
171
+ problems.append(f"{label}: capability must be one sentence of about 25 words")
172
+ return problems
173
+
174
+
175
+ def _decode(answer: object, key: str, noun: str, labels: dict[str, str], pending: set[str], taken: set[str],
176
+ judge) -> tuple[dict, dict]:
177
+ """The shared decoding of a ``tree_name`` or ``tree_top`` answer for the nodes in ``pending``.
178
+
179
+ Returns ``(good, errors)``: ``good`` maps a node to its record, ``errors`` maps a node to what is wrong.
180
+ ``taken`` holds the normalized titles accepted from earlier answers of this task. ``judge(label, node, row,
181
+ title, used, problems)`` adds the problems that are specific to the kind of task to ``problems`` and returns
182
+ the fields of the record that are specific to it (``evidence_units``)."""
183
+ rows = answer.get(key) if isinstance(answer, dict) else None
184
+ if not isinstance(rows, list):
185
+ raise ValueError(f'answer needs {{"{key}": [...]}}')
186
+ seen: dict[str, dict] = {}
187
+ for row in rows:
188
+ if isinstance(row, dict) and row.get("id") in labels and row["id"] not in seen:
189
+ seen[row["id"]] = row
190
+ used = set(taken) # titles kept so far: the first of two equal titles stays, the later one is asked again
191
+ good: dict[str, dict] = {}
192
+ errors: dict[str, list[str]] = {}
193
+ for label, node in labels.items():
194
+ if node not in pending:
195
+ continue
196
+ row = seen.get(label)
197
+ if row is None:
198
+ errors[node] = [f"{noun} {label} is missing from the answer"]
199
+ continue
200
+ title = str(row.get("title", "")).strip()
201
+ problems = _text_problem(row, label)
202
+ extra = judge(label, node, row, title, used, problems)
203
+ if problems:
204
+ errors[node] = problems
205
+ continue
206
+ used.add(normalize_title(title))
207
+ good[node] = {"title": title, "capability": row["capability"].strip(), "summary": row["summary"].strip(),
208
+ **extra}
209
+ return good, errors
210
+
211
+
212
+ def decode_names(answer: object, batch: list[str], tree: Tree, pending: set[str], taken: set[str]) -> tuple[dict, dict]:
213
+ """Decode one ``tree_name`` answer for the leaves in ``pending`` (see ``_decode``)."""
214
+ labels, codes = name_context(batch, tree)
215
+ by_code = {code: unit for unit, code in codes.items()}
216
+
217
+ def judge(label, leaf, row, title, used, problems):
218
+ if title and normalize_title(title) in used:
219
+ problems.append(f"{label}: title {title!r} is used by another module of this batch")
220
+ members = set(tree.nodes[leaf]["unit_ids"])
221
+ evidence = row.get("evidence_units")
222
+ want = min(2, len(members))
223
+ if (not isinstance(evidence, list) or any(not isinstance(e, str) or by_code.get(e) not in members for e in evidence)
224
+ or len(set(evidence)) < want):
225
+ problems.append(f"{label}: evidence_units needs {want} to 4 unit numbers that belong to {label}")
226
+ return {}
227
+ return {"evidence_units": [by_code[e] for e in dict.fromkeys(evidence)][:4]}
228
+
229
+ return _decode(answer, "modules", "module", labels, pending, taken, judge)
230
+
231
+
232
+ # ------------------------------------------------------------- tree_top ----
233
+
234
+ def top_context(tree: Tree) -> dict[str, str]:
235
+ """``{label: internal node}`` in tree order (the root is G1)."""
236
+ return {f"G{i + 1}": node for i, node in enumerate(tree.internals())}
237
+
238
+
239
+ def top_blocks(tree: Tree, names: dict[str, dict], edges: list[dict]) -> list[str]:
240
+ """One block per internal node: its children (named leaves, or the group that names them) and their ties."""
241
+ label_of = {node: label for label, node in top_context(tree).items()}
242
+
243
+ def child_name(node: str) -> str:
244
+ return names[node]["title"] if node in names else f"group {label_of[node]}"
245
+
246
+ blocks = []
247
+ for node, label in label_of.items():
248
+ kids = tree.kids[node]
249
+ home = {u: kid for kid in kids for u in tree.members(kid)}
250
+ ties = _ties([e for e in edges if e["from"] in home and e["to"] in home], home.get)
251
+ lines = [f"## {label} ({len(tree.members(node))} units)", "children:"]
252
+ for kid in kids:
253
+ what = f": {names[kid]['capability']}" if kid in names else ""
254
+ lines.append(f"- {child_name(kid)} ({len(tree.members(kid))} units){what}")
255
+ flows = [f"{child_name(a)} -> {child_name(b)} x{c}"
256
+ for a in kids for b, c in sorted(ties[a].items(), key=lambda t: -t[1])[:3]]
257
+ if flows:
258
+ lines.append("edges between children: " + "; ".join(flows))
259
+ blocks.append("\n".join(lines))
260
+ return blocks
261
+
262
+
263
+ def decode_top(answer: object, tree: Tree, names: dict[str, dict], pending: set[str], taken: set[str]) -> tuple[dict, dict]:
264
+ """Decode one ``tree_top`` answer for the internal nodes in ``pending`` (same return as ``decode_names``)."""
265
+ def judge(label, node, row, title, used, problems):
266
+ kid_titles = {normalize_title(names[k]["title"]) for k in tree.kids[node] if k in names}
267
+ if title and (normalize_title(title) in used or normalize_title(title) in kid_titles):
268
+ problems.append(f"{label}: title {title!r} repeats the title of another group or of one of its children")
269
+ if not problems and len(row["capability"].strip()) < MIN_GROUP_CAPABILITY:
270
+ problems.append(f"{label}: capability must be a full sentence")
271
+ return {"evidence_units": []}
272
+
273
+ return _decode(answer, "groups", "group", top_context(tree), pending, taken, judge)
274
+
275
+
276
+ # ------------------------------------------------- program-made titles ----
277
+
278
+ def _words(units: dict[str, dict], members: list[str]) -> Counter:
279
+ count = Counter()
280
+ for u in members:
281
+ for w in set(skeleton.word_tokens(skeleton.symbol_words(u) + " " + (units[u].get("summary") or ""))):
282
+ count[w] += 1
283
+ return count
284
+
285
+
286
+ def program_record(units: dict[str, dict], members: list[str], *, avoid: set[str] = frozenset(),
287
+ siblings: list[list[str]] = ()) -> dict:
288
+ """A title and a capability made from the members: telling words plus the main directory.
289
+
290
+ Never numbered; ``avoid`` holds normalized titles the result must not equal."""
291
+ words = _words(units, members)
292
+ others = Counter()
293
+ for other in siblings:
294
+ others.update(_words(units, other))
295
+ score = {w: c * (1 + math.log(len(members) / (1 + others.get(w, 0)) + 1)) for w, c in words.items()}
296
+ ranked = [w for w, _ in sorted(score.items(), key=lambda t: (-t[1], t[0]))]
297
+ dirs = Counter(posixpath.dirname((skeleton.unit_paths(units[u]) or [""])[0]).split("/")[-1] for u in members)
298
+ main_dir = next((d for d, _ in dirs.most_common() if d and d.lower() not in _STOP_PATH), "")
299
+
300
+ def prefixed(text: str) -> str:
301
+ return f"{main_dir}: {text}" if main_dir else text
302
+
303
+ options = [prefixed(" ".join(w.capitalize() for w in ranked[:k])) for k in range(2, len(ranked) + 1)]
304
+ files = Counter((skeleton.unit_paths(units[u]) or [""])[0] for u in members)
305
+ stem = posixpath.splitext(posixpath.basename(files.most_common(1)[0][0]))[0] if files else ""
306
+ base = prefixed(" ".join(w.capitalize() for w in ranked[:3])) if ranked else (main_dir or "Miscellaneous")
307
+ first = skeleton.symbol_words(sorted(members)[0]) if members else ""
308
+ options += [f"{base} ({stem})" if stem else base, f"{base} ({stem}: {first})" if stem else f"{base} ({first})"]
309
+ title = next((t for t in options if normalize_title(t) not in avoid), options[-1])
310
+ capability = f"Units around {', '.join(ranked[:4])}" + (f" in {main_dir}" if main_dir else "") + " (program-made description)."
311
+ return {"title": title, "capability": capability, "summary": capability, "evidence_units": [],
312
+ "program_made": True}
313
+
314
+
315
+ # ------------------------------------------------------------- assemble ----
316
+
317
+ def dependencies(tree: Tree, edges: list[dict]) -> list[dict]:
318
+ """``depends_on`` between sibling nodes, from the unit edges that cross between their subtrees."""
319
+ path_of: dict[str, list[str]] = {}
320
+ for leaf in tree.leaves():
321
+ chain, node = [], leaf
322
+ while node:
323
+ chain.append(node)
324
+ node = tree.nodes[node]["parent"]
325
+ for unit in tree.nodes[leaf]["unit_ids"]:
326
+ path_of[unit] = chain[::-1]
327
+ counts: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
328
+ for edge in edges:
329
+ pa, pb = path_of.get(edge["from"]), path_of.get(edge["to"])
330
+ if not pa or not pb or edge["from"] == edge["to"]:
331
+ continue
332
+ depth = next((i for i, (x, y) in enumerate(zip(pa, pb)) if x != y), None)
333
+ if depth is None:
334
+ continue
335
+ if edge["kind"] in ("call", "import") and edge.get("provenance") != "model_inferred":
336
+ counts[pa[depth], pb[depth]][0] += edge["count"]
337
+ elif edge["kind"] == "relation":
338
+ counts[pa[depth], pb[depth]][1] += edge["count"]
339
+ return [{"from": a, "to": b, "basis": f"{r} resolved call/import edge(s)" if r else f"{m} model-inferred relation(s)"}
340
+ for (a, b), (r, m) in sorted(counts.items()) if r or m]
341
+
342
+
343
+ def slugs(tree: Tree, titles: dict[str, str]) -> dict[str, str]:
344
+ """Node ids people can read and type: the title as lower-case ASCII words joined by hyphens.
345
+
346
+ The first node to use a slug keeps it; a later node, or one whose title has no ASCII letter, gets a
347
+ short hash of its skeleton id (the same input always gives the same ids)."""
348
+ out: dict[str, str] = {}
349
+ taken: set[str] = set()
350
+ for node in tree.order:
351
+ ascii_title = unicodedata.normalize("NFKD", titles[node]).encode("ascii", "ignore").decode()
352
+ base = re.sub(r"[^a-z0-9]+", "-", ascii_title.lower()).strip("-")[:48].strip("-")
353
+ slug, extra = base, 0
354
+ while not slug or slug in taken:
355
+ digest = hashlib.sha1(f"{node}{extra}".encode()).hexdigest()[:5]
356
+ slug, extra = f"{base}-{digest}" if base else f"node-{digest}", extra + 1
357
+ taken.add(slug)
358
+ out[node] = slug
359
+ return out
360
+
361
+
362
+ def assemble(tree: Tree, records: dict[str, dict], units: dict[str, dict], edges: list[dict], repo: str) -> dict:
363
+ """The tree in the published shape. A node without a record gets a program-made one, and so does a
364
+ node whose title a sibling already uses; both carry a ``note``."""
365
+ made: dict[str, dict] = {}
366
+ for node in tree.order:
367
+ record = records.get(node)
368
+ if record is None:
369
+ made[node] = program_record(units, tree.members(node))
370
+ if node == tree.root:
371
+ made[node]["title"] = repo
372
+ else:
373
+ made[node] = dict(record)
374
+ for parent in tree.internals():
375
+ used: set[str] = set()
376
+ for kid in tree.kids[parent]:
377
+ if normalize_title(made[kid]["title"]) in used:
378
+ others = [tree.members(o) for o in tree.kids[parent] if o != kid]
379
+ made[kid] = program_record(units, tree.members(kid), avoid=used | {normalize_title(made[o]["title"]) for o in tree.kids[parent] if o != kid},
380
+ siblings=others)
381
+ made[kid]["note"] = "program-made title (a sibling had the same title)"
382
+ used.add(normalize_title(made[kid]["title"]))
383
+ ids = slugs(tree, {node: made[node]["title"] for node in tree.order})
384
+ nodes = []
385
+ for node in tree.order:
386
+ record = made[node]
387
+ inside = set(tree.members(node))
388
+ parent = tree.nodes[node]["parent"]
389
+ row = {"id": ids[node], "parent": ids[parent] if parent else None, "title": record["title"],
390
+ "capability": record["capability"], "summary": record["summary"],
391
+ "unit_ids": sorted(tree.nodes[node]["unit_ids"]),
392
+ "evidence_units": [u for u in record.get("evidence_units") or [] if u in inside]}
393
+ note = record.get("note") or ("program-made title" if record.get("program_made") else None)
394
+ if note:
395
+ row["note"] = note
396
+ nodes.append(row)
397
+ return {"nodes": nodes, "depends_on": [{**dep, "from": ids[dep["from"]], "to": ids[dep["to"]]}
398
+ for dep in dependencies(tree, edges)]}