graphcheck 0.1.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.
- graphcheck/__init__.py +9 -0
- graphcheck/application/__init__.py +0 -0
- graphcheck/application/artifacts.py +145 -0
- graphcheck/application/paths.py +6 -0
- graphcheck/application/run.py +233 -0
- graphcheck/application/suites.py +44 -0
- graphcheck/baselines.py +228 -0
- graphcheck/bootstrap.py +19 -0
- graphcheck/cli.py +2339 -0
- graphcheck/connection_profiles.py +119 -0
- graphcheck/contracts/__init__.py +0 -0
- graphcheck/contracts/check.py +258 -0
- graphcheck/contracts/profile.py +290 -0
- graphcheck/contracts/results.py +409 -0
- graphcheck/contracts/scalars.py +23 -0
- graphcheck/contracts/schemas.py +140 -0
- graphcheck/debug_diagnostics.py +155 -0
- graphcheck/diff.py +420 -0
- graphcheck/engine/__init__.py +64 -0
- graphcheck/engine/baseline.py +262 -0
- graphcheck/engine/compiler.py +577 -0
- graphcheck/engine/core_pack.py +681 -0
- graphcheck/engine/evaluator.py +982 -0
- graphcheck/engine/executor.py +192 -0
- graphcheck/engine/identifiers.py +25 -0
- graphcheck/engine/parameters.py +99 -0
- graphcheck/engine/pii_pack.py +318 -0
- graphcheck/engine/runner.py +1798 -0
- graphcheck/engine/sampling.py +316 -0
- graphcheck/errors.py +69 -0
- graphcheck/generation/__init__.py +1 -0
- graphcheck/generation/client.py +383 -0
- graphcheck/generation/config.py +100 -0
- graphcheck/generation/disclosure.py +115 -0
- graphcheck/generation/prompts.py +112 -0
- graphcheck/generation/proposals.py +271 -0
- graphcheck/generation/service.py +379 -0
- graphcheck/generation/transmission.py +311 -0
- graphcheck/generation/writer.py +116 -0
- graphcheck/mcp/adapter.py +133 -0
- graphcheck/mcp/server.py +62 -0
- graphcheck/neo4j_adapter.py +1431 -0
- graphcheck/observability/__init__.py +13 -0
- graphcheck/observability/collector.py +31 -0
- graphcheck/observability/health.py +69 -0
- graphcheck/observability/metrics.py +34 -0
- graphcheck/observability/runner.py +28 -0
- graphcheck/observability/server.py +18 -0
- graphcheck/packs/__init__.py +213 -0
- graphcheck/packs/catalog.py +106 -0
- graphcheck/packs/core.yml +113 -0
- graphcheck/packs/metadata.py +329 -0
- graphcheck/packs/pii.yml +77 -0
- graphcheck/profiler.py +822 -0
- graphcheck/project.py +115 -0
- graphcheck/reporting/__init__.py +41 -0
- graphcheck/reporting/explorer.py +372 -0
- graphcheck/reporting/history.py +527 -0
- graphcheck/reporting/html.py +2239 -0
- graphcheck/reporting/presentation.py +156 -0
- graphcheck/reporting/redaction.py +321 -0
- graphcheck/reporting/writer.py +97 -0
- graphcheck/scoring.py +156 -0
- graphcheck/telemetry/__init__.py +28 -0
- graphcheck/telemetry/collector.py +296 -0
- graphcheck/telemetry/consent.py +208 -0
- graphcheck/telemetry/events.py +417 -0
- graphcheck/telemetry/inactive.py +130 -0
- graphcheck/telemetry/policy.py +769 -0
- graphcheck/telemetry/posthog.py +253 -0
- graphcheck/telemetry/release.py +10 -0
- graphcheck/telemetry/runtime.py +375 -0
- graphcheck/telemetry/types.py +222 -0
- graphcheck/yaml_loader.py +43 -0
- graphcheck-0.1.0.dist-info/METADATA +187 -0
- graphcheck-0.1.0.dist-info/RECORD +79 -0
- graphcheck-0.1.0.dist-info/WHEEL +4 -0
- graphcheck-0.1.0.dist-info/entry_points.txt +2 -0
- graphcheck-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from textwrap import dedent
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from graphcheck.contracts.check import (
|
|
9
|
+
CompetencyCheck,
|
|
10
|
+
ConformanceCheck,
|
|
11
|
+
DriftCheck,
|
|
12
|
+
LoadedCheck,
|
|
13
|
+
)
|
|
14
|
+
from graphcheck.contracts.results import Pattern
|
|
15
|
+
from graphcheck.engine.identifiers import node_pattern, property_access, relationship_pattern
|
|
16
|
+
from graphcheck.errors import GraphCheckError
|
|
17
|
+
from graphcheck.packs.catalog import PackCatalog, builtin_pack_catalog
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class EvidenceCondition:
|
|
22
|
+
"""Typed aggregate condition for conditional evidence execution."""
|
|
23
|
+
|
|
24
|
+
field: str
|
|
25
|
+
operator: Literal["gt", "lt"]
|
|
26
|
+
value: int | float
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class CompiledCheck:
|
|
31
|
+
"""A parameterized, read-only query and the assertion evaluated from its rows."""
|
|
32
|
+
|
|
33
|
+
check: LoadedCheck
|
|
34
|
+
query: str
|
|
35
|
+
params: dict[str, object]
|
|
36
|
+
expected: dict[str, object]
|
|
37
|
+
name: str
|
|
38
|
+
evidence_cap: int
|
|
39
|
+
sampled: bool = False
|
|
40
|
+
sampling_preflight: bool = True
|
|
41
|
+
population_query: str | None = None
|
|
42
|
+
population_params: dict[str, object] | None = None
|
|
43
|
+
sample_population: int | None = None
|
|
44
|
+
evidence_kinds: tuple[str, ...] = ()
|
|
45
|
+
evidence_id_fields: tuple[str, ...] = ()
|
|
46
|
+
evidence_query: str | None = None
|
|
47
|
+
evidence_params: dict[str, object] | None = None
|
|
48
|
+
evidence_condition: EvidenceCondition | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ConformancePlan:
|
|
53
|
+
"""Pack-facing compilation result for one conformance template."""
|
|
54
|
+
|
|
55
|
+
query: str
|
|
56
|
+
params: dict[str, object]
|
|
57
|
+
expected: dict[str, object]
|
|
58
|
+
name: str
|
|
59
|
+
sampled: bool = False
|
|
60
|
+
sampling_preflight: bool = True
|
|
61
|
+
population_query: str | None = None
|
|
62
|
+
population_params: dict[str, object] | None = None
|
|
63
|
+
evidence_query: str | None = None
|
|
64
|
+
evidence_params: dict[str, object] | None = None
|
|
65
|
+
evidence_condition: EvidenceCondition | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
ConformanceCompiler = Callable[[dict[str, object], int, int], ConformancePlan]
|
|
69
|
+
_CONFORMANCE_COMPILERS: dict[str, ConformanceCompiler] = {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def register_conformance_compiler(
|
|
73
|
+
name: str,
|
|
74
|
+
) -> Callable[[ConformanceCompiler], ConformanceCompiler]:
|
|
75
|
+
"""Register a pack template without changing SPEC-02's model-only registry."""
|
|
76
|
+
|
|
77
|
+
if not name.strip():
|
|
78
|
+
raise ValueError("a conformance compiler name cannot be blank")
|
|
79
|
+
|
|
80
|
+
def decorator(compiler: ConformanceCompiler) -> ConformanceCompiler:
|
|
81
|
+
_CONFORMANCE_COMPILERS[name] = compiler
|
|
82
|
+
return compiler
|
|
83
|
+
|
|
84
|
+
return decorator
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_SCHEMA_CATALOG = """
|
|
88
|
+
CALL {
|
|
89
|
+
CALL db.labels() YIELD label
|
|
90
|
+
RETURN collect(label) AS _gc_labels
|
|
91
|
+
}
|
|
92
|
+
CALL {
|
|
93
|
+
CALL db.relationshipTypes() YIELD relationshipType
|
|
94
|
+
RETURN collect(relationshipType) AS _gc_rel_types
|
|
95
|
+
}
|
|
96
|
+
WITH _gc_labels, _gc_rel_types
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
_SCHEMA_PROJECTION = """
|
|
100
|
+
all(name IN $required_labels WHERE name IN _gc_labels)
|
|
101
|
+
AND all(name IN $required_relationship_types WHERE name IN _gc_rel_types) AS schema_ok,
|
|
102
|
+
[name IN $required_labels WHERE NOT name IN _gc_labels] AS missing_labels,
|
|
103
|
+
[name IN $required_relationship_types WHERE NOT name IN _gc_rel_types]
|
|
104
|
+
AS missing_relationship_types
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _node_pointer(variable: str) -> str:
|
|
109
|
+
return f"{{kind: 'node', id: elementId({variable}), labels: labels({variable})}}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _rel_pointer(variable: str) -> str:
|
|
113
|
+
return f"{{kind: 'rel', id: elementId({variable}), type: type({variable})}}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@register_conformance_compiler("completeness")
|
|
117
|
+
def _compile_completeness(
|
|
118
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
119
|
+
) -> ConformancePlan:
|
|
120
|
+
del sample_seed
|
|
121
|
+
label_value = config.get("label")
|
|
122
|
+
property_value = config.get("property")
|
|
123
|
+
if not isinstance(label_value, str) or not label_value.strip():
|
|
124
|
+
raise GraphCheckError(
|
|
125
|
+
"engine.invalid_check",
|
|
126
|
+
"Completeness label must be a non-blank string.",
|
|
127
|
+
"Set `with.label` to the node label this check should inspect.",
|
|
128
|
+
)
|
|
129
|
+
if not isinstance(property_value, str) or not property_value.strip():
|
|
130
|
+
raise GraphCheckError(
|
|
131
|
+
"engine.invalid_check",
|
|
132
|
+
"Completeness property must be a non-blank string.",
|
|
133
|
+
"Set `with.property` to the property this check should inspect.",
|
|
134
|
+
)
|
|
135
|
+
label = label_value.strip()
|
|
136
|
+
property_name = property_value.strip()
|
|
137
|
+
node = node_pattern("n", label)
|
|
138
|
+
property_ref = property_access("n", property_name)
|
|
139
|
+
threshold = float(config.get("threshold", 1.0))
|
|
140
|
+
if not 0.0 <= threshold <= 1.0:
|
|
141
|
+
raise GraphCheckError(
|
|
142
|
+
"engine.invalid_check",
|
|
143
|
+
"Completeness threshold must be between 0 and 1.",
|
|
144
|
+
"Set `with.threshold` to a number in the closed interval [0, 1].",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
query = dedent(
|
|
148
|
+
f"""
|
|
149
|
+
{_SCHEMA_CATALOG}
|
|
150
|
+
CALL {{
|
|
151
|
+
MATCH {node}
|
|
152
|
+
RETURN count(n) AS population,
|
|
153
|
+
sum(CASE WHEN {property_ref} IS NOT NULL THEN 1 ELSE 0 END)
|
|
154
|
+
AS conforming_count,
|
|
155
|
+
sum(CASE WHEN {property_ref} IS NULL THEN 1 ELSE 0 END)
|
|
156
|
+
AS violation_count
|
|
157
|
+
}}
|
|
158
|
+
RETURN {_SCHEMA_PROJECTION},
|
|
159
|
+
population,
|
|
160
|
+
conforming_count,
|
|
161
|
+
violation_count,
|
|
162
|
+
CASE WHEN population = 0 THEN 1.0
|
|
163
|
+
ELSE toFloat(conforming_count) / population END AS coverage,
|
|
164
|
+
[] AS evidence
|
|
165
|
+
"""
|
|
166
|
+
).strip()
|
|
167
|
+
evidence_query = dedent(
|
|
168
|
+
f"""
|
|
169
|
+
MATCH {node}
|
|
170
|
+
WHERE {property_ref} IS NULL
|
|
171
|
+
WITH n ORDER BY elementId(n) LIMIT $evidence_cap
|
|
172
|
+
RETURN collect({_node_pointer("n")}) AS evidence
|
|
173
|
+
"""
|
|
174
|
+
).strip()
|
|
175
|
+
return ConformancePlan(
|
|
176
|
+
query=query,
|
|
177
|
+
params={
|
|
178
|
+
"evidence_cap": evidence_cap,
|
|
179
|
+
"required_labels": [label],
|
|
180
|
+
"required_relationship_types": [],
|
|
181
|
+
},
|
|
182
|
+
expected={"threshold": threshold},
|
|
183
|
+
name=f"{label}.{property_name} is present",
|
|
184
|
+
evidence_query=evidence_query,
|
|
185
|
+
evidence_params={"evidence_cap": evidence_cap},
|
|
186
|
+
evidence_condition=EvidenceCondition("coverage", "lt", threshold),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def expected_for(check: LoadedCheck) -> dict[str, object]:
|
|
191
|
+
spec = check.spec
|
|
192
|
+
if isinstance(spec, CompetencyCheck):
|
|
193
|
+
return spec.expect.model_dump(exclude_none=True)
|
|
194
|
+
if isinstance(spec, DriftCheck):
|
|
195
|
+
return {
|
|
196
|
+
"baseline": spec.baseline,
|
|
197
|
+
"tolerance": dict(spec.tolerance),
|
|
198
|
+
}
|
|
199
|
+
if isinstance(spec, ConformanceCheck):
|
|
200
|
+
if spec.check == "completeness":
|
|
201
|
+
return {"threshold": float(spec.with_.get("threshold", 1.0))}
|
|
202
|
+
return dict(spec.with_)
|
|
203
|
+
raise TypeError(f"unsupported loaded check spec: {type(spec).__name__}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def name_for(check: LoadedCheck) -> str:
|
|
207
|
+
spec = check.spec
|
|
208
|
+
if isinstance(spec, CompetencyCheck):
|
|
209
|
+
return spec.question.rstrip().removesuffix("?")
|
|
210
|
+
if isinstance(spec, DriftCheck):
|
|
211
|
+
target = spec.target.get("label") or spec.target.get("type") or "graph"
|
|
212
|
+
if spec.target.get("property") is not None:
|
|
213
|
+
target = f"{target}.{spec.target['property']}"
|
|
214
|
+
return f"{spec.metric} for {target} is within tolerance"
|
|
215
|
+
if isinstance(spec, ConformanceCheck):
|
|
216
|
+
if spec.check == "completeness":
|
|
217
|
+
return f"{spec.with_.get('label')}.{spec.with_.get('property')} is present"
|
|
218
|
+
return spec.check.replace("_", " ")
|
|
219
|
+
return check.id
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class CypherCompiler:
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
evidence_cap: int = 100,
|
|
227
|
+
pack_catalog: PackCatalog | None = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
if isinstance(evidence_cap, bool) or not isinstance(evidence_cap, int) or evidence_cap < 1:
|
|
230
|
+
raise ValueError("evidence_cap must be a positive integer")
|
|
231
|
+
self.evidence_cap = evidence_cap
|
|
232
|
+
self.pack_catalog = pack_catalog or builtin_pack_catalog()
|
|
233
|
+
|
|
234
|
+
def compile(self, check: LoadedCheck, *, sample_seed: int = 0) -> CompiledCheck:
|
|
235
|
+
if check.pattern is Pattern.CONFORMANCE:
|
|
236
|
+
return self._compile_conformance(check, sample_seed)
|
|
237
|
+
if check.pattern in (Pattern.COMPETENCY_SHAPE, Pattern.COMPETENCY_REGRESSION):
|
|
238
|
+
return self._compile_competency(check)
|
|
239
|
+
if check.pattern is Pattern.DRIFT:
|
|
240
|
+
return self._compile_drift(check)
|
|
241
|
+
raise GraphCheckError(
|
|
242
|
+
"engine.unsupported_pattern",
|
|
243
|
+
f"Check {check.id!r} uses unsupported pattern {check.pattern!s}.",
|
|
244
|
+
"Use a conformance, competency, or drift check pattern supported by this release.",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def _compile_conformance(self, check: LoadedCheck, sample_seed: int) -> CompiledCheck:
|
|
248
|
+
spec = check.spec
|
|
249
|
+
if not isinstance(spec, ConformanceCheck):
|
|
250
|
+
raise _invalid_loaded_check(check, "conformance")
|
|
251
|
+
definition = self.pack_catalog.checks.get(spec.check)
|
|
252
|
+
if definition is None:
|
|
253
|
+
raise GraphCheckError(
|
|
254
|
+
"packs.runtime_missing",
|
|
255
|
+
f"Conformance check {spec.check!r} is not declared by installed pack metadata.",
|
|
256
|
+
"Install a pack whose validated YAML declares this check and its runtime template.",
|
|
257
|
+
)
|
|
258
|
+
compiler = _CONFORMANCE_COMPILERS.get(definition.template)
|
|
259
|
+
if compiler is None:
|
|
260
|
+
raise GraphCheckError(
|
|
261
|
+
"engine.compiler_missing",
|
|
262
|
+
f"No compiler is registered for pack template {definition.template!r}.",
|
|
263
|
+
"Install a pack version that provides the check's C1 compiler template.",
|
|
264
|
+
)
|
|
265
|
+
runtime_config = dict(spec.with_)
|
|
266
|
+
if definition.pack == "pii":
|
|
267
|
+
runtime_config["__pack_metadata__"] = self.pack_catalog.pii
|
|
268
|
+
plan = compiler(runtime_config, self.evidence_cap, sample_seed)
|
|
269
|
+
if plan.sampled is not definition.sampled:
|
|
270
|
+
raise GraphCheckError(
|
|
271
|
+
"packs.runtime_mismatch",
|
|
272
|
+
f"Pack check {spec.check!r} declares sampled={definition.sampled!r}, "
|
|
273
|
+
f"but template {definition.template!r} compiled sampled={plan.sampled!r}.",
|
|
274
|
+
"Make the pack YAML sampling declaration match its registered compiler.",
|
|
275
|
+
)
|
|
276
|
+
params = _query_params(plan.query, plan.params)
|
|
277
|
+
evidence_params = (
|
|
278
|
+
_query_params(plan.evidence_query, plan.evidence_params or plan.params)
|
|
279
|
+
if plan.evidence_query is not None
|
|
280
|
+
else None
|
|
281
|
+
)
|
|
282
|
+
return CompiledCheck(
|
|
283
|
+
check=check,
|
|
284
|
+
query=plan.query,
|
|
285
|
+
params=params,
|
|
286
|
+
expected=dict(plan.expected),
|
|
287
|
+
name=plan.name,
|
|
288
|
+
evidence_cap=self.evidence_cap,
|
|
289
|
+
sampled=plan.sampled,
|
|
290
|
+
sampling_preflight=plan.sampling_preflight,
|
|
291
|
+
population_query=plan.population_query,
|
|
292
|
+
population_params=(
|
|
293
|
+
dict(plan.population_params) if plan.population_params is not None else None
|
|
294
|
+
),
|
|
295
|
+
evidence_kinds=definition.evidence_elements,
|
|
296
|
+
evidence_id_fields=definition.evidence_id_fields,
|
|
297
|
+
evidence_query=plan.evidence_query,
|
|
298
|
+
evidence_params=evidence_params,
|
|
299
|
+
evidence_condition=plan.evidence_condition,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def missing_capabilities(self, check: LoadedCheck, target: object) -> tuple[str, ...]:
|
|
303
|
+
"""Return target capabilities that prevent a pack check from being attempted."""
|
|
304
|
+
if not isinstance(check.spec, ConformanceCheck):
|
|
305
|
+
return ()
|
|
306
|
+
definition = self.pack_catalog.checks.get(check.spec.check)
|
|
307
|
+
if definition is None:
|
|
308
|
+
return ()
|
|
309
|
+
capabilities = getattr(target, "capabilities", None)
|
|
310
|
+
missing: list[str] = []
|
|
311
|
+
for requirement in definition.requires:
|
|
312
|
+
if requirement == "read":
|
|
313
|
+
# A successful C2 target probe establishes the engine's read path. Individual
|
|
314
|
+
# permission gaps still surface as errored checks rather than optimistic skips.
|
|
315
|
+
unavailable = False
|
|
316
|
+
else:
|
|
317
|
+
unavailable = not bool(getattr(capabilities, requirement, False))
|
|
318
|
+
if unavailable:
|
|
319
|
+
missing.append(requirement)
|
|
320
|
+
return tuple(missing)
|
|
321
|
+
|
|
322
|
+
def _compile_competency(self, check: LoadedCheck) -> CompiledCheck:
|
|
323
|
+
spec = check.spec
|
|
324
|
+
if not isinstance(spec, CompetencyCheck):
|
|
325
|
+
raise _invalid_loaded_check(check, "competency")
|
|
326
|
+
query = spec.query.strip()
|
|
327
|
+
if not query:
|
|
328
|
+
raise GraphCheckError(
|
|
329
|
+
"engine.empty_query",
|
|
330
|
+
f"Competency check {check.id!r} has an empty query.",
|
|
331
|
+
"Add a read-only Cypher query to the check's `query` field.",
|
|
332
|
+
)
|
|
333
|
+
params = dict(spec.params)
|
|
334
|
+
missing = sorted(_parameter_names(query) - params.keys())
|
|
335
|
+
if missing:
|
|
336
|
+
names = ", ".join(f"${name}" for name in missing)
|
|
337
|
+
raise GraphCheckError(
|
|
338
|
+
"engine.parameter_missing",
|
|
339
|
+
f"Competency check {check.id!r} has no value for {names}.",
|
|
340
|
+
"Declare every Cypher parameter under the check's `params` mapping.",
|
|
341
|
+
)
|
|
342
|
+
return CompiledCheck(
|
|
343
|
+
check=check,
|
|
344
|
+
query=query,
|
|
345
|
+
params=params,
|
|
346
|
+
expected=spec.expect.model_dump(exclude_none=True),
|
|
347
|
+
name=name_for(check),
|
|
348
|
+
evidence_cap=self.evidence_cap,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def _compile_drift(self, check: LoadedCheck) -> CompiledCheck:
|
|
352
|
+
spec = check.spec
|
|
353
|
+
if not isinstance(spec, DriftCheck):
|
|
354
|
+
raise _invalid_loaded_check(check, "drift")
|
|
355
|
+
compiler = {
|
|
356
|
+
"node_count": self._compile_node_count,
|
|
357
|
+
"relationship_count": self._compile_relationship_count,
|
|
358
|
+
"property_coverage": self._compile_property_coverage,
|
|
359
|
+
}.get(spec.metric)
|
|
360
|
+
if compiler is None:
|
|
361
|
+
raise GraphCheckError(
|
|
362
|
+
"engine.metric_unsupported",
|
|
363
|
+
f"Drift metric {spec.metric!r} has no C1 query compiler.",
|
|
364
|
+
"Use node_count, relationship_count, or property_coverage, "
|
|
365
|
+
"or install its provider.",
|
|
366
|
+
)
|
|
367
|
+
query, params = compiler(spec)
|
|
368
|
+
return CompiledCheck(
|
|
369
|
+
check=check,
|
|
370
|
+
query=query,
|
|
371
|
+
params=params,
|
|
372
|
+
expected=expected_for(check),
|
|
373
|
+
name=name_for(check),
|
|
374
|
+
evidence_cap=self.evidence_cap,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _compile_node_count(self, spec: DriftCheck) -> tuple[str, dict[str, object]]:
|
|
378
|
+
unknown = set(spec.target) - {"label"}
|
|
379
|
+
if unknown:
|
|
380
|
+
raise _unknown_target(spec.metric, unknown)
|
|
381
|
+
label = spec.target.get("label")
|
|
382
|
+
if label is not None and (not isinstance(label, str) or not label.strip()):
|
|
383
|
+
raise _bad_target(spec.metric, "target.label must be a non-blank string")
|
|
384
|
+
required_labels = [label] if label is not None else []
|
|
385
|
+
match = node_pattern("n", label)
|
|
386
|
+
query = dedent(
|
|
387
|
+
f"""
|
|
388
|
+
{_SCHEMA_CATALOG}
|
|
389
|
+
CALL {{
|
|
390
|
+
MATCH {match}
|
|
391
|
+
RETURN count(n) AS current
|
|
392
|
+
}}
|
|
393
|
+
RETURN {_SCHEMA_PROJECTION}, current, current AS population, [] AS evidence
|
|
394
|
+
"""
|
|
395
|
+
).strip()
|
|
396
|
+
return query, {
|
|
397
|
+
"required_labels": required_labels,
|
|
398
|
+
"required_relationship_types": [],
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
def _compile_relationship_count(self, spec: DriftCheck) -> tuple[str, dict[str, object]]:
|
|
402
|
+
unknown = set(spec.target) - {"type"}
|
|
403
|
+
if unknown:
|
|
404
|
+
raise _unknown_target(spec.metric, unknown)
|
|
405
|
+
rel_type = spec.target.get("type")
|
|
406
|
+
if rel_type is not None and (not isinstance(rel_type, str) or not rel_type.strip()):
|
|
407
|
+
raise _bad_target(spec.metric, "target.type must be a non-blank string")
|
|
408
|
+
required_types = [rel_type] if rel_type is not None else []
|
|
409
|
+
relationship = relationship_pattern("r", rel_type)
|
|
410
|
+
query = dedent(
|
|
411
|
+
f"""
|
|
412
|
+
{_SCHEMA_CATALOG}
|
|
413
|
+
CALL {{
|
|
414
|
+
MATCH ()-{relationship}->()
|
|
415
|
+
RETURN count(r) AS current
|
|
416
|
+
}}
|
|
417
|
+
RETURN {_SCHEMA_PROJECTION}, current, current AS population, [] AS evidence
|
|
418
|
+
"""
|
|
419
|
+
).strip()
|
|
420
|
+
return query, {
|
|
421
|
+
"required_labels": [],
|
|
422
|
+
"required_relationship_types": required_types,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
def _compile_property_coverage(self, spec: DriftCheck) -> tuple[str, dict[str, object]]:
|
|
426
|
+
unknown = set(spec.target) - {"label", "type", "property"}
|
|
427
|
+
if unknown:
|
|
428
|
+
raise _unknown_target(spec.metric, unknown)
|
|
429
|
+
label = spec.target.get("label")
|
|
430
|
+
rel_type = spec.target.get("type")
|
|
431
|
+
property_name = spec.target.get("property")
|
|
432
|
+
if (label is None) == (rel_type is None):
|
|
433
|
+
raise _bad_target(spec.metric, "target requires exactly one of label or type")
|
|
434
|
+
if not isinstance(property_name, str) or not property_name.strip():
|
|
435
|
+
raise _bad_target(spec.metric, "target.property must be a non-blank string")
|
|
436
|
+
if label is not None:
|
|
437
|
+
if not isinstance(label, str) or not label.strip():
|
|
438
|
+
raise _bad_target(spec.metric, "target.label must be a non-blank string")
|
|
439
|
+
match = f"MATCH {node_pattern('element', label)}"
|
|
440
|
+
property_ref = property_access("element", property_name)
|
|
441
|
+
missing = f"{match}\n WHERE {property_ref} IS NULL"
|
|
442
|
+
pointer = _node_pointer("element")
|
|
443
|
+
required_labels = [label]
|
|
444
|
+
required_types: list[object] = []
|
|
445
|
+
else:
|
|
446
|
+
if not isinstance(rel_type, str) or not rel_type.strip():
|
|
447
|
+
raise _bad_target(spec.metric, "target.type must be a non-blank string")
|
|
448
|
+
match = f"MATCH ()-{relationship_pattern('element', rel_type)}->()"
|
|
449
|
+
property_ref = property_access("element", property_name)
|
|
450
|
+
missing = f"{match}\n WHERE {property_ref} IS NULL"
|
|
451
|
+
pointer = _rel_pointer("element")
|
|
452
|
+
required_labels = []
|
|
453
|
+
required_types = [rel_type]
|
|
454
|
+
query = dedent(
|
|
455
|
+
f"""
|
|
456
|
+
{_SCHEMA_CATALOG}
|
|
457
|
+
CALL {{
|
|
458
|
+
{match}
|
|
459
|
+
RETURN count(element) AS population,
|
|
460
|
+
count({property_ref}) AS present
|
|
461
|
+
}}
|
|
462
|
+
CALL {{
|
|
463
|
+
{missing}
|
|
464
|
+
WITH element ORDER BY elementId(element) LIMIT $evidence_cap
|
|
465
|
+
RETURN collect({pointer}) AS evidence
|
|
466
|
+
}}
|
|
467
|
+
RETURN {_SCHEMA_PROJECTION}, population, present,
|
|
468
|
+
CASE WHEN population = 0 THEN 100.0
|
|
469
|
+
ELSE 100.0 * present / population END AS current,
|
|
470
|
+
evidence
|
|
471
|
+
"""
|
|
472
|
+
).strip()
|
|
473
|
+
return query, {
|
|
474
|
+
"evidence_cap": self.evidence_cap,
|
|
475
|
+
"required_labels": required_labels,
|
|
476
|
+
"required_relationship_types": required_types,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def compile_check(
|
|
481
|
+
check: LoadedCheck, *, evidence_cap: int = 100, sample_seed: int = 0
|
|
482
|
+
) -> CompiledCheck:
|
|
483
|
+
return CypherCompiler(evidence_cap=evidence_cap).compile(check, sample_seed=sample_seed)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _parameter_names(query: str) -> set[str]:
|
|
487
|
+
"""Return Cypher parameter names while ignoring quoted strings and comments."""
|
|
488
|
+
|
|
489
|
+
names: set[str] = set()
|
|
490
|
+
index = 0
|
|
491
|
+
length = len(query)
|
|
492
|
+
while index < length:
|
|
493
|
+
char = query[index]
|
|
494
|
+
if char in {"'", '"', "`"}:
|
|
495
|
+
quote = char
|
|
496
|
+
index += 1
|
|
497
|
+
while index < length:
|
|
498
|
+
if query[index] == "\\" and quote != "`":
|
|
499
|
+
index += 2
|
|
500
|
+
continue
|
|
501
|
+
if query[index] == quote:
|
|
502
|
+
if index + 1 < length and query[index + 1] == quote:
|
|
503
|
+
index += 2
|
|
504
|
+
continue
|
|
505
|
+
index += 1
|
|
506
|
+
break
|
|
507
|
+
index += 1
|
|
508
|
+
continue
|
|
509
|
+
if query.startswith("//", index):
|
|
510
|
+
newline = query.find("\n", index + 2)
|
|
511
|
+
index = length if newline < 0 else newline + 1
|
|
512
|
+
continue
|
|
513
|
+
if query.startswith("/*", index):
|
|
514
|
+
end = query.find("*/", index + 2)
|
|
515
|
+
index = length if end < 0 else end + 2
|
|
516
|
+
continue
|
|
517
|
+
if char == "$" and index + 1 < length:
|
|
518
|
+
start = index + 1
|
|
519
|
+
if query[start].isalpha() or query[start] == "_":
|
|
520
|
+
end = start + 1
|
|
521
|
+
while end < length and (query[end].isalnum() or query[end] == "_"):
|
|
522
|
+
end += 1
|
|
523
|
+
names.add(query[start:end])
|
|
524
|
+
index = end
|
|
525
|
+
continue
|
|
526
|
+
index += 1
|
|
527
|
+
return names
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _query_params(query: str, params: dict[str, object]) -> dict[str, object]:
|
|
531
|
+
names = _parameter_names(query)
|
|
532
|
+
missing = sorted(names - params.keys())
|
|
533
|
+
if missing:
|
|
534
|
+
rendered = ", ".join(f"${name}" for name in missing)
|
|
535
|
+
raise GraphCheckError(
|
|
536
|
+
"engine.parameter_missing",
|
|
537
|
+
f"Compiled query has no value for {rendered}.",
|
|
538
|
+
"Fix the compiler so every Cypher parameter has a value.",
|
|
539
|
+
)
|
|
540
|
+
return {name: params[name] for name in params if name in names}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _invalid_loaded_check(check: LoadedCheck, expected: str) -> GraphCheckError:
|
|
544
|
+
return GraphCheckError(
|
|
545
|
+
"engine.invalid_check",
|
|
546
|
+
f"Check {check.id!r} is marked {expected} but carries {type(check.spec).__name__}.",
|
|
547
|
+
"Reload the suite with the SPEC-02 loader before compiling it.",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _unknown_target(metric: str, keys: set[str]) -> GraphCheckError:
|
|
552
|
+
rendered = ", ".join(sorted(keys))
|
|
553
|
+
return GraphCheckError(
|
|
554
|
+
"engine.invalid_target",
|
|
555
|
+
f"Drift metric {metric!r} does not accept target keys: {rendered}.",
|
|
556
|
+
"Remove the unknown target keys or use the metric that defines them.",
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _bad_target(metric: str, detail: str) -> GraphCheckError:
|
|
561
|
+
return GraphCheckError(
|
|
562
|
+
"engine.invalid_target",
|
|
563
|
+
f"Invalid target for drift metric {metric!r}: {detail}.",
|
|
564
|
+
"Fix the drift check's `target` mapping and run it again.",
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _register_builtin_pack_compilers() -> None:
|
|
569
|
+
# The pack registry remains model-only. Importing the C1 bridge installs compiler
|
|
570
|
+
# callbacks for the template names carried by C3's data-only core pack.
|
|
571
|
+
from graphcheck.engine import core_pack as loaded_core_pack
|
|
572
|
+
from graphcheck.engine import pii_pack as loaded_pii_pack
|
|
573
|
+
|
|
574
|
+
del loaded_core_pack, loaded_pii_pack
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
_register_builtin_pack_compilers()
|