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.
Files changed (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. 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]