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