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,217 @@
1
+ """Drift-analysis orchestration shared by the CLI and the MCP server."""
2
+
3
+ import dataclasses
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from cgis.core.models import Node
9
+ from cgis.query.drift.drift import DomainConfig, DriftReport, DriftScorer, FitQuality
10
+ from cgis.query.drift.fingerprint import FingerprintExtractor
11
+ from cgis.query.drift.ontology_init import discover_domains
12
+ from cgis.query.drift.quotient import build_quotient
13
+ from cgis.storage.sqlite_store import SQLiteStore
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class DriftAnalysis:
18
+ """Full drift run: per-domain reports plus the quotient (k=1) layer."""
19
+
20
+ reports: list[DriftReport]
21
+ quotient: list[tuple[DomainConfig, DriftReport]]
22
+ any_critical: bool
23
+ # Candidate domain prefixes present in the graph but bound by no project_domain
24
+ # — code drift silently ignores (#177 coverage roll-up).
25
+ coverage: list[str] = field(default_factory=list)
26
+
27
+
28
+ _ALLOWED_PATTERN_SUFFIXES = {".yaml", ".yml"}
29
+
30
+ # Fit-quality bands (#177): residual ≤ _GOOD_RESIDUAL is a clean archetype match;
31
+ # residual > max_residual means no template fits (see _fit_quality).
32
+ _GOOD_RESIDUAL = 0.25
33
+ # Statuses whose fingerprint carries a real, fittable shape.
34
+ _FITTABLE_STATUSES = frozenset({"clean", "warning", "critical", "gate_failed"})
35
+
36
+
37
+ def _fit_quality(
38
+ scorer: DriftScorer, report: DriftReport, profile: str, max_residual: float
39
+ ) -> FitQuality | None:
40
+ """Rank the alphabet against one domain's shape and band the closest fit (#177).
41
+
42
+ Ranking uses the canonical distance (``fit_templates``); the reported
43
+ residuals and band use the gate-FREE ``shape_residual`` so a gate breach
44
+ (e.g. a cycle) never masquerades as "no template fits". Returns None when
45
+ the alphabet declares no templates (a profiles-only config).
46
+ """
47
+ fits = scorer.fit_templates(report.actual, profile)
48
+ if not fits:
49
+ return None
50
+ nearest_name = fits[0][0]
51
+ nearest_res = scorer.shape_residual(report.actual, profile, nearest_name)
52
+ if nearest_res is None:
53
+ # No trusted shape weight (v1 profile / empty census / fully unresolved) —
54
+ # "no signal to fit" is not a perfect match. Leave fit unset.
55
+ return None
56
+ runner_name = fits[1][0] if len(fits) > 1 else None
57
+ runner_res = scorer.shape_residual(report.actual, profile, runner_name) if runner_name else None
58
+ # Keep the good-band reachable even when --max-residual is set below it.
59
+ good_cut = min(_GOOD_RESIDUAL, max_residual)
60
+ band: Literal["good", "weak", "none"]
61
+ if nearest_res > max_residual:
62
+ band = "none"
63
+ elif nearest_res <= good_cut:
64
+ band = "good"
65
+ else:
66
+ band = "weak"
67
+ return FitQuality(
68
+ nearest_template=nearest_name,
69
+ nearest_residual=nearest_res,
70
+ runner_up_template=runner_name,
71
+ runner_up_residual=runner_res,
72
+ band=band,
73
+ )
74
+
75
+
76
+ def _uncovered_prefixes(nodes: list[Node], domain_prefixes: list[str]) -> list[str]:
77
+ """Discovered domain candidates that overlap no declared domain prefix (#177).
78
+
79
+ A candidate is "covered" when it shares a subtree with a declared domain
80
+ (either is an ancestor-or-equal of the other). What remains is graph code
81
+ no ``project_domain`` binds — exactly what drift silently skips.
82
+ """
83
+ return sorted(
84
+ candidate
85
+ for candidate in discover_domains(nodes)
86
+ if not any(
87
+ candidate == d or candidate.startswith(f"{d}.") or d.startswith(f"{candidate}.")
88
+ for d in domain_prefixes
89
+ )
90
+ )
91
+
92
+
93
+ def _empty_note(store: SQLiteStore, fqn_prefix: str) -> str:
94
+ """Return a 'matched 0 nodes' diagnostic with closest-prefix suggestions (spec §2.4).
95
+
96
+ Short-circuits on blank prefixes (no DB query). Tries the full prefix as a
97
+ dot-boundary suffix first, then its last segment; caps at 3 suggestions.
98
+ """
99
+ base = f"fqn_prefix '{fqn_prefix}' matched 0 nodes"
100
+ if not fqn_prefix.strip():
101
+ return base
102
+ matches = store.find_nodes_by_suffix(fqn_prefix, limit=3)
103
+ if not matches:
104
+ last_segment = fqn_prefix.rsplit(".", maxsplit=1)[-1]
105
+ if last_segment.strip():
106
+ matches = store.find_nodes_by_suffix(last_segment, limit=3)
107
+ if not matches:
108
+ return base
109
+ ids = sorted(n.id for n in matches)[:3]
110
+ return f"{base}; did you mean: {', '.join(ids)}?"
111
+
112
+
113
+ def analyze_drift(
114
+ db_path: str,
115
+ patterns_path: str,
116
+ max_drift: float = 0.50,
117
+ profile: str | None = None,
118
+ max_residual: float = 0.45,
119
+ ) -> DriftAnalysis:
120
+ """Score every project domain (and the quotient level) against patterns.
121
+
122
+ When ``profile`` is set, only domains (and project_level bindings) whose
123
+ ``binding.profile == profile`` (or ``binding.profile is None``) are scored.
124
+ Profile-less domains match any filter — they carry language-agnostic hygiene
125
+ rules that should apply regardless of which language graph is being measured.
126
+ Omitting ``profile`` retains the score-everything default.
127
+
128
+ ``any_critical`` is True when any ENFORCED binding has status in
129
+ ``{"critical", "gate_failed", "empty"}`` (spec §2.3, #170B). The
130
+ per-domain ``drift_tolerance`` determines whether a score yields
131
+ ``"critical"``; ``max_drift`` is the fallback default tolerance for domains
132
+ that do not declare their own (``drift_tolerance`` is no longer a global cap).
133
+ ``"no_signal"`` never trips the gate. ``enforce: false`` stays observe-only.
134
+
135
+ For each ``"empty"`` report, the ``note`` field is decorated with closest-prefix
136
+ suggestions from the suffix index (spec §2.4).
137
+
138
+ Each profiled report with a real shape also carries ``fit`` (#177): the nearest
139
+ alphabet template + residual, the runner-up, and a ``good|weak|none`` band
140
+ (``none`` when the nearest residual exceeds ``max_residual`` — no archetype
141
+ captures the domain). ``DriftAnalysis.coverage`` lists graph prefixes bound by
142
+ no project_domain (code drift silently ignores).
143
+
144
+ Raises:
145
+ FileNotFoundError: If ``db_path`` does not point to an existing file.
146
+ Use ``cgis ingest`` to create the graph database first.
147
+ ValueError: If ``patterns_path`` does not end in ``.yaml`` or ``.yml``.
148
+ FileNotFoundError: If ``patterns_path`` does not point to an existing file.
149
+ """
150
+ if not Path(db_path).is_file():
151
+ msg = f"Graph database not found: {db_path}"
152
+ raise FileNotFoundError(msg)
153
+ resolved = Path(patterns_path).resolve()
154
+ if resolved.suffix.lower() not in _ALLOWED_PATTERN_SUFFIXES:
155
+ msg = f"patterns_path must be a .yaml or .yml file, got: {patterns_path!r}"
156
+ raise ValueError(msg)
157
+ if not resolved.is_file():
158
+ msg = f"Patterns file not found: {patterns_path}"
159
+ raise FileNotFoundError(msg)
160
+ scorer = DriftScorer(str(resolved))
161
+ domains = scorer.load_project_domains()
162
+ if profile is not None:
163
+ domains = [d for d in domains if d.profile is None or d.profile == profile]
164
+
165
+ quotient: list[tuple[DomainConfig, DriftReport]] = []
166
+ with SQLiteStore(db_path) as store:
167
+ extractor = FingerprintExtractor(store)
168
+ reports = [
169
+ scorer.score(extractor.extract(domain.fqn_prefix), domain, default_tolerance=max_drift)
170
+ for domain in domains
171
+ ]
172
+ reports = [
173
+ dataclasses.replace(r, note=_empty_note(store, r.fqn_prefix))
174
+ if r.status == "empty"
175
+ else r
176
+ for r in reports
177
+ ]
178
+ # Annotate fit-quality for domains with a real shape and a profile (#177).
179
+ reports = [
180
+ dataclasses.replace(r, fit=_fit_quality(scorer, r, d.profile, max_residual))
181
+ if d.profile is not None and r.status in _FITTABLE_STATUSES
182
+ else r
183
+ for d, r in zip(domains, reports, strict=True)
184
+ ]
185
+ all_nodes = store.get_all_nodes() # one materialization, reused below (#177 #4)
186
+ coverage = _uncovered_prefixes(all_nodes, [d.fqn_prefix for d in domains])
187
+ level_bindings = scorer.load_project_level()
188
+ if profile is not None:
189
+ level_bindings = [
190
+ b for b in level_bindings if b.profile is None or b.profile == profile
191
+ ]
192
+ if level_bindings:
193
+ qnodes, qedges = build_quotient(all_nodes, store.get_all_edges(), domains)
194
+ q_extractor = FingerprintExtractor.from_graph(qnodes, qedges)
195
+ quotient = [
196
+ (b, scorer.score(q_extractor.extract(b.fqn_prefix), b, default_tolerance=max_drift))
197
+ for b in level_bindings
198
+ ]
199
+ quotient = [
200
+ (b, dataclasses.replace(r, note=_empty_note(store, r.fqn_prefix)))
201
+ if r.status == "empty"
202
+ else (b, r)
203
+ for b, r in quotient
204
+ ]
205
+
206
+ # Gate is now uniformly status-based and enforce-respecting (spec §2.3,
207
+ # #170B): 'critical' and 'gate_failed' on enforced bindings trip any_critical;
208
+ # 'empty' on enforced bindings also trips it (broken fqn_prefix must not
209
+ # silently pass CI). 'no_signal' never trips. enforce:false stays observe-only.
210
+ any_critical = any(
211
+ r.status in ("critical", "gate_failed", "empty")
212
+ for d, r in zip(domains, reports, strict=True)
213
+ if d.enforce
214
+ ) or any(r.status in ("critical", "gate_failed", "empty") for b, r in quotient if b.enforce)
215
+ return DriftAnalysis(
216
+ reports=reports, quotient=quotient, any_critical=any_critical, coverage=coverage
217
+ )
@@ -0,0 +1,255 @@
1
+ """PatternFingerprint dataclass and FingerprintExtractor."""
2
+
3
+ from collections import Counter
4
+ from dataclasses import dataclass, field
5
+
6
+ from cgis.core.models import Edge, EdgeType, Node, NodeType
7
+ from cgis.query.analysis.health import HealthScorer
8
+ from cgis.query.drift._scc import build_adjacency, tarjan_scc
9
+ from cgis.query.drift.triads import ZERO_TRIADS, normalized_census, tangle_mass, triad_census
10
+ from cgis.storage.sqlite_store import SQLiteStore
11
+
12
+ RAW_CALL_PREFIX = "raw_call:"
13
+
14
+ _HUB_FAN_IN_THRESHOLD = 2
15
+ _STAR_FAN_OUT_THRESHOLD = 3
16
+ _ROUTER_FAN_OUT_THRESHOLD = 2
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PatternFingerprint:
21
+ """Structural fingerprint for a domain: seven v1 counters plus two v2 census vectors."""
22
+
23
+ domain: str
24
+
25
+ hub_count: int
26
+ star_count: int
27
+ chain_len: float
28
+ dag_depth: int
29
+ router_count: int
30
+
31
+ cycle_ratio: float
32
+ unresolved_ratio: float
33
+
34
+ # Fingerprint v2 (spec §3.2): normalized 13-triad census per layer,
35
+ # ordered by triads.TRIAD_ORDER. Zero vector = empty layer ("no data").
36
+ t_imports: tuple[float, ...] = field(default=ZERO_TRIADS)
37
+ t_calls: tuple[float, ...] = field(default=ZERO_TRIADS)
38
+
39
+ # How many graph nodes / intra-domain edges the selector matched.
40
+ # Defaults are 1 ("hand-built fingerprints are assumed measurable",
41
+ # spec Amendment 1); only extract() produces real zeros. 0 nodes means
42
+ # the fqn_prefix selected nothing; 0 edges with >0 nodes means there is
43
+ # no structure to score (isolated symbols / alias-only matches).
44
+ node_count: int = 1
45
+ edge_count: int = 1
46
+
47
+ @property
48
+ def tangle_ratio(self) -> float:
49
+ """Worst-layer normalized mutual-motif mass, in [0, 1] (spec #186).
50
+
51
+ max over the IMPORTS and CALLS census of tangle_mass — a hard hygiene
52
+ signal: a breach in either layer is a breach. 0 for empty or
53
+ antisymmetric (no-mutual-dyad) layers.
54
+
55
+ The CALLS layer is faded by the confidence discount
56
+ ``clip(1 - unresolved_ratio)`` before the max (#244), mirroring the
57
+ drift-distance path (``_clip_discount`` / ``_score_v2``): ``t_calls`` is a
58
+ census over RESOLVED intra-domain calls only, so a sparse/mostly-unresolved
59
+ call graph would otherwise over-report calls-tangle and trip the gate on
60
+ thin evidence. The IMPORTS layer (always resolved) is never discounted.
61
+ """
62
+ calls_discount = max(0.0, min(1.0 - self.unresolved_ratio, 1.0))
63
+ return max(tangle_mass(self.t_imports), calls_discount * tangle_mass(self.t_calls))
64
+
65
+
66
+ def _in_domain(fqn: str, prefix: str) -> bool:
67
+ """Return True iff fqn is the prefix itself or a child of it (segment-boundary aware)."""
68
+ return fqn == prefix or fqn.startswith(prefix + ".")
69
+
70
+
71
+ def _root_depths(domain_ids: set[str], edges: list[Edge], edge_type: EdgeType) -> list[int]:
72
+ """Longest-path depth from each root node along edges of edge_type within the domain.
73
+
74
+ A root is a domain node with outgoing edges of the given type but no incoming ones.
75
+ Uses memoized DFS (cycle-safe); roots are sorted so results are deterministic.
76
+ """
77
+ adj: dict[str, list[str]] = {}
78
+ has_incoming: set[str] = set()
79
+ for e in edges:
80
+ if e.type == edge_type:
81
+ adj.setdefault(e.source, []).append(e.target)
82
+ has_incoming.add(e.target)
83
+
84
+ roots = sorted(n for n in domain_ids if n not in has_incoming and n in adj)
85
+ if not roots:
86
+ return []
87
+
88
+ memo: dict[str, int] = {}
89
+ visiting: set[str] = set()
90
+
91
+ def dfs(u: str) -> int:
92
+ if u in memo:
93
+ return memo[u]
94
+ if u in visiting:
95
+ return 0
96
+ visiting.add(u)
97
+ val = max((1 + dfs(v) for v in adj.get(u, [])), default=0)
98
+ visiting.remove(u)
99
+ memo[u] = val
100
+ return val
101
+
102
+ return [dfs(root) for root in roots]
103
+
104
+
105
+ def _avg_chain_length(domain_ids: set[str], internal_calls: list[Edge]) -> float:
106
+ """Average longest-path depth from each source node along CALLS edges within the domain."""
107
+ depths = _root_depths(domain_ids, internal_calls, EdgeType.CALLS)
108
+ return sum(depths) / len(depths) if depths else 0.0
109
+
110
+
111
+ def _max_dag_depth(domain_ids: set[str], internal_edges: list[Edge]) -> int:
112
+ """Max longest-path depth along IMPORTS edges within the domain (from import-root nodes)."""
113
+ depths = _root_depths(domain_ids, internal_edges, EdgeType.IMPORTS)
114
+ return max(depths) if depths else 0
115
+
116
+
117
+ def _count_routers(domain_node_ids: set[str], all_edges: list[Edge]) -> int:
118
+ """Count domain nodes with fan_out > 2 whose CALLS targets don't import them."""
119
+ imported_by: dict[str, set[str]] = {}
120
+ calls_by_source: dict[str, list[str]] = {}
121
+ for e in all_edges:
122
+ if e.type == EdgeType.IMPORTS:
123
+ imported_by.setdefault(e.target, set()).add(e.source)
124
+ elif e.type == EdgeType.CALLS:
125
+ calls_by_source.setdefault(e.source, []).append(e.target)
126
+
127
+ router_count = 0
128
+ for node_id in domain_node_ids:
129
+ calls_targets = calls_by_source.get(node_id, [])
130
+ if len(calls_targets) <= _ROUTER_FAN_OUT_THRESHOLD:
131
+ continue
132
+ node_importers = imported_by.get(node_id, set())
133
+ if not (set(calls_targets) & node_importers):
134
+ router_count += 1
135
+
136
+ return router_count
137
+
138
+
139
+ class FingerprintExtractor:
140
+ """Compute a PatternFingerprint for a given FQN domain prefix from a SQLiteStore."""
141
+
142
+ def __init__(self, store: SQLiteStore | None) -> None:
143
+ """Accept an open SQLiteStore, or None when built via from_graph()."""
144
+ self._store = store
145
+ self._cache: tuple[list[Node], list[Edge]] | None = None
146
+
147
+ @classmethod
148
+ def from_graph(cls, nodes: list[Node], edges: list[Edge]) -> "FingerprintExtractor":
149
+ """Build an extractor over an in-memory graph — no SQLiteStore involved.
150
+
151
+ The inputs are copied defensively, so later mutation of the caller's
152
+ lists cannot change what extract() measures (matches the store path,
153
+ which returns fresh lists on every fetch).
154
+ """
155
+ inst = cls(None)
156
+ inst._cache = (HealthScorer(nodes, edges).enrich(), list(edges))
157
+ return inst
158
+
159
+ def _loaded(self) -> tuple[list[Node], list[Edge]]:
160
+ """Return (enriched_nodes, all_edges), fetching from the store once and caching."""
161
+ if self._cache is None:
162
+ if self._store is None:
163
+ msg = "FingerprintExtractor needs a store or a from_graph() preload."
164
+ raise RuntimeError(msg)
165
+ all_nodes = self._store.get_all_nodes()
166
+ all_edges = self._store.get_all_edges()
167
+ self._cache = (HealthScorer(all_nodes, all_edges).enrich(), all_edges)
168
+ return self._cache
169
+
170
+ def extract(self, fqn_prefix: str) -> PatternFingerprint:
171
+ """Return the structural fingerprint for all nodes under fqn_prefix."""
172
+ enriched_all, all_edges = self._loaded()
173
+
174
+ domain_nodes = [n for n in enriched_all if _in_domain(n.id, fqn_prefix)]
175
+ if not domain_nodes:
176
+ return PatternFingerprint(
177
+ domain=fqn_prefix,
178
+ hub_count=0,
179
+ star_count=0,
180
+ chain_len=0.0,
181
+ dag_depth=0,
182
+ router_count=0,
183
+ cycle_ratio=0.0,
184
+ unresolved_ratio=0.0,
185
+ node_count=0,
186
+ edge_count=0,
187
+ )
188
+
189
+ domain_ids = {n.id for n in domain_nodes}
190
+
191
+ internal_edges = [e for e in all_edges if e.source in domain_ids and e.target in domain_ids]
192
+ domain_outgoing = [e for e in all_edges if e.source in domain_ids]
193
+
194
+ # Fan-in/fan-out over intra-domain CALLS only — external targets
195
+ # (stdlib, other domains) would otherwise inflate star/hub counts.
196
+ fan_in = Counter(e.target for e in internal_edges if e.type == EdgeType.CALLS)
197
+ fan_out = Counter(e.source for e in internal_edges if e.type == EdgeType.CALLS)
198
+
199
+ hub_count = sum(
200
+ 1 for n in domain_nodes if fan_in[n.id] > _HUB_FAN_IN_THRESHOLD and fan_out[n.id] == 0
201
+ )
202
+ star_count = sum(
203
+ 1 for n in domain_nodes if fan_out[n.id] > _STAR_FAN_OUT_THRESHOLD and fan_in[n.id] <= 1
204
+ )
205
+ chain_len = _avg_chain_length(domain_ids, internal_edges)
206
+ dag_depth = _max_dag_depth(domain_ids, internal_edges)
207
+ router_count = _count_routers(domain_ids, all_edges)
208
+
209
+ cycle_ratio = self._intra_domain_cycle_ratio(domain_nodes, all_edges)
210
+
211
+ calls_edges = [e for e in domain_outgoing if e.type == EdgeType.CALLS]
212
+ raw_calls = [e for e in calls_edges if e.target.startswith(RAW_CALL_PREFIX)]
213
+ unresolved_ratio = len(raw_calls) / len(calls_edges) if calls_edges else 0.0
214
+
215
+ t_imports = normalized_census(triad_census(domain_ids, internal_edges, EdgeType.IMPORTS))
216
+ t_calls = normalized_census(triad_census(domain_ids, internal_edges, EdgeType.CALLS))
217
+
218
+ return PatternFingerprint(
219
+ domain=fqn_prefix,
220
+ hub_count=hub_count,
221
+ star_count=star_count,
222
+ chain_len=chain_len,
223
+ dag_depth=dag_depth,
224
+ router_count=router_count,
225
+ cycle_ratio=cycle_ratio,
226
+ unresolved_ratio=unresolved_ratio,
227
+ t_imports=t_imports,
228
+ t_calls=t_calls,
229
+ node_count=len(domain_nodes),
230
+ edge_count=len(internal_edges),
231
+ )
232
+
233
+ def _intra_domain_cycle_ratio(self, domain_nodes: list[Node], all_edges: list[Edge]) -> float:
234
+ """Blast radius of the domain's OWN import cycles (spec §2.1, #176).
235
+
236
+ Tarjan SCC over IMPORTS edges whose endpoints are both FILE/MODULE
237
+ nodes of this domain; the ratio counts domain nodes living in files
238
+ that participate in an intra-domain cycle. Single-file domains are
239
+ 0.0 by construction; cross-domain cycles are the quotient layer's
240
+ concern and never count here.
241
+ """
242
+ file_types = {NodeType.FILE, NodeType.MODULE}
243
+ domain_files = {n.id for n in domain_nodes if n.type in file_types}
244
+ if len(domain_files) < 2:
245
+ return 0.0
246
+ adj = build_adjacency(all_edges, frozenset({EdgeType.IMPORTS}))
247
+ adj = {
248
+ k: [v for v in vs if v in domain_files] for k, vs in adj.items() if k in domain_files
249
+ }
250
+ cyclic_files = {n for scc in tarjan_scc(adj) if len(scc) > 1 for n in scc}
251
+ if not cyclic_files:
252
+ return 0.0
253
+ cyclic_paths = {n.file_path for n in domain_nodes if n.id in cyclic_files}
254
+ cycle_count = sum(1 for n in domain_nodes if n.file_path in cyclic_paths)
255
+ return cycle_count / len(domain_nodes)