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.
Files changed (79) hide show
  1. graphcheck/__init__.py +9 -0
  2. graphcheck/application/__init__.py +0 -0
  3. graphcheck/application/artifacts.py +145 -0
  4. graphcheck/application/paths.py +6 -0
  5. graphcheck/application/run.py +233 -0
  6. graphcheck/application/suites.py +44 -0
  7. graphcheck/baselines.py +228 -0
  8. graphcheck/bootstrap.py +19 -0
  9. graphcheck/cli.py +2339 -0
  10. graphcheck/connection_profiles.py +119 -0
  11. graphcheck/contracts/__init__.py +0 -0
  12. graphcheck/contracts/check.py +258 -0
  13. graphcheck/contracts/profile.py +290 -0
  14. graphcheck/contracts/results.py +409 -0
  15. graphcheck/contracts/scalars.py +23 -0
  16. graphcheck/contracts/schemas.py +140 -0
  17. graphcheck/debug_diagnostics.py +155 -0
  18. graphcheck/diff.py +420 -0
  19. graphcheck/engine/__init__.py +64 -0
  20. graphcheck/engine/baseline.py +262 -0
  21. graphcheck/engine/compiler.py +577 -0
  22. graphcheck/engine/core_pack.py +681 -0
  23. graphcheck/engine/evaluator.py +982 -0
  24. graphcheck/engine/executor.py +192 -0
  25. graphcheck/engine/identifiers.py +25 -0
  26. graphcheck/engine/parameters.py +99 -0
  27. graphcheck/engine/pii_pack.py +318 -0
  28. graphcheck/engine/runner.py +1798 -0
  29. graphcheck/engine/sampling.py +316 -0
  30. graphcheck/errors.py +69 -0
  31. graphcheck/generation/__init__.py +1 -0
  32. graphcheck/generation/client.py +383 -0
  33. graphcheck/generation/config.py +100 -0
  34. graphcheck/generation/disclosure.py +115 -0
  35. graphcheck/generation/prompts.py +112 -0
  36. graphcheck/generation/proposals.py +271 -0
  37. graphcheck/generation/service.py +379 -0
  38. graphcheck/generation/transmission.py +311 -0
  39. graphcheck/generation/writer.py +116 -0
  40. graphcheck/mcp/adapter.py +133 -0
  41. graphcheck/mcp/server.py +62 -0
  42. graphcheck/neo4j_adapter.py +1431 -0
  43. graphcheck/observability/__init__.py +13 -0
  44. graphcheck/observability/collector.py +31 -0
  45. graphcheck/observability/health.py +69 -0
  46. graphcheck/observability/metrics.py +34 -0
  47. graphcheck/observability/runner.py +28 -0
  48. graphcheck/observability/server.py +18 -0
  49. graphcheck/packs/__init__.py +213 -0
  50. graphcheck/packs/catalog.py +106 -0
  51. graphcheck/packs/core.yml +113 -0
  52. graphcheck/packs/metadata.py +329 -0
  53. graphcheck/packs/pii.yml +77 -0
  54. graphcheck/profiler.py +822 -0
  55. graphcheck/project.py +115 -0
  56. graphcheck/reporting/__init__.py +41 -0
  57. graphcheck/reporting/explorer.py +372 -0
  58. graphcheck/reporting/history.py +527 -0
  59. graphcheck/reporting/html.py +2239 -0
  60. graphcheck/reporting/presentation.py +156 -0
  61. graphcheck/reporting/redaction.py +321 -0
  62. graphcheck/reporting/writer.py +97 -0
  63. graphcheck/scoring.py +156 -0
  64. graphcheck/telemetry/__init__.py +28 -0
  65. graphcheck/telemetry/collector.py +296 -0
  66. graphcheck/telemetry/consent.py +208 -0
  67. graphcheck/telemetry/events.py +417 -0
  68. graphcheck/telemetry/inactive.py +130 -0
  69. graphcheck/telemetry/policy.py +769 -0
  70. graphcheck/telemetry/posthog.py +253 -0
  71. graphcheck/telemetry/release.py +10 -0
  72. graphcheck/telemetry/runtime.py +375 -0
  73. graphcheck/telemetry/types.py +222 -0
  74. graphcheck/yaml_loader.py +43 -0
  75. graphcheck-0.1.0.dist-info/METADATA +187 -0
  76. graphcheck-0.1.0.dist-info/RECORD +79 -0
  77. graphcheck-0.1.0.dist-info/WHEEL +4 -0
  78. graphcheck-0.1.0.dist-info/entry_points.txt +2 -0
  79. 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()