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,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
|