codegraph-brain 0.6.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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Structural tier ladder and its entropy slope (spec 2026-07-30, #186).
|
|
2
|
+
|
|
3
|
+
Coarsens the graph along its OWN structure — symbol, class, module, then
|
|
4
|
+
directory levels — and measures the 13-triad census at every rung. The number of
|
|
5
|
+
rungs is set by the repository, never by a swept parameter: grain-dependence is
|
|
6
|
+
what retired the closure-gap metric on the same issue.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from cgis.core.models import Edge, EdgeType, Node, NodeType
|
|
14
|
+
from cgis.query.drift.triads import TRIAD_ORDER, normalized_census, tangle_mass, triad_census
|
|
15
|
+
from cgis.storage.sqlite_store import SQLiteStore
|
|
16
|
+
|
|
17
|
+
#: Layers the ladder is measured on, in report order.
|
|
18
|
+
LADDER_LAYERS: tuple[EdgeType, ...] = (EdgeType.IMPORTS, EdgeType.CALLS)
|
|
19
|
+
|
|
20
|
+
#: The only structural edge types in the graph; a parent-walk follows these.
|
|
21
|
+
_STRUCT_EDGES = frozenset({EdgeType.CONTAINS, EdgeType.DECLARES})
|
|
22
|
+
_SYMBOL_TYPES = frozenset({NodeType.FUNCTION, NodeType.METHOD, NodeType.VARIABLE})
|
|
23
|
+
_FILE_TYPES = frozenset({NodeType.FILE, NodeType.MODULE})
|
|
24
|
+
|
|
25
|
+
#: Data-sufficiency floor: a rung below this many triads is reported but not
|
|
26
|
+
#: fitted. It decides whether a rung is OBSERVED, never what the verdict is.
|
|
27
|
+
MIN_RUNG_TRIADS = 10
|
|
28
|
+
|
|
29
|
+
#: Fewer live rungs than this and there is no curve to fit.
|
|
30
|
+
MIN_LIVE_RUNGS = 3
|
|
31
|
+
|
|
32
|
+
#: Group id for a file that has been folded above its top directory.
|
|
33
|
+
ROOT_GROUP = "<root>"
|
|
34
|
+
|
|
35
|
+
Grouping = dict[str, str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parent_map(nodes: list[Node], edges: list[Edge]) -> dict[str, str]:
|
|
39
|
+
"""Child id -> parent id, from CONTAINS / DECLARES edges."""
|
|
40
|
+
ids = {n.id for n in nodes}
|
|
41
|
+
return {e.target: e.source for e in edges if e.type in _STRUCT_EDGES and e.source in ids}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _walk_to(
|
|
45
|
+
node_id: str,
|
|
46
|
+
parents: dict[str, str],
|
|
47
|
+
types: dict[str, NodeType],
|
|
48
|
+
stop: frozenset[NodeType],
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Walk up the containment chain to the nearest ancestor of a stop type.
|
|
51
|
+
|
|
52
|
+
Cycle-guarded: a malformed graph returns the node itself rather than
|
|
53
|
+
looping. A node with no ancestor of a stop type is its own group.
|
|
54
|
+
"""
|
|
55
|
+
seen: set[str] = set()
|
|
56
|
+
current: str | None = node_id
|
|
57
|
+
while current is not None and current not in seen:
|
|
58
|
+
if types.get(current) in stop:
|
|
59
|
+
return current
|
|
60
|
+
seen.add(current)
|
|
61
|
+
current = parents.get(current)
|
|
62
|
+
return node_id
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _directory_parts(nodes: list[Node], file_of: Grouping) -> dict[str, list[str]]:
|
|
66
|
+
"""Node id -> the directory components of its file's path."""
|
|
67
|
+
by_id = {n.id: n for n in nodes}
|
|
68
|
+
parts: dict[str, list[str]] = {}
|
|
69
|
+
for node_id, file_id in file_of.items():
|
|
70
|
+
node = by_id.get(file_id)
|
|
71
|
+
parts[node_id] = node.file_path.split("/")[:-1] if node and node.file_path else []
|
|
72
|
+
return parts
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_ladder(nodes: list[Node], edges: list[Edge]) -> list[tuple[str, Grouping]]:
|
|
76
|
+
"""Return the repository's structural rungs, finest first.
|
|
77
|
+
|
|
78
|
+
``T0_symbol`` is the identity grouping, ``T1_class`` folds symbols into their
|
|
79
|
+
declaring class, ``T2_module`` folds everything into its file, and each
|
|
80
|
+
``Tn_upk`` folds files into a directory with ``k - 1`` components trimmed
|
|
81
|
+
**from the leaf end** — so every file moves at every rung until it bottoms
|
|
82
|
+
out at ``<root>``.
|
|
83
|
+
"""
|
|
84
|
+
parents = _parent_map(nodes, edges)
|
|
85
|
+
types = {n.id: n.type for n in nodes}
|
|
86
|
+
|
|
87
|
+
file_of = {n.id: _walk_to(n.id, parents, types, _FILE_TYPES) for n in nodes}
|
|
88
|
+
class_of = {
|
|
89
|
+
n.id: (
|
|
90
|
+
_walk_to(n.id, parents, types, _FILE_TYPES | {NodeType.CLASS})
|
|
91
|
+
if n.type in _SYMBOL_TYPES
|
|
92
|
+
else n.id
|
|
93
|
+
)
|
|
94
|
+
for n in nodes
|
|
95
|
+
}
|
|
96
|
+
parts = _directory_parts(nodes, file_of)
|
|
97
|
+
|
|
98
|
+
rungs: list[tuple[str, Grouping]] = [
|
|
99
|
+
("T0_symbol", {n.id: n.id for n in nodes}),
|
|
100
|
+
("T1_class", class_of),
|
|
101
|
+
("T2_module", file_of),
|
|
102
|
+
]
|
|
103
|
+
depth = max((len(p) for p in parts.values()), default=0)
|
|
104
|
+
for k in range(1, depth + 1):
|
|
105
|
+
rungs.append(
|
|
106
|
+
(
|
|
107
|
+
f"T{len(rungs)}_up{k}",
|
|
108
|
+
{
|
|
109
|
+
node_id: "/".join(p[: max(len(p) - k + 1, 0)]) or ROOT_GROUP
|
|
110
|
+
for node_id, p in parts.items()
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return rungs
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class RungReport:
|
|
119
|
+
"""One rung of the ladder measured on one layer.
|
|
120
|
+
|
|
121
|
+
``live`` marks a rung with enough triads to enter the fit; thin and
|
|
122
|
+
single-group rungs are still reported so the curve stays readable.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
name: str
|
|
126
|
+
groups: int
|
|
127
|
+
triads: int
|
|
128
|
+
census: tuple[float, ...]
|
|
129
|
+
entropy: float | None
|
|
130
|
+
dominant: str
|
|
131
|
+
dominant_share: float
|
|
132
|
+
tangle_ratio: float
|
|
133
|
+
live: bool
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def entropy_bits(census: tuple[float, ...]) -> float:
|
|
137
|
+
"""Shannon entropy of a normalized census, in bits (max log2(13) ~ 3.70)."""
|
|
138
|
+
return -sum(p * math.log2(p) for p in census if p > 0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _quotient_edges(grouping: Grouping, edges: list[Edge], layer: EdgeType) -> list[Edge]:
|
|
142
|
+
"""Distinct cross-group edges of one layer; self-loops dropped."""
|
|
143
|
+
pairs = {
|
|
144
|
+
(grouping[e.source], grouping[e.target])
|
|
145
|
+
for e in edges
|
|
146
|
+
if e.type is layer
|
|
147
|
+
and e.source in grouping
|
|
148
|
+
and e.target in grouping
|
|
149
|
+
and grouping[e.source] != grouping[e.target]
|
|
150
|
+
}
|
|
151
|
+
return [
|
|
152
|
+
Edge(
|
|
153
|
+
id=f"{u}:{layer.value}:{v}",
|
|
154
|
+
source=u,
|
|
155
|
+
target=v,
|
|
156
|
+
type=layer,
|
|
157
|
+
weight=1.0,
|
|
158
|
+
confidence=1.0,
|
|
159
|
+
)
|
|
160
|
+
for u, v in sorted(pairs)
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _rung_report(name: str, groups: int, counts: dict[str, int]) -> RungReport:
|
|
165
|
+
"""Build one RungReport from a raw triad census."""
|
|
166
|
+
triads = sum(counts.values())
|
|
167
|
+
census = normalized_census(counts)
|
|
168
|
+
dominant, share = (
|
|
169
|
+
max(zip(TRIAD_ORDER, census, strict=True), key=lambda kv: kv[1]) if triads else ("-", 0.0)
|
|
170
|
+
)
|
|
171
|
+
return RungReport(
|
|
172
|
+
name=name,
|
|
173
|
+
groups=groups,
|
|
174
|
+
triads=triads,
|
|
175
|
+
census=census,
|
|
176
|
+
entropy=entropy_bits(census) if triads else None,
|
|
177
|
+
dominant=dominant,
|
|
178
|
+
dominant_share=share,
|
|
179
|
+
tangle_ratio=tangle_mass(census),
|
|
180
|
+
live=triads >= MIN_RUNG_TRIADS and groups > 1,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def measure_layer(nodes: list[Node], edges: list[Edge], layer: EdgeType) -> list[RungReport]:
|
|
185
|
+
"""Measure every rung on one layer, collapsing rungs with identical censuses.
|
|
186
|
+
|
|
187
|
+
The dedup is not an optimization. IMPORTS edges connect only FILE nodes, so
|
|
188
|
+
``T0``/``T1``/``T2`` are literally the same import quotient — counting them
|
|
189
|
+
three times would triple-weight one observation in the fit.
|
|
190
|
+
"""
|
|
191
|
+
rows: list[RungReport] = []
|
|
192
|
+
for name, grouping in build_ladder(nodes, edges):
|
|
193
|
+
groups = set(grouping.values())
|
|
194
|
+
counts = triad_census(groups, _quotient_edges(grouping, edges, layer), layer)
|
|
195
|
+
row = _rung_report(name, len(groups), counts)
|
|
196
|
+
if rows and rows[-1].census == row.census and rows[-1].triads == row.triads:
|
|
197
|
+
continue
|
|
198
|
+
rows.append(row)
|
|
199
|
+
return rows
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
#: Numeric floor below which a sum of squares counts as zero.
|
|
203
|
+
_EPS = 1e-12
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class FractalFit:
|
|
208
|
+
"""Least-squares fit of entropy against log group count."""
|
|
209
|
+
|
|
210
|
+
slope: float
|
|
211
|
+
r_squared: float
|
|
212
|
+
std_error: float
|
|
213
|
+
live_rungs: int
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class FractalReport:
|
|
218
|
+
"""One layer's ladder, its fit and the resulting verdict."""
|
|
219
|
+
|
|
220
|
+
layer: str
|
|
221
|
+
rungs: list[RungReport]
|
|
222
|
+
fit: FractalFit | None
|
|
223
|
+
verdict: str
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def fit_ladder(rungs: list[RungReport]) -> FractalFit | None:
|
|
227
|
+
"""Fit entropy against ``-log2(groups)`` over the live rungs.
|
|
228
|
+
|
|
229
|
+
``x`` increases as the graph coarsens, so a positive slope means coarsening
|
|
230
|
+
ADDS motif diversity. The fit is rung-count invariant by construction: it
|
|
231
|
+
normalizes by actual collapse, not by rung index. Returns None when there is
|
|
232
|
+
no curve to fit.
|
|
233
|
+
"""
|
|
234
|
+
points = [(-math.log2(r.groups), r.entropy) for r in rungs if r.live and r.entropy is not None]
|
|
235
|
+
if len(points) < MIN_LIVE_RUNGS:
|
|
236
|
+
return None
|
|
237
|
+
xs = [x for x, _ in points]
|
|
238
|
+
ys = [y for _, y in points]
|
|
239
|
+
mean_x, mean_y = sum(xs) / len(xs), sum(ys) / len(ys)
|
|
240
|
+
sxx = sum((x - mean_x) ** 2 for x in xs)
|
|
241
|
+
if sxx < _EPS:
|
|
242
|
+
return None
|
|
243
|
+
slope = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys, strict=True)) / sxx
|
|
244
|
+
ss_res = sum((y - (mean_y + slope * (x - mean_x))) ** 2 for x, y in zip(xs, ys, strict=True))
|
|
245
|
+
ss_tot = sum((y - mean_y) ** 2 for y in ys)
|
|
246
|
+
return FractalFit(
|
|
247
|
+
slope=slope,
|
|
248
|
+
# A perfectly flat curve has no variance to explain; report 0.0 rather
|
|
249
|
+
# than NaN so the value stays JSON-serializable.
|
|
250
|
+
r_squared=1.0 - ss_res / ss_tot if ss_tot > _EPS else 0.0,
|
|
251
|
+
std_error=math.sqrt(ss_res / (len(points) - 2) / sxx),
|
|
252
|
+
live_rungs=len(points),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def verdict_of(fit: FractalFit | None) -> str:
|
|
257
|
+
"""Three-way verdict from the sign of the slope with a 2*SE dead-band.
|
|
258
|
+
|
|
259
|
+
The dead-band comes from the fit's own residuals rather than a tuned
|
|
260
|
+
constant: every thresholded verdict in #186's history was falsified by
|
|
261
|
+
re-measurement.
|
|
262
|
+
"""
|
|
263
|
+
if fit is None:
|
|
264
|
+
return "no_signal"
|
|
265
|
+
band = 2.0 * fit.std_error
|
|
266
|
+
if fit.slope > band:
|
|
267
|
+
return "hierarchical"
|
|
268
|
+
if fit.slope < -band:
|
|
269
|
+
return "flat"
|
|
270
|
+
return "scale_invariant"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def analyze_fractal(nodes: list[Node], edges: list[Edge], layer: EdgeType) -> FractalReport:
|
|
274
|
+
"""Measure one layer's ladder and band it."""
|
|
275
|
+
rungs = measure_layer(nodes, edges, layer)
|
|
276
|
+
fit = fit_ladder(rungs)
|
|
277
|
+
return FractalReport(layer=layer.value, rungs=rungs, fit=fit, verdict=verdict_of(fit))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def analyze_fractal_db(db_path: str) -> list[FractalReport]:
|
|
281
|
+
"""Measure every ladder layer from a graph database.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
FileNotFoundError: If ``db_path`` does not point to an existing file.
|
|
285
|
+
Use ``cgis ingest`` to create the graph database first.
|
|
286
|
+
"""
|
|
287
|
+
if not Path(db_path).is_file():
|
|
288
|
+
msg = f"Graph database not found: {db_path}"
|
|
289
|
+
raise FileNotFoundError(msg)
|
|
290
|
+
with SQLiteStore(db_path) as store:
|
|
291
|
+
nodes, edges = store.get_all_nodes(), store.get_all_edges()
|
|
292
|
+
return [analyze_fractal(nodes, edges, layer) for layer in LADDER_LAYERS]
|