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,867 @@
1
+ """DomainConfig, DriftReport, and DriftScorer for architectural drift measurement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ import yaml
10
+
11
+ from cgis.query.drift.fingerprint import PatternFingerprint
12
+ from cgis.query.drift.triads import TRIAD_ORDER, ZERO_TRIADS, tv_distance
13
+
14
+ _COMPONENT_NAMES = (
15
+ "hub_count",
16
+ "star_count",
17
+ "chain_len",
18
+ "dag_depth",
19
+ "router_count",
20
+ "cycle_ratio",
21
+ "unresolved_ratio",
22
+ "tangle_ratio",
23
+ )
24
+
25
+ _CALLS_LAYER = frozenset({"hub_count", "star_count", "chain_len", "router_count"})
26
+
27
+ _TRIAD_VIOLATION_THRESHOLD = 0.05
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DomainConfig:
32
+ """Project-level domain expectation loaded from patterns.yaml."""
33
+
34
+ name: str
35
+ fqn_prefix: str
36
+ expected_pattern: str | None
37
+ drift_tolerance: float | None = None
38
+ profile: str | None = None
39
+ params: dict[str, float] = field(default_factory=dict)
40
+ # Acknowledged hygiene debt: per-constraint relaxed bound (ratchet-down
41
+ # convention; values may only decrease over time). Spec §2.2.
42
+ hygiene_baseline: dict[str, float] = field(default_factory=dict)
43
+ # When False the domain is audited but violations never block CI.
44
+ enforce: bool = True
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class FitQuality:
49
+ """How well the closest alphabet template matches a domain's shape (#177).
50
+
51
+ ``residual`` is the domain's tolerance-free distance to a template's ideal
52
+ (``DriftScorer.fit_templates``) — it answers "clean because it MATCHES an
53
+ archetype" vs "clean because tolerance is loose". ``band``:
54
+ ``good`` (≤ good-threshold), ``weak``, or ``none`` (> max_residual — no
55
+ template in the closed alphabet captures this domain: a genuine grab-bag,
56
+ a mesh/tangle, or a gap the alphabet should fill).
57
+ """
58
+
59
+ nearest_template: str
60
+ nearest_residual: float
61
+ runner_up_template: str | None
62
+ runner_up_residual: float | None
63
+ band: Literal["good", "weak", "none"]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class DriftReport:
68
+ """Per-domain drift analysis result."""
69
+
70
+ domain: str
71
+ fqn_prefix: str
72
+ expected_pattern: str | None
73
+ actual: PatternFingerprint
74
+ ideal: PatternFingerprint
75
+ drift_score: float
76
+ violations: list[str]
77
+ status: Literal["clean", "warning", "critical", "gate_failed", "empty", "no_signal"]
78
+ tolerance: float
79
+ tv_imports: float | None = None
80
+ tv_calls: float | None = None
81
+ # Human-readable diagnostic (e.g. closest-prefix suggestions for "empty").
82
+ note: str | None = None
83
+ # Fit quality vs the alphabet (#177); None for hygiene-only/empty/no_signal.
84
+ fit: FitQuality | None = None
85
+
86
+
87
+ def _clip_discount(actual: PatternFingerprint) -> float:
88
+ """Confidence discount clip(1 - unresolved_ratio) to [0, 1].
89
+
90
+ Clipped defensively: a hand-built fingerprint could carry a ratio outside
91
+ [0, 1]; negative effective weights must never occur.
92
+ """
93
+ return max(0.0, min(1.0 - actual.unresolved_ratio, 1.0))
94
+
95
+
96
+ def _classify(score: float, tolerance: float) -> Literal["clean", "warning", "critical"]:
97
+ """Status from the score RELATIVE to the binding's effective tolerance (#170B)."""
98
+ if score > tolerance:
99
+ return "critical"
100
+ if score > 0.75 * tolerance:
101
+ return "warning"
102
+ return "clean"
103
+
104
+
105
+ def _validate_mapping(node: object, owner: str) -> dict[str, Any]:
106
+ """Return node unchanged after validating it is a mapping.
107
+
108
+ Raises TypeError otherwise (e.g. a YAML block hand-edited into a list).
109
+ """
110
+ if not isinstance(node, dict):
111
+ msg = f"{owner} must be a mapping, got {type(node).__name__}."
112
+ raise TypeError(msg)
113
+ return node
114
+
115
+
116
+ def _params_mapping(container: dict[str, Any], owner: str) -> dict[str, Any]:
117
+ """Return the container's params block, validating it is a mapping.
118
+
119
+ Raises TypeError if params is present but not a mapping (e.g. a list).
120
+ """
121
+ return _validate_mapping(container.get("params") or {}, f"{owner} params")
122
+
123
+
124
+ class DriftScorer:
125
+ """Load patterns.yaml and score actual PatternFingerprints against ideal templates.
126
+
127
+ Global hygiene invariants apply to every domain (template constraints win per
128
+ component); CALLS-derived component weights are discounted by
129
+ (1 - unresolved_ratio) before renormalization.
130
+ """
131
+
132
+ def __init__(self, patterns_config: str) -> None:
133
+ """Load and parse the patterns YAML file at patterns_config path."""
134
+ content = yaml.safe_load(Path(patterns_config).read_text())
135
+ raw: dict[str, Any] = content if isinstance(content, dict) else {}
136
+ self._weights: dict[str, float] = raw.get("drift_weights") or {}
137
+ self._patterns: dict[str, dict[str, Any]] = raw.get("patterns") or {}
138
+ self._project_domains: list[dict[str, Any]] = raw.get("project_domains") or []
139
+ # Measurement profiles (spec §2.3) and global hygiene invariants (§2.1).
140
+ self._profiles: dict[str, dict[str, Any]] = raw.get("profiles") or {}
141
+ self._hygiene: dict[str, Any] = raw.get("hygiene") or {}
142
+ self._project_level: list[dict[str, Any]] = raw.get("project_level") or []
143
+
144
+ @staticmethod
145
+ def _load_params(d: dict[str, Any]) -> dict[str, float]:
146
+ """Parse a domain binding's params block; non-numeric values fail loud.
147
+
148
+ Raises TypeError if any param value is not numeric (int, float, or bool).
149
+ """
150
+ params: dict[str, float] = {}
151
+ for k, v in _params_mapping(d, f"Domain '{d.get('name', '?')}'").items():
152
+ if not isinstance(v, (int, float)):
153
+ msg = f"Domain '{d.get('name', '?')}' param '{k}' must be numeric, got {v!r}."
154
+ raise TypeError(msg)
155
+ params[k] = float(v)
156
+ return params
157
+
158
+ def _build_domain_config(self, d: dict[str, Any], *, enforce_default: bool) -> DomainConfig:
159
+ """Build one DomainConfig from a YAML binding dict."""
160
+ raw_tol = d.get("drift_tolerance")
161
+ tolerance = float(raw_tol) if raw_tol is not None else None
162
+ hygiene_baseline = self._parse_hygiene_baseline(d)
163
+ return DomainConfig(
164
+ name=d["name"],
165
+ fqn_prefix=d["fqn_prefix"],
166
+ expected_pattern=d.get("expected_pattern"),
167
+ drift_tolerance=tolerance,
168
+ profile=d.get("profile"),
169
+ params=self._load_params(d),
170
+ hygiene_baseline=hygiene_baseline,
171
+ enforce=bool(d.get("enforce", enforce_default)),
172
+ )
173
+
174
+ def _parse_hygiene_baseline(self, d: dict[str, Any]) -> dict[str, float]:
175
+ """Parse and validate the hygiene_baseline block from a domain binding dict.
176
+
177
+ Raises ValueError if any baseline key is not a valid hygiene constraint key.
178
+ """
179
+ raw = d.get("hygiene_baseline")
180
+ if raw is None:
181
+ return {}
182
+ mapping = _validate_mapping(raw, f"Domain '{d.get('name', '?')}' hygiene_baseline")
183
+ hygiene_keys = set(self._parse_constraints(self._hygiene, {}).keys())
184
+ result: dict[str, float] = {}
185
+ for key, val in mapping.items():
186
+ if key not in hygiene_keys:
187
+ valid = sorted(hygiene_keys)
188
+ msg = (
189
+ f"hygiene_baseline key '{key}' in domain '{d.get('name', '?')}' "
190
+ f"names no hygiene constraint; valid keys: {valid}"
191
+ )
192
+ raise ValueError(msg)
193
+ result[key] = float(val)
194
+ return result
195
+
196
+ def load_project_domains(self) -> list[DomainConfig]:
197
+ """Return all project domains declared in patterns.yaml."""
198
+ return [self._build_domain_config(d, enforce_default=True) for d in self._project_domains]
199
+
200
+ def load_project_level(self) -> list[DomainConfig]:
201
+ """Return project-level quotient bindings (spec §3.4); enforce defaults False here."""
202
+ return [self._build_domain_config(d, enforce_default=False) for d in self._project_level]
203
+
204
+ def _weights_for(self, domain: DomainConfig) -> dict[str, float]:
205
+ """Return the drift weights for a domain: its profile's, or the top-level default.
206
+
207
+ If domain.profile is set, look it up in self._profiles and return its
208
+ drift_weights. If profile is not found, raise ValueError. If domain.profile
209
+ is None, return the top-level self._weights.
210
+
211
+ Raises ValueError if the named profile does not exist in the config.
212
+ """
213
+ if domain.profile is None:
214
+ return self._weights
215
+ profile = self._profiles.get(domain.profile)
216
+ if profile is None:
217
+ msg = f"Domain '{domain.name}' names unknown profile '{domain.profile}'."
218
+ raise ValueError(msg)
219
+ weights: dict[str, float] = profile.get("drift_weights") or {}
220
+ return weights
221
+
222
+ def ideal_for(self, pattern_name: str) -> tuple[tuple[float, ...], tuple[float, ...]] | None:
223
+ """Return (ideal_imports, ideal_calls) 13-tuples for a template, or None.
224
+
225
+ None means the template declares no ideal block — the domain scores on
226
+ the v1 path. Raises ValueError on unknown triad keys, layers other
227
+ than imports/calls, or a layer that does not sum to 1.0.
228
+ """
229
+ template = self._patterns.get(pattern_name) or {}
230
+ ideal = template.get("ideal")
231
+ if ideal is None:
232
+ return None
233
+ if not isinstance(ideal, dict) or set(ideal) != {"imports", "calls"}:
234
+ msg = f"Pattern '{pattern_name}' ideal must declare exactly imports and calls."
235
+ raise ValueError(msg)
236
+ return self._ideal_layer(pattern_name, ideal["imports"]), self._ideal_layer(
237
+ pattern_name, ideal["calls"]
238
+ )
239
+
240
+ @staticmethod
241
+ def _ideal_layer(pattern_name: str, layer: dict[str, Any]) -> tuple[float, ...]:
242
+ """Convert one {triad: share} mapping into a TRIAD_ORDER-aligned tuple."""
243
+ _validate_mapping(layer, f"Pattern '{pattern_name}' ideal layer")
244
+ unknown = set(layer) - set(TRIAD_ORDER)
245
+ if unknown:
246
+ msg = f"Pattern '{pattern_name}' ideal names unknown triad(s) {sorted(unknown)}."
247
+ raise ValueError(msg)
248
+ values = tuple(float(layer.get(name, 0.0)) for name in TRIAD_ORDER)
249
+ if abs(sum(values) - 1.0) > 1e-9:
250
+ msg = f"Pattern '{pattern_name}' ideal layer must sum to 1.0, got {sum(values)}."
251
+ raise ValueError(msg)
252
+ return values
253
+
254
+ def layers_for(self, profile_name: str) -> dict[str, float] | None:
255
+ """Return validated layer weights for a profile, or None when undeclared."""
256
+ profile = self._profiles.get(profile_name) or {}
257
+ layers = profile.get("layers")
258
+ if layers is None:
259
+ return None
260
+ _validate_mapping(layers, f"Profile '{profile_name}' layers")
261
+ if set(layers) != {"imports", "calls", "gates"}:
262
+ msg = f"Profile '{profile_name}' layers must declare imports, calls, gates."
263
+ raise ValueError(msg)
264
+ result = {k: float(v) for k, v in layers.items()}
265
+ if any(v < 0.0 for v in result.values()):
266
+ msg = f"Profile '{profile_name}' layers must be non-negative, got {result}."
267
+ raise ValueError(msg)
268
+ if abs(sum(result.values()) - 1.0) > 1e-9:
269
+ msg = f"Profile '{profile_name}' layers must sum to 1.0, got {sum(result.values())}."
270
+ raise ValueError(msg)
271
+ return result
272
+
273
+ def triad_weights_for(self, profile_name: str) -> tuple[float, ...]:
274
+ """Per-triad w_i for a profile; unlisted triads default to 1.0 (spec §3.3)."""
275
+ profile = self._profiles.get(profile_name) or {}
276
+ declared: dict[str, Any] = _validate_mapping(
277
+ profile.get("triad_weights") or {}, f"Profile '{profile_name}' triad_weights"
278
+ )
279
+ unknown = set(declared) - set(TRIAD_ORDER)
280
+ if unknown:
281
+ msg = (
282
+ f"Profile '{profile_name}' triad_weights names unknown triad(s) {sorted(unknown)}."
283
+ )
284
+ raise ValueError(msg)
285
+ weights = tuple(float(declared.get(name, 1.0)) for name in TRIAD_ORDER)
286
+ if any(w < 0.0 for w in weights):
287
+ msg = f"Profile '{profile_name}' triad_weights must be non-negative."
288
+ raise ValueError(msg)
289
+ return weights
290
+
291
+ @staticmethod
292
+ def _merge_params(template: dict[str, Any], domain: DomainConfig) -> dict[str, float]:
293
+ """Merge template parameter defaults with domain overrides; unknown keys fail loud.
294
+
295
+ Raises ValueError if domain.params contains keys not declared in template.params.
296
+ """
297
+ declared = {
298
+ k: float(v)
299
+ for k, v in _params_mapping(template, f"Pattern '{domain.expected_pattern}'").items()
300
+ }
301
+ unknown = set(domain.params) - set(declared)
302
+ if unknown:
303
+ msg = (
304
+ f"Domain '{domain.name}' overrides undeclared parameter(s) "
305
+ f"{sorted(unknown)} for pattern '{domain.expected_pattern}'."
306
+ )
307
+ raise ValueError(msg)
308
+ return {**declared, **domain.params}
309
+
310
+ @staticmethod
311
+ def _resolve_value(value: str | float | int, params: dict[str, float]) -> float:
312
+ """Return a numeric constraint value, substituting a $name placeholder if present.
313
+
314
+ Raises ValueError if a $placeholder references an undeclared parameter.
315
+ """
316
+ if isinstance(value, str) and value.startswith("$"):
317
+ key = value[1:]
318
+ if key not in params:
319
+ msg = f"Constraint placeholder '${key}' has no declared parameter."
320
+ raise ValueError(msg)
321
+ return params[key]
322
+ return float(value)
323
+
324
+ def _resolve_template(self, domain: DomainConfig) -> tuple[dict[str, Any], dict[str, float]]:
325
+ """Return the domain's bound template and merged params; ({}, {}) when hygiene-only.
326
+
327
+ Raises ValueError if the named pattern is missing and TypeError if it
328
+ is not a mapping of constraints.
329
+ """
330
+ if domain.expected_pattern is None:
331
+ return {}, {}
332
+ found = self._patterns.get(domain.expected_pattern)
333
+ if found is None:
334
+ msg = f"Expected pattern '{domain.expected_pattern}' not found in patterns config."
335
+ raise ValueError(msg)
336
+ if not isinstance(found, dict):
337
+ msg = f"Pattern '{domain.expected_pattern}' must be a mapping of constraints."
338
+ raise TypeError(msg)
339
+ return found, self._merge_params(found, domain)
340
+
341
+ def score(
342
+ self,
343
+ actual: PatternFingerprint,
344
+ domain: DomainConfig,
345
+ default_tolerance: float = 0.50,
346
+ ) -> DriftReport:
347
+ """Compute the drift score and return a DriftReport (v2 when configured).
348
+
349
+ Hygiene and template constraints are evaluated SEPARATELY (spec §2.2):
350
+ hygiene violations against operator-aware baseline-relaxed bounds force
351
+ status='gate_failed'; template violations keep score-driven classification.
352
+ The SCORE math (merged constraint dict) is unchanged from prior behaviour.
353
+
354
+ ``default_tolerance`` is the fallback when ``domain.drift_tolerance is None``
355
+ — callers (CLI, MCP) pass ``max_drift`` here; the per-domain value takes
356
+ precedence when declared (#170B).
357
+ """
358
+ tolerance_eff = (
359
+ domain.drift_tolerance if domain.drift_tolerance is not None else default_tolerance
360
+ )
361
+ if actual.node_count == 0:
362
+ return self._signal_report(actual, domain, status="empty", tolerance_eff=tolerance_eff)
363
+ if actual.edge_count == 0:
364
+ return self._signal_report(
365
+ actual, domain, status="no_signal", tolerance_eff=tolerance_eff
366
+ )
367
+ template, params = self._resolve_template(domain)
368
+ hygiene = self._parse_constraints(self._hygiene, {})
369
+ template_constraints = self._parse_constraints(template, params)
370
+ # SCORE math unchanged: merged dict feeds v1/v2 paths.
371
+ constraints = {**hygiene, **template_constraints}
372
+
373
+ # STATUS: hygiene evaluated separately with baseline-relaxed effective bounds.
374
+ hygiene_eff = self._apply_baseline(hygiene, domain.hygiene_baseline)
375
+ gate_violations, acknowledged = self._hygiene_check(actual, hygiene_eff, domain)
376
+
377
+ ideal = None if domain.expected_pattern is None else self.ideal_for(domain.expected_pattern)
378
+ layers = None if domain.profile is None else self.layers_for(domain.profile)
379
+ hygiene_keys: frozenset[str] = frozenset(hygiene.keys())
380
+ if ideal is None or layers is None:
381
+ return self._score_v1(
382
+ actual,
383
+ domain,
384
+ constraints,
385
+ self._weights_for(domain),
386
+ hygiene_keys=hygiene_keys,
387
+ gate_violations=gate_violations,
388
+ acknowledged=acknowledged,
389
+ tolerance_eff=tolerance_eff,
390
+ )
391
+ return self._score_v2(
392
+ actual,
393
+ domain,
394
+ constraints,
395
+ ideal,
396
+ layers,
397
+ hygiene_keys=hygiene_keys,
398
+ gate_violations=gate_violations,
399
+ acknowledged=acknowledged,
400
+ tolerance_eff=tolerance_eff,
401
+ )
402
+
403
+ def fit_templates(self, actual: PatternFingerprint, profile: str) -> list[tuple[str, float]]:
404
+ """Return ``[(template_name, residual)]`` sorted by (residual asc, name asc).
405
+
406
+ The residual is ``actual``'s drift score against each template's ideal
407
+ under a synthetic binding with ``drift_tolerance=1.0`` — a pure,
408
+ status-free distance ("how far is this shape from the archetype",
409
+ independent of any tolerance). Iterates THIS scorer's loaded templates,
410
+ so it reflects whatever alphabet ``patterns.yaml`` declares. This is the
411
+ canonical "distance to template" reused by both fit-quality reporting
412
+ (#177) and init-ontology labelling (#174) — one source of truth.
413
+
414
+ Returns the FULL ``drift_score`` (gates included) — this is the value
415
+ init-ontology turns into a domain's tolerance, so the proposed ontology
416
+ round-trips even on cyclic domains (#174). Fit-quality reporting bands
417
+ on ``shape_residual`` instead (gate-free), so a cycle inflates the
418
+ score here without ever printing "no template fits".
419
+ """
420
+ fits = [
421
+ (
422
+ t_name,
423
+ self.score(actual, self._fit_config(actual.domain, t_name, profile)).drift_score,
424
+ )
425
+ for t_name in self._patterns
426
+ ]
427
+ fits.sort(key=lambda pair: (pair[1], pair[0]))
428
+ return fits
429
+
430
+ @staticmethod
431
+ def _fit_config(domain: str, template: str, profile: str) -> DomainConfig:
432
+ """Synthetic domain binding used to measure distance to one template."""
433
+ return DomainConfig(
434
+ name=domain,
435
+ fqn_prefix=domain,
436
+ expected_pattern=template,
437
+ profile=profile,
438
+ drift_tolerance=1.0,
439
+ )
440
+
441
+ def shape_residual(
442
+ self, actual: PatternFingerprint, profile: str, template: str
443
+ ) -> float | None:
444
+ """Gate-FREE layered-TV distance to one template's ideal, or None (#177 fit band).
445
+
446
+ Unlike ``fit_templates`` (which returns the full ``drift_score`` so
447
+ init-ontology's tolerance covers the gate term too, #174 round-trip),
448
+ this excludes the hygiene/gate contribution: a domain that genuinely
449
+ fits an archetype but carries a cycle is NOT banded "no template fits"
450
+ — the cycle is the gate's story, not the shape's. Combines the two TV
451
+ layers exactly as ``_score_v2`` does, minus the gates layer.
452
+
453
+ Returns ``None`` when there is no trusted shape weight at all — a v1
454
+ profile (no ``layers``), an empty census on both layers, or fully
455
+ unresolved calls (``discount == 0``) with no imports layer. Banding
456
+ that 0.0 would read as "clean because it MATCHES an archetype" when the
457
+ truth is "no signal to fit" (review #1 / gemini); the caller maps None
458
+ to ``fit=None``.
459
+ """
460
+ report = self.score(actual, self._fit_config(actual.domain, template, profile))
461
+ layers = self.layers_for(profile) or {}
462
+ discount = _clip_discount(actual)
463
+ imp_w = layers.get("imports", 0.0) if report.tv_imports is not None else 0.0
464
+ cal_w = layers.get("calls", 0.0) * discount if report.tv_calls is not None else 0.0
465
+ total = imp_w + cal_w
466
+ if total <= 0.0:
467
+ return None
468
+ return (imp_w * (report.tv_imports or 0.0) + cal_w * (report.tv_calls or 0.0)) / total
469
+
470
+ def _score_v1(
471
+ self,
472
+ actual: PatternFingerprint,
473
+ domain: DomainConfig,
474
+ constraints: dict[str, tuple[str, float]],
475
+ weights: dict[str, float],
476
+ *,
477
+ hygiene_keys: frozenset[str],
478
+ gate_violations: list[str],
479
+ acknowledged: list[str],
480
+ tolerance_eff: float,
481
+ ) -> DriftReport:
482
+ """V1 scoring: per-constraint weighted drift with CALLS-layer discount."""
483
+ if not constraints:
484
+ return self._zero_drift_report(
485
+ actual,
486
+ domain,
487
+ gate_violations=gate_violations,
488
+ acknowledged=acknowledged,
489
+ tolerance_eff=tolerance_eff,
490
+ )
491
+
492
+ drift_sum, raw_violations, ideal_overrides = self._weighted_constraint_drift(
493
+ actual, constraints, weights
494
+ )
495
+ # Filter merged-path violations for hygiene keys that _hygiene_check already
496
+ # reported — suppress double-reporting (e.g. "cycle_ratio 0.07 > max 0.0"
497
+ # alongside "hygiene cycle_ratio 0.07 violates max 0.0").
498
+ # Only keys that produced a gate_violations or acknowledged entry are suppressed;
499
+ # a key that appears in both hygiene AND template but only breaches the TEMPLATE
500
+ # bound (not the hygiene bound) is NOT suppressed — its template violation is the
501
+ # sole reporter in that case.
502
+ hygiene_reported_keys = {
503
+ k for k in hygiene_keys if any(k in msg for msg in gate_violations + acknowledged)
504
+ }
505
+ violations = [
506
+ v
507
+ for v in raw_violations
508
+ if not any(v.startswith(k + " ") for k in hygiene_reported_keys)
509
+ ]
510
+
511
+ all_violations = violations + gate_violations + acknowledged
512
+ status: Literal["clean", "warning", "critical", "gate_failed", "empty", "no_signal"] = (
513
+ "gate_failed" if gate_violations else _classify(drift_sum, tolerance_eff)
514
+ )
515
+
516
+ ideal_fp = PatternFingerprint(
517
+ domain=domain.fqn_prefix,
518
+ hub_count=int(ideal_overrides.get("hub_count", 0)),
519
+ star_count=int(ideal_overrides.get("star_count", 0)),
520
+ chain_len=float(ideal_overrides.get("chain_len", 0.0)),
521
+ dag_depth=int(ideal_overrides.get("dag_depth", 0)),
522
+ router_count=int(ideal_overrides.get("router_count", 0)),
523
+ cycle_ratio=float(ideal_overrides.get("cycle_ratio", 0.0)),
524
+ unresolved_ratio=float(ideal_overrides.get("unresolved_ratio", 0.0)),
525
+ )
526
+
527
+ return DriftReport(
528
+ domain=domain.name,
529
+ fqn_prefix=domain.fqn_prefix,
530
+ expected_pattern=domain.expected_pattern,
531
+ actual=actual,
532
+ ideal=ideal_fp,
533
+ drift_score=drift_sum,
534
+ violations=all_violations,
535
+ status=status,
536
+ tolerance=tolerance_eff,
537
+ )
538
+
539
+ def _weighted_constraint_drift(
540
+ self,
541
+ actual: PatternFingerprint,
542
+ constraints: dict[str, tuple[str, float]],
543
+ weights: dict[str, float],
544
+ discount: float | None = None,
545
+ ) -> tuple[float, list[str], dict[str, float]]:
546
+ """Shared per-constraint loop: CALLS-layer discount, renorm, violation strings.
547
+
548
+ Returns (drift_sum, violations, ideal_overrides).
549
+ discount defaults to clip(1 - actual.unresolved_ratio) when not supplied.
550
+ """
551
+ if discount is None:
552
+ discount = _clip_discount(actual)
553
+
554
+ raw_weights = {name: weights.get(name, 0.0) for name in constraints}
555
+ eff_weights = {
556
+ name: w * discount if name in _CALLS_LAYER else w for name, w in raw_weights.items()
557
+ }
558
+ raw_total = sum(raw_weights.values())
559
+ eff_total = sum(eff_weights.values())
560
+ violations: list[str] = []
561
+ drift_sum = 0.0
562
+ ideal_overrides: dict[str, float] = {}
563
+
564
+ for name, (op, value) in constraints.items():
565
+ actual_val = float(getattr(actual, name))
566
+ ideal_val, norm, raw, violation = self._score_constraint(name, op, value, actual_val)
567
+ if violation:
568
+ violations.append(violation)
569
+ ideal_overrides[name] = ideal_val
570
+ component_drift = min(raw / norm, 1.0)
571
+ if raw_total > 0.0:
572
+ weight = eff_weights[name] / eff_total if eff_total > 0.0 else 0.0
573
+ else:
574
+ weight = 1.0 / len(constraints)
575
+ drift_sum += weight * component_drift
576
+
577
+ return drift_sum, violations, ideal_overrides
578
+
579
+ def _gate_drift(
580
+ self,
581
+ actual: PatternFingerprint,
582
+ domain: DomainConfig,
583
+ gates: dict[str, tuple[str, float]],
584
+ discount: float,
585
+ ) -> tuple[float, list[str]]:
586
+ """Compute drift for gate constraints only (v2 path), reusing the shared mechanism."""
587
+ if not gates:
588
+ return 0.0, []
589
+ weights = self._weights_for(domain)
590
+ gate_drift, violations, _ = self._weighted_constraint_drift(
591
+ actual, gates, weights, discount=discount
592
+ )
593
+ return gate_drift, violations
594
+
595
+ def _score_v2(
596
+ self,
597
+ actual: PatternFingerprint,
598
+ domain: DomainConfig,
599
+ gates: dict[str, tuple[str, float]],
600
+ ideal: tuple[tuple[float, ...], tuple[float, ...]],
601
+ layers: dict[str, float],
602
+ *,
603
+ hygiene_keys: frozenset[str],
604
+ gate_violations: list[str],
605
+ acknowledged: list[str],
606
+ tolerance_eff: float,
607
+ ) -> DriftReport:
608
+ """Fingerprint v2 drift: layered TV distance + hard gates (spec §3.3).
609
+
610
+ Every constraint reaching this path is treated as a hard gate — the
611
+ topological shape itself is measured by the TV terms, not constraints.
612
+ Hygiene gate violations are threaded in from score() and take precedence
613
+ over score-driven classification (spec §2.2).
614
+ """
615
+ if domain.profile is None: # layers presence implies a profile
616
+ msg = "v2 scoring requires a profile on the domain binding."
617
+ raise RuntimeError(msg)
618
+ triad_w = self.triad_weights_for(domain.profile)
619
+ discount = _clip_discount(actual)
620
+ violations: list[str] = []
621
+
622
+ tv_imp: float | None = None
623
+ if actual.t_imports != ZERO_TRIADS:
624
+ tv_imp, contribs = tv_distance(actual.t_imports, ideal[0], triad_w)
625
+ violations.extend(
626
+ self._triad_violations("T_imports", actual.t_imports, ideal[0], contribs)
627
+ )
628
+ tv_cal: float | None = None
629
+ if actual.t_calls != ZERO_TRIADS:
630
+ tv_cal, contribs = tv_distance(actual.t_calls, ideal[1], triad_w)
631
+ violations.extend(self._triad_violations("T_calls", actual.t_calls, ideal[1], contribs))
632
+
633
+ gate_drift, raw_v1_violations = self._gate_drift(actual, domain, gates, discount)
634
+ # Filter v1 gate violations for hygiene keys that _hygiene_check already reported.
635
+ # Same logic as _score_v1: only suppress keys that gate_violations/acknowledged
636
+ # already covers; template-only violations on hygiene-named keys are kept.
637
+ hygiene_reported_keys = {
638
+ k for k in hygiene_keys if any(k in msg for msg in gate_violations + acknowledged)
639
+ }
640
+ violations.extend(
641
+ v
642
+ for v in raw_v1_violations
643
+ if not any(v.startswith(k + " ") for k in hygiene_reported_keys)
644
+ )
645
+ all_violations = violations + gate_violations + acknowledged
646
+
647
+ eff = {
648
+ "imports": layers["imports"] if tv_imp is not None else 0.0,
649
+ "calls": layers["calls"] * discount if tv_cal is not None else 0.0,
650
+ "gates": layers["gates"] if gates else 0.0,
651
+ }
652
+ terms = {
653
+ "imports": 0.0 if tv_imp is None else tv_imp,
654
+ "calls": 0.0 if tv_cal is None else tv_cal,
655
+ "gates": gate_drift,
656
+ }
657
+ total = sum(eff.values())
658
+ drift = sum(eff[k] * terms[k] for k in eff) / total if total > 0.0 else 0.0
659
+
660
+ status: Literal["clean", "warning", "critical", "gate_failed", "empty", "no_signal"] = (
661
+ "gate_failed" if gate_violations else _classify(drift, tolerance_eff)
662
+ )
663
+
664
+ return DriftReport(
665
+ domain=domain.name,
666
+ fqn_prefix=domain.fqn_prefix,
667
+ expected_pattern=domain.expected_pattern,
668
+ actual=actual,
669
+ ideal=self._ideal_fingerprint_v2(domain, ideal),
670
+ drift_score=drift,
671
+ violations=all_violations,
672
+ status=status,
673
+ tolerance=tolerance_eff,
674
+ tv_imports=tv_imp,
675
+ tv_calls=tv_cal,
676
+ )
677
+
678
+ @staticmethod
679
+ def _triad_violations(
680
+ layer: str,
681
+ actual: tuple[float, ...],
682
+ ideal: tuple[float, ...],
683
+ contribs: list[tuple[str, float]],
684
+ ) -> list[str]:
685
+ """Render triad terms contributing ≥ threshold as violation strings."""
686
+ return [
687
+ f"{layer}[{name}]={actual[i]:.2f} vs ideal {ideal[i]:.2f} (+{c:.2f})"
688
+ for i, (name, c) in enumerate(contribs)
689
+ if c >= _TRIAD_VIOLATION_THRESHOLD
690
+ ]
691
+
692
+ @staticmethod
693
+ def _ideal_fingerprint_v2(
694
+ domain: DomainConfig,
695
+ ideal: tuple[tuple[float, ...], tuple[float, ...]],
696
+ ) -> PatternFingerprint:
697
+ """Ideal fingerprint carrying the template's triad points (v1 fields zero)."""
698
+ return PatternFingerprint(
699
+ domain=domain.fqn_prefix,
700
+ hub_count=0,
701
+ star_count=0,
702
+ chain_len=0.0,
703
+ dag_depth=0,
704
+ router_count=0,
705
+ cycle_ratio=0.0,
706
+ unresolved_ratio=0.0,
707
+ t_imports=ideal[0],
708
+ t_calls=ideal[1],
709
+ )
710
+
711
+ def _signal_report(
712
+ self,
713
+ actual: PatternFingerprint,
714
+ domain: DomainConfig,
715
+ status: Literal["empty", "no_signal"],
716
+ *,
717
+ tolerance_eff: float,
718
+ ) -> DriftReport:
719
+ """Report for a domain with nothing to score: matched 0 nodes (empty)
720
+ or matched nodes but 0 intra-domain edges (no_signal). Score is 0.0 by
721
+ definition — the gate handles 'empty' separately (spec §2.3)."""
722
+ return DriftReport(
723
+ domain=domain.name,
724
+ fqn_prefix=domain.fqn_prefix,
725
+ expected_pattern=domain.expected_pattern,
726
+ actual=actual,
727
+ ideal=actual,
728
+ drift_score=0.0,
729
+ violations=[],
730
+ status=status,
731
+ tolerance=tolerance_eff,
732
+ )
733
+
734
+ def _zero_drift_report(
735
+ self,
736
+ actual: PatternFingerprint,
737
+ domain: DomainConfig,
738
+ *,
739
+ gate_violations: list[str],
740
+ acknowledged: list[str],
741
+ tolerance_eff: float,
742
+ ) -> DriftReport:
743
+ """Return a zero-drift report for domains with no constraints.
744
+
745
+ Gate violations still force 'gate_failed' even when there are no
746
+ template constraints to score against.
747
+ """
748
+ ideal_fp = PatternFingerprint(
749
+ domain=domain.fqn_prefix,
750
+ hub_count=0,
751
+ star_count=0,
752
+ chain_len=0.0,
753
+ dag_depth=0,
754
+ router_count=0,
755
+ cycle_ratio=0.0,
756
+ unresolved_ratio=0.0,
757
+ )
758
+ all_violations = gate_violations + acknowledged
759
+ status: Literal["clean", "warning", "critical", "gate_failed", "empty", "no_signal"] = (
760
+ "gate_failed" if gate_violations else "clean"
761
+ )
762
+ return DriftReport(
763
+ domain=domain.name,
764
+ fqn_prefix=domain.fqn_prefix,
765
+ expected_pattern=domain.expected_pattern,
766
+ actual=actual,
767
+ ideal=ideal_fp,
768
+ drift_score=0.0,
769
+ violations=all_violations,
770
+ status=status,
771
+ tolerance=tolerance_eff,
772
+ )
773
+
774
+ @staticmethod
775
+ def _apply_baseline(
776
+ hygiene: dict[str, tuple[str, float]],
777
+ baseline: dict[str, float],
778
+ ) -> dict[str, tuple[str, float]]:
779
+ """Relax hygiene bounds by the domain's acknowledged debt (spec §2.2).
780
+
781
+ Operator-aware: max-bounds take max(global, baseline), min-bounds take
782
+ min(global, baseline), exact-bounds are overridden — a baseline can
783
+ only ever RELAX, never tighten.
784
+ """
785
+ out = dict(hygiene)
786
+ for key, ack in baseline.items():
787
+ op, bound = out[key] # key validity enforced at load time
788
+ if op == "max":
789
+ out[key] = (op, max(bound, ack))
790
+ elif op == "min":
791
+ out[key] = (op, min(bound, ack))
792
+ else: # exact
793
+ out[key] = (op, ack)
794
+ return out
795
+
796
+ @staticmethod
797
+ def _hygiene_check(
798
+ actual: PatternFingerprint,
799
+ hygiene_eff: dict[str, tuple[str, float]],
800
+ domain: DomainConfig,
801
+ ) -> tuple[list[str], list[str]]:
802
+ """Return (breaches, acknowledgments) against the EFFECTIVE hygiene bounds.
803
+
804
+ A breach of any effective bound forces status='gate_failed' upstream.
805
+ A measurement over the GLOBAL bound but within the acknowledged
806
+ baseline produces a visibility note, not a breach.
807
+
808
+ Comparison directions mirror ``_score_constraint`` / ``_weighted_constraint_drift``:
809
+ max → actual > bound; min → actual < bound; exact → actual != bound.
810
+ """
811
+ breaches: list[str] = []
812
+ acknowledged: list[str] = []
813
+ for key, (op, bound) in hygiene_eff.items():
814
+ value = float(getattr(actual, key))
815
+ violated = (
816
+ (op == "max" and value > bound)
817
+ or (op == "min" and value < bound)
818
+ or (op == "exact" and value != bound)
819
+ )
820
+ if violated:
821
+ breaches.append(f"hygiene {key} {value:.4f} violates {op} {bound}")
822
+ elif key in domain.hygiene_baseline:
823
+ acknowledged.append(
824
+ f"{key} {value:.4f} acknowledged (baseline {domain.hygiene_baseline[key]})"
825
+ )
826
+ return breaches, acknowledged
827
+
828
+ @staticmethod
829
+ def _score_constraint(
830
+ name: str, op: str, value: float, actual_val: float
831
+ ) -> tuple[float, float, float, str | None]:
832
+ """Compute ideal_val, norm, raw drift, and optional violation message for one constraint."""
833
+ violation: str | None = None
834
+ if op == "min":
835
+ ideal_val = value
836
+ norm = max(ideal_val, 1.0)
837
+ raw = max(0.0, ideal_val - actual_val)
838
+ if actual_val < value:
839
+ violation = f"{name} {actual_val} < min {value}"
840
+ elif op == "max":
841
+ ideal_val = 0.0
842
+ norm = max(value, 1.0)
843
+ raw = max(0.0, actual_val - value)
844
+ if actual_val > value:
845
+ violation = f"{name} {actual_val} > max {value}"
846
+ else: # exact
847
+ ideal_val = value
848
+ norm = max(ideal_val, 1.0)
849
+ raw = abs(actual_val - ideal_val)
850
+ if actual_val != value:
851
+ violation = f"{name} {actual_val} != exact {value}"
852
+ return ideal_val, norm, raw, violation
853
+
854
+ def _parse_constraints(
855
+ self, template: dict[str, Any], params: dict[str, float]
856
+ ) -> dict[str, tuple[str, float]]:
857
+ """Extract (operator, value) pairs for each constrained component, resolving $params."""
858
+ result: dict[str, tuple[str, float]] = {}
859
+ for name in _COMPONENT_NAMES:
860
+ constraint = template.get(name)
861
+ if constraint is None or not isinstance(constraint, dict):
862
+ continue
863
+ for op in ("min", "max", "exact"):
864
+ if op in constraint:
865
+ result[name] = (op, self._resolve_value(constraint[op], params))
866
+ break
867
+ return result