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,681 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from textwrap import dedent
|
|
5
|
+
|
|
6
|
+
from graphcheck.engine.compiler import (
|
|
7
|
+
ConformancePlan,
|
|
8
|
+
EvidenceCondition,
|
|
9
|
+
register_conformance_compiler,
|
|
10
|
+
)
|
|
11
|
+
from graphcheck.engine.identifiers import (
|
|
12
|
+
cypher_identifier,
|
|
13
|
+
node_pattern,
|
|
14
|
+
property_access,
|
|
15
|
+
relationship_pattern,
|
|
16
|
+
)
|
|
17
|
+
from graphcheck.engine.sampling import (
|
|
18
|
+
CYPHER_SAMPLE_MODULUS,
|
|
19
|
+
cypher_hash_expression,
|
|
20
|
+
cypher_hash_parameters,
|
|
21
|
+
)
|
|
22
|
+
from graphcheck.errors import GraphCheckError
|
|
23
|
+
|
|
24
|
+
_DEFAULT_HUB_SAMPLE_SIZE = 1000
|
|
25
|
+
_SAMPLE_GATE_MULTIPLIER = 32
|
|
26
|
+
|
|
27
|
+
_SCHEMA_CATALOG = """
|
|
28
|
+
CALL {
|
|
29
|
+
CALL db.labels() YIELD label
|
|
30
|
+
RETURN collect(label) AS _gc_labels
|
|
31
|
+
}
|
|
32
|
+
CALL {
|
|
33
|
+
CALL db.relationshipTypes() YIELD relationshipType
|
|
34
|
+
RETURN collect(relationshipType) AS _gc_rel_types
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_SCHEMA_PROJECTION = """
|
|
39
|
+
all(name IN $required_labels WHERE name IN _gc_labels)
|
|
40
|
+
AND all(name IN $required_relationship_types WHERE name IN _gc_rel_types) AS schema_ok,
|
|
41
|
+
[name IN $required_labels WHERE NOT name IN _gc_labels] AS missing_labels,
|
|
42
|
+
[name IN $required_relationship_types WHERE NOT name IN _gc_rel_types]
|
|
43
|
+
AS missing_relationship_types
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _node_pointer(variable: str) -> str:
|
|
48
|
+
return f"{{kind: 'node', id: elementId({variable}), labels: labels({variable})}}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _rel_pointer(variable: str) -> str:
|
|
52
|
+
return f"{{kind: 'rel', id: elementId({variable}), type: type({variable})}}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _invalid(detail: str, fix: str) -> GraphCheckError:
|
|
56
|
+
return GraphCheckError("engine.invalid_check", detail, fix)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _string(config: dict[str, object], key: str) -> str:
|
|
60
|
+
value = config.get(key)
|
|
61
|
+
if not isinstance(value, str) or not value.strip():
|
|
62
|
+
raise _invalid(
|
|
63
|
+
f"Conformance field {key!r} must be a non-blank string.",
|
|
64
|
+
f"Set `with.{key}` to the identifier required by this check.",
|
|
65
|
+
)
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _optional_string(config: dict[str, object], key: str) -> str | None:
|
|
70
|
+
value = config.get(key)
|
|
71
|
+
if value is None:
|
|
72
|
+
return None
|
|
73
|
+
if not isinstance(value, str) or not value.strip():
|
|
74
|
+
raise _invalid(
|
|
75
|
+
f"Conformance field {key!r} must be null or a non-blank string.",
|
|
76
|
+
f"Remove `with.{key}` or set it to a valid graph identifier.",
|
|
77
|
+
)
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _integer(
|
|
82
|
+
config: dict[str, object], key: str, *, default: int | None = None, minimum: int = 0
|
|
83
|
+
) -> int:
|
|
84
|
+
value = config.get(key, default)
|
|
85
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
86
|
+
raise _invalid(
|
|
87
|
+
f"Conformance field {key!r} must be an integer of at least {minimum}.",
|
|
88
|
+
f"Set `with.{key}` to a valid integer.",
|
|
89
|
+
)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _number(
|
|
94
|
+
config: dict[str, object], key: str, *, default: float | None = None, positive: bool = False
|
|
95
|
+
) -> float:
|
|
96
|
+
value = config.get(key, default)
|
|
97
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
98
|
+
raise _invalid(
|
|
99
|
+
f"Conformance field {key!r} must be numeric.",
|
|
100
|
+
f"Set `with.{key}` to a finite number.",
|
|
101
|
+
)
|
|
102
|
+
numeric = float(value)
|
|
103
|
+
if not math.isfinite(numeric) or (positive and numeric <= 0.0):
|
|
104
|
+
qualifier = "a finite positive number" if positive else "a finite number"
|
|
105
|
+
raise _invalid(
|
|
106
|
+
f"Conformance field {key!r} must be {qualifier}.",
|
|
107
|
+
f"Set `with.{key}` to {qualifier}.",
|
|
108
|
+
)
|
|
109
|
+
return numeric
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _direction(config: dict[str, object], *, default: str = "any") -> str:
|
|
113
|
+
value = config.get("direction", default)
|
|
114
|
+
if value not in {"out", "in", "any"}:
|
|
115
|
+
raise _invalid(
|
|
116
|
+
"Conformance direction must be one of 'out', 'in', or 'any'.",
|
|
117
|
+
"Set `with.direction` to out, in, or any.",
|
|
118
|
+
)
|
|
119
|
+
return str(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _relationship_path(
|
|
123
|
+
direction: str, *, relationship_type: str | None = None, other_label: str | None = None
|
|
124
|
+
) -> str:
|
|
125
|
+
relationship = relationship_pattern("r", relationship_type)
|
|
126
|
+
other = node_pattern("other", other_label)
|
|
127
|
+
return {
|
|
128
|
+
"out": f"(n)-{relationship}->{other}",
|
|
129
|
+
"in": f"(n)<-{relationship}-{other}",
|
|
130
|
+
"any": f"(n)-{relationship}-{other}",
|
|
131
|
+
}[direction]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _schema_params(
|
|
135
|
+
*, labels: list[str], relationship_types: list[str], evidence_cap: int
|
|
136
|
+
) -> dict[str, object]:
|
|
137
|
+
return {
|
|
138
|
+
"required_labels": labels,
|
|
139
|
+
"required_relationship_types": relationship_types,
|
|
140
|
+
"evidence_cap": evidence_cap,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _node_predicate_queries(*, match: str, population: str, violation: str) -> tuple[str, str]:
|
|
145
|
+
return dedent(
|
|
146
|
+
f"""
|
|
147
|
+
{_SCHEMA_CATALOG}
|
|
148
|
+
CALL {{
|
|
149
|
+
MATCH {match}
|
|
150
|
+
WHERE {population}
|
|
151
|
+
RETURN count(n) AS population,
|
|
152
|
+
sum(CASE WHEN {violation} THEN 1 ELSE 0 END) AS violation_count
|
|
153
|
+
}}
|
|
154
|
+
RETURN {_SCHEMA_PROJECTION},
|
|
155
|
+
population, violation_count, [] AS evidence
|
|
156
|
+
"""
|
|
157
|
+
).strip(), dedent(
|
|
158
|
+
f"""
|
|
159
|
+
MATCH {match}
|
|
160
|
+
WHERE ({population}) AND ({violation})
|
|
161
|
+
WITH n ORDER BY elementId(n) LIMIT $evidence_cap
|
|
162
|
+
RETURN collect({_node_pointer("n")}) AS evidence
|
|
163
|
+
"""
|
|
164
|
+
).strip()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _relationship_predicate_queries(
|
|
168
|
+
*, match: str, population: str, violation: str
|
|
169
|
+
) -> tuple[str, str]:
|
|
170
|
+
return dedent(
|
|
171
|
+
f"""
|
|
172
|
+
{_SCHEMA_CATALOG}
|
|
173
|
+
CALL {{
|
|
174
|
+
MATCH {match}
|
|
175
|
+
WHERE {population}
|
|
176
|
+
RETURN count(r) AS population,
|
|
177
|
+
sum(CASE WHEN {violation} THEN 1 ELSE 0 END) AS violation_count
|
|
178
|
+
}}
|
|
179
|
+
RETURN {_SCHEMA_PROJECTION},
|
|
180
|
+
population, violation_count, [] AS evidence
|
|
181
|
+
"""
|
|
182
|
+
).strip(), dedent(
|
|
183
|
+
f"""
|
|
184
|
+
MATCH {match}
|
|
185
|
+
WHERE ({population}) AND ({violation})
|
|
186
|
+
WITH r ORDER BY elementId(r) LIMIT $evidence_cap
|
|
187
|
+
RETURN collect({_rel_pointer("r")}) AS evidence
|
|
188
|
+
"""
|
|
189
|
+
).strip()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _degree_queries(*, node: str, pattern: str, violation: str) -> tuple[str, str]:
|
|
193
|
+
return dedent(
|
|
194
|
+
f"""
|
|
195
|
+
{_SCHEMA_CATALOG}
|
|
196
|
+
CALL {{
|
|
197
|
+
MATCH {node}
|
|
198
|
+
OPTIONAL MATCH {pattern}
|
|
199
|
+
WITH n, count(r) AS degree
|
|
200
|
+
RETURN count(n) AS population,
|
|
201
|
+
sum(CASE WHEN {violation} THEN 1 ELSE 0 END) AS violation_count
|
|
202
|
+
}}
|
|
203
|
+
RETURN {_SCHEMA_PROJECTION},
|
|
204
|
+
population, violation_count, [] AS evidence
|
|
205
|
+
"""
|
|
206
|
+
).strip(), dedent(
|
|
207
|
+
f"""
|
|
208
|
+
MATCH {node}
|
|
209
|
+
OPTIONAL MATCH {pattern}
|
|
210
|
+
WITH n, count(r) AS degree
|
|
211
|
+
WHERE {violation}
|
|
212
|
+
WITH n ORDER BY elementId(n) LIMIT $evidence_cap
|
|
213
|
+
RETURN collect({_node_pointer("n")}) AS evidence
|
|
214
|
+
"""
|
|
215
|
+
).strip()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _split_evidence_plan(
|
|
219
|
+
queries: tuple[str, str],
|
|
220
|
+
*,
|
|
221
|
+
params: dict[str, object],
|
|
222
|
+
expected: dict[str, object],
|
|
223
|
+
name: str,
|
|
224
|
+
) -> ConformancePlan:
|
|
225
|
+
return ConformancePlan(
|
|
226
|
+
query=queries[0],
|
|
227
|
+
params=params,
|
|
228
|
+
expected=expected,
|
|
229
|
+
name=name,
|
|
230
|
+
evidence_query=queries[1],
|
|
231
|
+
evidence_params=params,
|
|
232
|
+
evidence_condition=EvidenceCondition("violation_count", "gt", 0),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@register_conformance_compiler("cardinality")
|
|
237
|
+
def _compile_cardinality(
|
|
238
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
239
|
+
) -> ConformancePlan:
|
|
240
|
+
del sample_seed
|
|
241
|
+
from_label = _string(config, "from_label")
|
|
242
|
+
rel_type = _string(config, "rel_type")
|
|
243
|
+
to_label = _string(config, "to_label")
|
|
244
|
+
direction = _direction(config, default="out")
|
|
245
|
+
exactly = _integer(config, "exactly", default=1)
|
|
246
|
+
queries = _degree_queries(
|
|
247
|
+
node=node_pattern("n", from_label),
|
|
248
|
+
pattern=_relationship_path(direction, relationship_type=rel_type, other_label=to_label),
|
|
249
|
+
violation="degree <> $exactly",
|
|
250
|
+
)
|
|
251
|
+
params = {
|
|
252
|
+
**_schema_params(
|
|
253
|
+
labels=[from_label, to_label],
|
|
254
|
+
relationship_types=[rel_type],
|
|
255
|
+
evidence_cap=evidence_cap,
|
|
256
|
+
),
|
|
257
|
+
"exactly": exactly,
|
|
258
|
+
}
|
|
259
|
+
return _split_evidence_plan(
|
|
260
|
+
queries,
|
|
261
|
+
params=params,
|
|
262
|
+
expected={"exactly": exactly},
|
|
263
|
+
name=f"{from_label} has exactly {exactly} {rel_type} relationship(s) to {to_label}",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@register_conformance_compiler("no_orphans")
|
|
268
|
+
def _compile_no_orphans(
|
|
269
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
270
|
+
) -> ConformancePlan:
|
|
271
|
+
del sample_seed
|
|
272
|
+
label = _string(config, "label")
|
|
273
|
+
rel_type = _optional_string(config, "rel_type")
|
|
274
|
+
direction = _direction(config)
|
|
275
|
+
queries = _degree_queries(
|
|
276
|
+
node=node_pattern("n", label),
|
|
277
|
+
pattern=_relationship_path(direction, relationship_type=rel_type),
|
|
278
|
+
violation="degree = 0",
|
|
279
|
+
)
|
|
280
|
+
params = {
|
|
281
|
+
**_schema_params(
|
|
282
|
+
labels=[label],
|
|
283
|
+
relationship_types=[rel_type] if rel_type is not None else [],
|
|
284
|
+
evidence_cap=evidence_cap,
|
|
285
|
+
),
|
|
286
|
+
}
|
|
287
|
+
return _split_evidence_plan(
|
|
288
|
+
queries,
|
|
289
|
+
params=params,
|
|
290
|
+
expected={"orphans": 0},
|
|
291
|
+
name=f"{label} nodes are connected",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@register_conformance_compiler("dangling_rels")
|
|
296
|
+
def _compile_dangling_rels(
|
|
297
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
298
|
+
) -> ConformancePlan:
|
|
299
|
+
del config, evidence_cap, sample_seed
|
|
300
|
+
# Cypher exposes a relationship only after resolving both endpoints, so MATCH can never
|
|
301
|
+
# observe the backing-store corruption this C3 check describes. Reporting zero would be an
|
|
302
|
+
# optimistic false pass; fail closed until C2 exposes a supported store-consistency probe.
|
|
303
|
+
raise GraphCheckError(
|
|
304
|
+
"engine.check_unobservable",
|
|
305
|
+
"dangling_rels cannot be observed through Neo4j's read-only Cypher surface.",
|
|
306
|
+
"Run Neo4j's offline consistency checker, or install a connector capability that "
|
|
307
|
+
"provides a read-only relationship-store integrity probe.",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
_TYPE_MATCH_TEMPLATE = """
|
|
312
|
+
coalesce(
|
|
313
|
+
CASE $expected_type
|
|
314
|
+
WHEN 'string' THEN toStringOrNull(__GC_PROPERTY__) = __GC_PROPERTY__
|
|
315
|
+
WHEN 'integer' THEN
|
|
316
|
+
toIntegerOrNull(__GC_PROPERTY__) = __GC_PROPERTY__
|
|
317
|
+
AND toStringOrNull(__GC_PROPERTY__) =~ '^-?[0-9]+$'
|
|
318
|
+
WHEN 'float' THEN
|
|
319
|
+
toFloatOrNull(__GC_PROPERTY__) = __GC_PROPERTY__
|
|
320
|
+
AND NOT (
|
|
321
|
+
toIntegerOrNull(__GC_PROPERTY__) = __GC_PROPERTY__
|
|
322
|
+
AND toStringOrNull(__GC_PROPERTY__) =
|
|
323
|
+
toStringOrNull(toIntegerOrNull(__GC_PROPERTY__))
|
|
324
|
+
)
|
|
325
|
+
WHEN 'boolean' THEN toBooleanOrNull(__GC_PROPERTY__) = __GC_PROPERTY__
|
|
326
|
+
WHEN 'date' THEN
|
|
327
|
+
NOT (toStringOrNull(__GC_PROPERTY__) = __GC_PROPERTY__)
|
|
328
|
+
AND toStringOrNull(__GC_PROPERTY__) =~ '^-?[0-9]{4,}-[0-9]{2}-[0-9]{2}$'
|
|
329
|
+
WHEN 'datetime' THEN
|
|
330
|
+
NOT (toStringOrNull(__GC_PROPERTY__) = __GC_PROPERTY__)
|
|
331
|
+
AND toStringOrNull(__GC_PROPERTY__) =~ '^-?[0-9]{4,}-[0-9]{2}-[0-9]{2}T.*$'
|
|
332
|
+
ELSE false
|
|
333
|
+
END,
|
|
334
|
+
false
|
|
335
|
+
)
|
|
336
|
+
""".strip()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@register_conformance_compiler("property_type")
|
|
340
|
+
def _compile_property_type(
|
|
341
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
342
|
+
) -> ConformancePlan:
|
|
343
|
+
del sample_seed
|
|
344
|
+
label = _string(config, "label")
|
|
345
|
+
property_name = _string(config, "property")
|
|
346
|
+
expected_type = _string(config, "type")
|
|
347
|
+
property_ref = property_access("n", property_name)
|
|
348
|
+
queries = _node_predicate_queries(
|
|
349
|
+
match=node_pattern("n", label),
|
|
350
|
+
population=f"{property_ref} IS NOT NULL",
|
|
351
|
+
violation=f"NOT ({_TYPE_MATCH_TEMPLATE.replace('__GC_PROPERTY__', property_ref)})",
|
|
352
|
+
)
|
|
353
|
+
params = {
|
|
354
|
+
**_schema_params(labels=[label], relationship_types=[], evidence_cap=evidence_cap),
|
|
355
|
+
"expected_type": expected_type,
|
|
356
|
+
}
|
|
357
|
+
return _split_evidence_plan(
|
|
358
|
+
queries,
|
|
359
|
+
params=params,
|
|
360
|
+
expected={"type": expected_type},
|
|
361
|
+
name=f"{label}.{property_name} values have type {expected_type}",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@register_conformance_compiler("property_format")
|
|
366
|
+
def _compile_property_format(
|
|
367
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
368
|
+
) -> ConformancePlan:
|
|
369
|
+
del sample_seed
|
|
370
|
+
label = _string(config, "label")
|
|
371
|
+
property_name = _string(config, "property")
|
|
372
|
+
regex = _string(config, "regex")
|
|
373
|
+
property_ref = property_access("n", property_name)
|
|
374
|
+
queries = _node_predicate_queries(
|
|
375
|
+
match=node_pattern("n", label),
|
|
376
|
+
population=f"{property_ref} IS NOT NULL",
|
|
377
|
+
violation=(
|
|
378
|
+
f"NOT coalesce(toStringOrNull({property_ref}) = {property_ref} "
|
|
379
|
+
f"AND toStringOrNull({property_ref}) =~ $regex, false)"
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
params = {
|
|
383
|
+
**_schema_params(labels=[label], relationship_types=[], evidence_cap=evidence_cap),
|
|
384
|
+
"regex": regex,
|
|
385
|
+
}
|
|
386
|
+
return _split_evidence_plan(
|
|
387
|
+
queries,
|
|
388
|
+
params=params,
|
|
389
|
+
expected={"regex": regex},
|
|
390
|
+
name=f"{label}.{property_name} matches its required format",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@register_conformance_compiler("value_in_set")
|
|
395
|
+
def _compile_value_in_set(
|
|
396
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
397
|
+
) -> ConformancePlan:
|
|
398
|
+
del sample_seed
|
|
399
|
+
label = _string(config, "label")
|
|
400
|
+
property_name = _string(config, "property")
|
|
401
|
+
values = config.get("values")
|
|
402
|
+
if not isinstance(values, list) or not values:
|
|
403
|
+
raise _invalid(
|
|
404
|
+
"Conformance field 'values' must be a non-empty list.",
|
|
405
|
+
"Set `with.values` to the finite set accepted by this property.",
|
|
406
|
+
)
|
|
407
|
+
normalized_values = list(values)
|
|
408
|
+
property_ref = property_access("n", property_name)
|
|
409
|
+
queries = _node_predicate_queries(
|
|
410
|
+
match=node_pattern("n", label),
|
|
411
|
+
population=f"{property_ref} IS NOT NULL",
|
|
412
|
+
violation=f"NOT {property_ref} IN $values",
|
|
413
|
+
)
|
|
414
|
+
params = {
|
|
415
|
+
**_schema_params(labels=[label], relationship_types=[], evidence_cap=evidence_cap),
|
|
416
|
+
"values": normalized_values,
|
|
417
|
+
}
|
|
418
|
+
return _split_evidence_plan(
|
|
419
|
+
queries,
|
|
420
|
+
params=params,
|
|
421
|
+
expected={"values": normalized_values},
|
|
422
|
+
name=f"{label}.{property_name} is in the allowed value set",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@register_conformance_compiler("uniqueness")
|
|
427
|
+
def _compile_uniqueness(
|
|
428
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
429
|
+
) -> ConformancePlan:
|
|
430
|
+
del sample_seed
|
|
431
|
+
label = _string(config, "label")
|
|
432
|
+
property_name = _string(config, "property")
|
|
433
|
+
node = node_pattern("n", label)
|
|
434
|
+
property_ref = property_access("n", property_name)
|
|
435
|
+
query = dedent(
|
|
436
|
+
f"""
|
|
437
|
+
{_SCHEMA_CATALOG}
|
|
438
|
+
CALL {{
|
|
439
|
+
MATCH {node}
|
|
440
|
+
WHERE {property_ref} IS NOT NULL
|
|
441
|
+
RETURN count(n) AS population
|
|
442
|
+
}}
|
|
443
|
+
CALL {{
|
|
444
|
+
MATCH {node}
|
|
445
|
+
WHERE {property_ref} IS NOT NULL
|
|
446
|
+
WITH {property_ref} AS value, count(n) AS frequency
|
|
447
|
+
WHERE frequency > 1
|
|
448
|
+
RETURN coalesce(sum(frequency), 0) AS violation_count
|
|
449
|
+
}}
|
|
450
|
+
RETURN {_SCHEMA_PROJECTION},
|
|
451
|
+
population, violation_count, [] AS evidence
|
|
452
|
+
"""
|
|
453
|
+
).strip()
|
|
454
|
+
evidence_query = dedent(
|
|
455
|
+
f"""
|
|
456
|
+
MATCH {node}
|
|
457
|
+
WHERE {property_ref} IS NOT NULL
|
|
458
|
+
AND EXISTS {{
|
|
459
|
+
MATCH {node_pattern("duplicate", label)}
|
|
460
|
+
WHERE {property_access("duplicate", property_name)} = {property_ref}
|
|
461
|
+
AND elementId(duplicate) <> elementId(n)
|
|
462
|
+
}}
|
|
463
|
+
WITH n ORDER BY elementId(n) LIMIT $evidence_cap
|
|
464
|
+
RETURN collect({_node_pointer("n")}) AS evidence
|
|
465
|
+
"""
|
|
466
|
+
).strip()
|
|
467
|
+
params = {
|
|
468
|
+
**_schema_params(labels=[label], relationship_types=[], evidence_cap=evidence_cap),
|
|
469
|
+
}
|
|
470
|
+
return ConformancePlan(
|
|
471
|
+
query=query,
|
|
472
|
+
params=params,
|
|
473
|
+
expected={"unique": True},
|
|
474
|
+
name=f"{label}.{property_name} is unique",
|
|
475
|
+
evidence_query=evidence_query,
|
|
476
|
+
evidence_params=params,
|
|
477
|
+
evidence_condition=EvidenceCondition("violation_count", "gt", 0),
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@register_conformance_compiler("hub_outlier")
|
|
482
|
+
def _compile_hub_outlier(
|
|
483
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
484
|
+
) -> ConformancePlan:
|
|
485
|
+
label = _string(config, "label")
|
|
486
|
+
rel_type = _optional_string(config, "rel_type")
|
|
487
|
+
direction = _direction(config)
|
|
488
|
+
z_threshold = _number(config, "z_threshold", default=3.0, positive=True)
|
|
489
|
+
requested_sample_size = (
|
|
490
|
+
_DEFAULT_HUB_SAMPLE_SIZE
|
|
491
|
+
if config.get("sample_size") is None
|
|
492
|
+
else _integer(config, "sample_size", minimum=1)
|
|
493
|
+
)
|
|
494
|
+
if isinstance(sample_seed, bool) or not isinstance(sample_seed, int) or sample_seed < 0:
|
|
495
|
+
raise _invalid(
|
|
496
|
+
"Hub sample_seed must be a non-negative integer.",
|
|
497
|
+
"Use the C1 sampling policy to derive a deterministic check seed.",
|
|
498
|
+
)
|
|
499
|
+
node = node_pattern("n", label)
|
|
500
|
+
pattern = _relationship_path(direction, relationship_type=rel_type)
|
|
501
|
+
hash_params = cypher_hash_parameters(sample_seed)
|
|
502
|
+
hash_expression = cypher_hash_expression("_gc_sample_input")
|
|
503
|
+
gate_hash_expression = cypher_hash_expression("_gc_sample_input").replace(
|
|
504
|
+
"$sample_hash_", "$sample_gate_hash_"
|
|
505
|
+
)
|
|
506
|
+
gate_hash_params = {
|
|
507
|
+
name.replace("sample_hash_", "sample_gate_hash_"): value
|
|
508
|
+
for name, value in cypher_hash_parameters(sample_seed + 1).items()
|
|
509
|
+
}
|
|
510
|
+
# Numeric internal IDs remain only inside this explicitly selected Cypher 5 sampling path.
|
|
511
|
+
# elementId() has no guaranteed parseable format, so changing the rank input would silently
|
|
512
|
+
# change the sampling algorithm and requires a separately versioned transition.
|
|
513
|
+
query = (
|
|
514
|
+
"CYPHER 5\n"
|
|
515
|
+
+ dedent(
|
|
516
|
+
f"""
|
|
517
|
+
{_SCHEMA_CATALOG}
|
|
518
|
+
CALL {{
|
|
519
|
+
MATCH {node}
|
|
520
|
+
RETURN count(n) AS population
|
|
521
|
+
}}
|
|
522
|
+
CALL {{
|
|
523
|
+
WITH population
|
|
524
|
+
CALL {{
|
|
525
|
+
WITH population
|
|
526
|
+
WITH population
|
|
527
|
+
WHERE population <= $exhaustive_limit
|
|
528
|
+
MATCH {node}
|
|
529
|
+
RETURN n
|
|
530
|
+
UNION
|
|
531
|
+
WITH population
|
|
532
|
+
WITH population
|
|
533
|
+
WHERE population > $exhaustive_limit
|
|
534
|
+
MATCH {node}
|
|
535
|
+
WITH population, n, id(n) % {CYPHER_SAMPLE_MODULUS} AS _gc_sample_input
|
|
536
|
+
WITH population, n, _gc_sample_input,
|
|
537
|
+
{gate_hash_expression} AS _gc_gate_key
|
|
538
|
+
WHERE _gc_gate_key < toInteger(ceil(
|
|
539
|
+
toFloat({CYPHER_SAMPLE_MODULUS})
|
|
540
|
+
* toFloat($sample_size)
|
|
541
|
+
* toFloat($sample_gate_multiplier)
|
|
542
|
+
/ toFloat(population)
|
|
543
|
+
))
|
|
544
|
+
WITH n, {hash_expression} AS _gc_sample_key
|
|
545
|
+
ORDER BY _gc_sample_key, id(n)
|
|
546
|
+
LIMIT $sample_size
|
|
547
|
+
RETURN n
|
|
548
|
+
}}
|
|
549
|
+
OPTIONAL MATCH {pattern}
|
|
550
|
+
WITH n, count(r) AS degree
|
|
551
|
+
WITH collect({{node: n, degree: degree}}) AS sampled_nodes,
|
|
552
|
+
coalesce(avg(toFloat(degree)), 0.0) AS mean_degree,
|
|
553
|
+
coalesce(stDevP(toFloat(degree)), 0.0) AS degree_stddev
|
|
554
|
+
WITH sampled_nodes, mean_degree, degree_stddev,
|
|
555
|
+
[item IN sampled_nodes
|
|
556
|
+
WHERE degree_stddev > 0.0
|
|
557
|
+
AND toFloat(item.degree) > mean_degree + $z_threshold * degree_stddev]
|
|
558
|
+
AS violations
|
|
559
|
+
RETURN size(sampled_nodes) AS sample_size,
|
|
560
|
+
size(violations) AS violation_count,
|
|
561
|
+
mean_degree,
|
|
562
|
+
degree_stddev,
|
|
563
|
+
[item IN violations[0..$evidence_cap] |
|
|
564
|
+
{{kind: 'node', id: elementId(item.node), labels: labels(item.node)}}]
|
|
565
|
+
AS evidence
|
|
566
|
+
}}
|
|
567
|
+
RETURN {_SCHEMA_PROJECTION},
|
|
568
|
+
population, sample_size, violation_count,
|
|
569
|
+
mean_degree, degree_stddev, evidence
|
|
570
|
+
"""
|
|
571
|
+
).strip()
|
|
572
|
+
)
|
|
573
|
+
params = {
|
|
574
|
+
**_schema_params(
|
|
575
|
+
labels=[label],
|
|
576
|
+
relationship_types=[rel_type] if rel_type is not None else [],
|
|
577
|
+
evidence_cap=evidence_cap,
|
|
578
|
+
),
|
|
579
|
+
"z_threshold": z_threshold,
|
|
580
|
+
"sample_size": requested_sample_size,
|
|
581
|
+
"exhaustive_limit": 100_000,
|
|
582
|
+
"sample_gate_multiplier": _SAMPLE_GATE_MULTIPLIER,
|
|
583
|
+
**hash_params,
|
|
584
|
+
**gate_hash_params,
|
|
585
|
+
}
|
|
586
|
+
return ConformancePlan(
|
|
587
|
+
query=query,
|
|
588
|
+
params=params,
|
|
589
|
+
expected={"z_threshold": z_threshold, "sample_size": requested_sample_size},
|
|
590
|
+
name=f"{label} relationship degree stays within {z_threshold:g} standard deviations",
|
|
591
|
+
sampled=True,
|
|
592
|
+
sampling_preflight=False,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@register_conformance_compiler("label_cooccurrence")
|
|
597
|
+
def _compile_label_cooccurrence(
|
|
598
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
599
|
+
) -> ConformancePlan:
|
|
600
|
+
del sample_seed
|
|
601
|
+
label_a = _string(config, "label_a")
|
|
602
|
+
label_b = _string(config, "label_b")
|
|
603
|
+
label_a_predicate = f"n:{cypher_identifier(label_a)}"
|
|
604
|
+
label_b_predicate = f"n:{cypher_identifier(label_b)}"
|
|
605
|
+
queries = _node_predicate_queries(
|
|
606
|
+
match=node_pattern("n"),
|
|
607
|
+
population=f"{label_a_predicate} OR {label_b_predicate}",
|
|
608
|
+
violation=f"{label_a_predicate} AND {label_b_predicate}",
|
|
609
|
+
)
|
|
610
|
+
params = {
|
|
611
|
+
**_schema_params(
|
|
612
|
+
labels=[label_a, label_b], relationship_types=[], evidence_cap=evidence_cap
|
|
613
|
+
),
|
|
614
|
+
}
|
|
615
|
+
return _split_evidence_plan(
|
|
616
|
+
queries,
|
|
617
|
+
params=params,
|
|
618
|
+
expected={"cooccurrences": 0},
|
|
619
|
+
name=f"{label_a} and {label_b} do not co-occur",
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@register_conformance_compiler("rel_direction")
|
|
624
|
+
def _compile_rel_direction(
|
|
625
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
626
|
+
) -> ConformancePlan:
|
|
627
|
+
del sample_seed
|
|
628
|
+
from_label = _string(config, "from_label")
|
|
629
|
+
rel_type = _string(config, "rel_type")
|
|
630
|
+
to_label = _string(config, "to_label")
|
|
631
|
+
from_token = cypher_identifier(from_label)
|
|
632
|
+
to_token = cypher_identifier(to_label)
|
|
633
|
+
population = (
|
|
634
|
+
f"(source:{from_token} AND target:{to_token}) "
|
|
635
|
+
f"OR (source:{to_token} AND target:{from_token})"
|
|
636
|
+
)
|
|
637
|
+
violation = f"source:{to_token} AND target:{from_token}"
|
|
638
|
+
queries = _relationship_predicate_queries(
|
|
639
|
+
match=f"(source)-{relationship_pattern('r', rel_type)}->(target)",
|
|
640
|
+
population=population,
|
|
641
|
+
violation=violation,
|
|
642
|
+
)
|
|
643
|
+
params = {
|
|
644
|
+
**_schema_params(
|
|
645
|
+
labels=[from_label, to_label],
|
|
646
|
+
relationship_types=[rel_type],
|
|
647
|
+
evidence_cap=evidence_cap,
|
|
648
|
+
),
|
|
649
|
+
}
|
|
650
|
+
return _split_evidence_plan(
|
|
651
|
+
queries,
|
|
652
|
+
params=params,
|
|
653
|
+
expected={"wrong_direction": 0},
|
|
654
|
+
name=f"{rel_type} points from {from_label} to {to_label}",
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@register_conformance_compiler("temporal_sanity")
|
|
659
|
+
def _compile_temporal_sanity(
|
|
660
|
+
config: dict[str, object], evidence_cap: int, sample_seed: int
|
|
661
|
+
) -> ConformancePlan:
|
|
662
|
+
del sample_seed
|
|
663
|
+
label = _string(config, "label")
|
|
664
|
+
start_property = _string(config, "start_property")
|
|
665
|
+
end_property = _string(config, "end_property")
|
|
666
|
+
start_ref = property_access("n", start_property)
|
|
667
|
+
end_ref = property_access("n", end_property)
|
|
668
|
+
queries = _node_predicate_queries(
|
|
669
|
+
match=node_pattern("n", label),
|
|
670
|
+
population=f"{start_ref} IS NOT NULL AND {end_ref} IS NOT NULL",
|
|
671
|
+
violation=f"{end_ref} < {start_ref}",
|
|
672
|
+
)
|
|
673
|
+
params = {
|
|
674
|
+
**_schema_params(labels=[label], relationship_types=[], evidence_cap=evidence_cap),
|
|
675
|
+
}
|
|
676
|
+
return _split_evidence_plan(
|
|
677
|
+
queries,
|
|
678
|
+
params=params,
|
|
679
|
+
expected={"end_not_before_start": True},
|
|
680
|
+
name=f"{label}.{end_property} is not before {start_property}",
|
|
681
|
+
)
|