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/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)]}
|