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,119 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from urllib.parse import urlsplit
6
+
7
+ import yaml
8
+ from pydantic import BaseModel, ConfigDict, ValidationError
9
+
10
+ from graphcheck.errors import (
11
+ profile_invalid,
12
+ profile_missing,
13
+ profile_not_found,
14
+ profile_password_missing,
15
+ profile_uri_invalid,
16
+ )
17
+ from graphcheck.project import PROFILES_FILE
18
+
19
+
20
+ class ConnectionProfile(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ uri: str
24
+ user: str
25
+ password: str | None = None
26
+ password_env: str | None = None
27
+ database: str
28
+
29
+ def resolved(self, name: str) -> ConnectionProfile:
30
+ if self.password_env:
31
+ env_password = os.environ.get(self.password_env)
32
+ if env_password:
33
+ return self.model_copy(update={"password": env_password, "password_env": None})
34
+ if not self.password:
35
+ raise profile_password_missing(name, self.password_env)
36
+ if not self.password:
37
+ raise profile_password_missing(name)
38
+ return self.model_copy(update={"password_env": None})
39
+
40
+
41
+ class ProfilesFile(BaseModel):
42
+ model_config = ConfigDict(extra="forbid")
43
+
44
+ default: str
45
+ profiles: dict[str, ConnectionProfile]
46
+
47
+
48
+ def default_profiles() -> ProfilesFile:
49
+ return ProfilesFile(
50
+ default="local",
51
+ profiles={
52
+ "local": ConnectionProfile(
53
+ uri="bolt://localhost:7687",
54
+ user="neo4j",
55
+ password="graphora",
56
+ database="neo4j",
57
+ )
58
+ },
59
+ )
60
+
61
+
62
+ def write_default_profiles(root: Path) -> None:
63
+ (root / PROFILES_FILE).write_text(
64
+ """# Edit the inline password below for the fastest local setup.
65
+ # For CI/production, remove `password` and use `password_env: NEO4J_PASSWORD` instead.
66
+ # Enterprise: assign only the built-in reader role. Community: EXPLAIN guards each query.
67
+ # Use bolt:// for direct local Bolt, or neo4j+s:// for a CA-signed TLS/routing endpoint.
68
+ default: local
69
+ profiles:
70
+ local:
71
+ uri: bolt://localhost:7687
72
+ user: neo4j
73
+ password: graphora
74
+ database: neo4j
75
+ """,
76
+ encoding="utf-8",
77
+ )
78
+
79
+
80
+ def load_profiles(root: Path) -> ProfilesFile:
81
+ path = root / PROFILES_FILE
82
+ if not path.exists():
83
+ raise profile_missing()
84
+ try:
85
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
86
+ return ProfilesFile.model_validate(raw)
87
+ except (OSError, yaml.YAMLError, ValidationError) as exc:
88
+ raise profile_invalid(f"Invalid profiles.yml: {exc}") from exc
89
+
90
+
91
+ def select_profile(
92
+ profiles: ProfilesFile, name: str | None = None
93
+ ) -> tuple[str, ConnectionProfile]:
94
+ selected = name or profiles.default
95
+ try:
96
+ profile = profiles.profiles[selected]
97
+ except KeyError as exc:
98
+ raise profile_not_found(selected) from exc
99
+ validate_profile_uri(profile.uri)
100
+ return selected, profile.resolved(selected)
101
+
102
+
103
+ def validate_profile_uri(uri: str) -> None:
104
+ try:
105
+ parsed = urlsplit(uri)
106
+ valid = parsed.scheme in {
107
+ "bolt",
108
+ "bolt+s",
109
+ "bolt+ssc",
110
+ "neo4j",
111
+ "neo4j+s",
112
+ "neo4j+ssc",
113
+ } and bool(parsed.hostname)
114
+ if parsed.port is not None and not 1 <= parsed.port <= 65535:
115
+ valid = False
116
+ except ValueError:
117
+ valid = False
118
+ if not valid:
119
+ raise profile_uri_invalid(uri)
File without changes
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, model_validator
7
+
8
+ from graphcheck.contracts.results import Pattern, Severity
9
+ from graphcheck.contracts.scalars import JsonSchemaInteger
10
+ from graphcheck.packs import REGISTRY
11
+ from graphcheck.yaml_loader import DuplicateKeyError as DuplicateKeyError
12
+ from graphcheck.yaml_loader import load_yaml_mapping
13
+
14
+
15
+ class UnknownCheckError(ValueError):
16
+ """A conformance check references a `check` name not in the pack registry."""
17
+
18
+
19
+ class SuiteValidationError(ValueError):
20
+ """A suite file is syntactically valid YAML but invalid as a GraphCheck suite."""
21
+
22
+
23
+ def load_suite_yaml(text: str) -> dict:
24
+ try:
25
+ return load_yaml_mapping(text, description="a suite file")
26
+ except DuplicateKeyError:
27
+ raise
28
+ except ValueError as exc:
29
+ raise SuiteValidationError(str(exc)) from exc
30
+
31
+
32
+ def _parse_severity(value: object) -> object:
33
+ if type(value) is str:
34
+ return Severity(value)
35
+ return value
36
+
37
+
38
+ type SuiteSeverity = Annotated[Severity | None, BeforeValidator(_parse_severity)]
39
+
40
+
41
+ class _Strict(BaseModel):
42
+ model_config = ConfigDict(extra="forbid", strict=True)
43
+
44
+
45
+ class Defaults(_Strict):
46
+ severity: SuiteSeverity = None
47
+ tags: list[str] = []
48
+
49
+
50
+ class _Envelope(_Strict):
51
+ id: str
52
+ severity: SuiteSeverity = None
53
+ tags: list[str] = []
54
+ provenance: str | None = None
55
+ generated: bool = False
56
+
57
+
58
+ class ConformanceCheck(_Envelope):
59
+ # No populate_by_name: the external key is the frozen `with` alias only, never `with_`.
60
+ check: str
61
+ with_: dict = Field(alias="with") # required — SPEC-02 freezes `check` + `with` for conformance
62
+
63
+
64
+ class RowBounds(_Strict):
65
+ min: JsonSchemaInteger | None = None
66
+ max: JsonSchemaInteger | None = None
67
+ exactly: JsonSchemaInteger | None = None
68
+
69
+ @model_validator(mode="after")
70
+ def _bounds_are_meaningful_and_consistent(self) -> RowBounds:
71
+ values = {"min": self.min, "max": self.max, "exactly": self.exactly}
72
+ if all(value is None for value in values.values()):
73
+ raise ValueError("rows must declare at least one of min, max, or exactly")
74
+ for name, value in values.items():
75
+ if value is not None and value < 0:
76
+ raise ValueError(f"rows.{name} must be non-negative")
77
+ if self.min is not None and self.max is not None and self.min > self.max:
78
+ raise ValueError("rows.min must not exceed rows.max")
79
+ if self.exactly is not None:
80
+ if self.min is not None and self.exactly < self.min:
81
+ raise ValueError("rows.exactly must not be less than rows.min")
82
+ if self.max is not None and self.exactly > self.max:
83
+ raise ValueError("rows.exactly must not exceed rows.max")
84
+ return self
85
+
86
+ def permits(self, count: int) -> bool:
87
+ if self.exactly is not None and count != self.exactly:
88
+ return False
89
+ if self.min is not None and count < self.min:
90
+ return False
91
+ return self.max is None or count <= self.max
92
+
93
+ def permits_nonempty(self) -> bool:
94
+ if self.exactly is not None:
95
+ return self.exactly > 0
96
+ return self.max is None or self.max > 0
97
+
98
+
99
+ class Expect(_Strict):
100
+ rows: RowBounds | None = None
101
+ columns: list[str] | None = None
102
+ unique: bool | None = None
103
+ contains: list | None = None
104
+ equals: list | None = None
105
+ empty: bool | None = None
106
+
107
+ @model_validator(mode="after")
108
+ def _assertions_are_meaningful_and_consistent(self) -> Expect:
109
+ assertions = (
110
+ self.rows,
111
+ self.columns,
112
+ self.unique,
113
+ self.contains,
114
+ self.equals,
115
+ self.empty,
116
+ )
117
+ if all(assertion is None for assertion in assertions):
118
+ raise ValueError("expect must declare at least one assertion")
119
+ if self.contains == []:
120
+ raise ValueError("expect.contains must not be empty")
121
+
122
+ if self.rows is not None:
123
+ if self.empty is True and not self.rows.permits(0):
124
+ raise ValueError("expect.empty=true conflicts with expect.rows")
125
+ if self.empty is False and not self.rows.permits_nonempty():
126
+ raise ValueError("expect.empty=false conflicts with expect.rows")
127
+ if self.equals is not None and not self.rows.permits(len(self.equals)):
128
+ raise ValueError("expect.equals length conflicts with expect.rows")
129
+ if self.contains and not self.rows.permits_nonempty():
130
+ raise ValueError("expect.contains conflicts with expect.rows")
131
+
132
+ if self.empty is True:
133
+ if self.contains:
134
+ raise ValueError("expect.empty=true conflicts with expect.contains")
135
+ if self.equals is not None and self.equals:
136
+ raise ValueError("expect.empty=true conflicts with expect.equals")
137
+ elif self.empty is False and self.equals == []:
138
+ raise ValueError("expect.empty=false conflicts with empty expect.equals")
139
+
140
+ if (
141
+ self.contains is not None
142
+ and self.equals is not None
143
+ and any(value not in self.equals for value in self.contains)
144
+ ):
145
+ raise ValueError("expect.contains must be a subset of expect.equals")
146
+ return self
147
+
148
+
149
+ class CompetencyCheck(_Envelope):
150
+ question: str
151
+ query: str
152
+ params: dict = {}
153
+ expect: Expect
154
+
155
+ @model_validator(mode="after")
156
+ def _content_is_valid(self) -> CompetencyCheck:
157
+ if not self.question.strip():
158
+ raise ValueError("competency question must not be blank")
159
+ if not self.query.strip():
160
+ raise ValueError("competency query must not be blank")
161
+ if any(not isinstance(key, str) for key in self.params):
162
+ raise ValueError("competency params keys must be strings")
163
+ return self
164
+
165
+
166
+ class DriftCheck(_Envelope):
167
+ metric: str
168
+ target: dict
169
+ baseline: str = "latest"
170
+ tolerance: dict
171
+
172
+ @model_validator(mode="after")
173
+ def _content_is_valid(self) -> DriftCheck:
174
+ if not self.metric.strip():
175
+ raise ValueError("drift metric must not be blank")
176
+ if not self.baseline.strip():
177
+ raise ValueError("drift baseline must not be blank")
178
+ if not self.tolerance:
179
+ raise ValueError("drift tolerance must not be empty")
180
+ if any(not isinstance(key, str) for key in self.target):
181
+ raise ValueError("drift target keys must be strings")
182
+ if any(not isinstance(key, str) for key in self.tolerance):
183
+ raise ValueError("drift tolerance keys must be strings")
184
+ return self
185
+
186
+
187
+ class LoadedCheck(_Strict):
188
+ id: str
189
+ pattern: Pattern
190
+ severity: Severity # resolved (check -> defaults -> error)
191
+ tags: list[str] # resolved (defaults + check)
192
+ provenance: str | None = None
193
+ generated: bool # effective (file OR check)
194
+ spec: ConformanceCheck | CompetencyCheck | DriftCheck # validated payload the engine executes
195
+
196
+
197
+ class Suite(_Strict):
198
+ suite: str
199
+ checks: list[LoadedCheck]
200
+
201
+
202
+ class _SuiteFile(_Strict):
203
+ suite: str | None = None # optional in the file; falls back to the source filename stem
204
+ generated: bool = False
205
+ defaults: Defaults = Defaults()
206
+ conformance: list[ConformanceCheck] = []
207
+ competency: list[CompetencyCheck] = []
208
+ drift: list[DriftCheck] = []
209
+
210
+
211
+ def _competency_pattern(expect: Expect) -> Pattern:
212
+ if expect.contains is not None or expect.equals is not None:
213
+ return Pattern.COMPETENCY_REGRESSION
214
+ return Pattern.COMPETENCY_SHAPE
215
+
216
+
217
+ def load_suite(text: str, *, source: str | None = None) -> Suite:
218
+ raw = load_suite_yaml(text)
219
+ parsed = _SuiteFile.model_validate(raw)
220
+ suite_id = parsed.suite or (Path(source).stem if source else None)
221
+ if not suite_id:
222
+ raise SuiteValidationError("suite name required: no `suite:` key and no source filename")
223
+ defaults = parsed.defaults
224
+ checks: list[LoadedCheck] = []
225
+
226
+ def resolve(env: _Envelope, pattern: Pattern) -> LoadedCheck:
227
+ severity = env.severity or defaults.severity or Severity.ERROR
228
+ tags = list(dict.fromkeys([*defaults.tags, *env.tags]))
229
+ generated = parsed.generated or env.generated
230
+ return LoadedCheck(
231
+ id=env.id,
232
+ pattern=pattern,
233
+ severity=severity,
234
+ tags=tags,
235
+ provenance=env.provenance,
236
+ generated=generated,
237
+ spec=env,
238
+ )
239
+
240
+ for c in parsed.conformance:
241
+ if c.check not in REGISTRY:
242
+ raise UnknownCheckError(f"unknown check type: {c.check!r}")
243
+ # Validate `with` against the pack model AND keep the normalized result, so pack
244
+ # defaults (e.g. completeness threshold=1.0) survive onto spec.with_ for the engine.
245
+ c.with_ = REGISTRY[c.check].model_validate(c.with_).model_dump()
246
+ checks.append(resolve(c, Pattern.CONFORMANCE))
247
+ for c in parsed.competency:
248
+ checks.append(resolve(c, _competency_pattern(c.expect)))
249
+ for c in parsed.drift:
250
+ checks.append(resolve(c, Pattern.DRIFT))
251
+
252
+ seen: set[str] = set()
253
+ for lc in checks:
254
+ if lc.id in seen:
255
+ raise SuiteValidationError(f"duplicate check id {lc.id!r} in suite {suite_id!r}")
256
+ seen.add(lc.id)
257
+
258
+ return Suite(suite=suite_id, checks=checks)
@@ -0,0 +1,290 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Hashable
6
+ from enum import StrEnum
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
10
+
11
+ from graphcheck.contracts.results import RunTarget
12
+
13
+ SCHEMA_VERSION = "1.0"
14
+ FINGERPRINT_ALGORITHM = "sha256"
15
+
16
+
17
+ class ProfileStatus(StrEnum):
18
+ COMPLETE = "complete"
19
+ PARTIAL = "partial"
20
+
21
+
22
+ class _Strict(BaseModel):
23
+ model_config = ConfigDict(
24
+ extra="forbid",
25
+ strict=True,
26
+ )
27
+
28
+
29
+ class ProfileMetadata(_Strict):
30
+ generated_at: str
31
+ graphcheck_version: str
32
+
33
+
34
+ class ProfileProperty(_Strict):
35
+ name: str
36
+ type: str
37
+
38
+
39
+ class LabelProfile(_Strict):
40
+ name: str
41
+ count: int = Field(ge=0)
42
+ properties: list[ProfileProperty]
43
+ degree_distribution: DegreeDistribution | None
44
+
45
+
46
+ class RelationshipTypeProfile(_Strict):
47
+ name: str
48
+ count: int = Field(ge=0)
49
+
50
+
51
+ class ConstraintProfile(_Strict):
52
+ name: str
53
+ type: str
54
+ labels_or_types: list[str]
55
+ properties: list[str]
56
+
57
+
58
+ class IndexProfile(_Strict):
59
+ name: str
60
+ type: str
61
+ labels_or_types: list[str]
62
+ properties: list[str]
63
+
64
+
65
+ class GraphSchema(_Strict):
66
+ labels: list[LabelProfile]
67
+ relationship_types: list[RelationshipTypeProfile]
68
+ constraints: list[ConstraintProfile]
69
+ indexes: list[IndexProfile]
70
+
71
+
72
+ class DegreeDistribution(_Strict):
73
+ median: float = Field(ge=0)
74
+ p95: float = Field(ge=0)
75
+ p99: float = Field(ge=0)
76
+ maximum: int = Field(ge=0)
77
+
78
+ @model_validator(mode="after")
79
+ def _percentiles_are_ordered(self) -> DegreeDistribution:
80
+ if not self.median <= self.p95 <= self.p99 <= self.maximum:
81
+ raise ValueError("degree distribution must satisfy median <= p95 <= p99 <= maximum")
82
+ return self
83
+
84
+
85
+ class PropertyCoverage(_Strict):
86
+ owner: Literal["node", "relationship"]
87
+ owner_name: str
88
+ property: str
89
+ coverage: float = Field(ge=0, le=100)
90
+
91
+
92
+ class ProfileStatistics(_Strict):
93
+ node_count: int = Field(ge=0)
94
+ relationship_count: int = Field(ge=0)
95
+ property_coverage: list[PropertyCoverage]
96
+
97
+
98
+ class BaselineProfile(_Strict):
99
+ schema_version: Literal["1.0"]
100
+ status: ProfileStatus
101
+ partial_reason: str | None
102
+ target: RunTarget
103
+ metadata: ProfileMetadata
104
+ graph_schema: GraphSchema = Field(alias="schema")
105
+ statistics: ProfileStatistics
106
+ fingerprint: str
107
+
108
+ @model_validator(mode="after")
109
+ def _semantic_invariants(self) -> BaselineProfile:
110
+ if self.status is ProfileStatus.COMPLETE:
111
+ if self.partial_reason is not None:
112
+ raise ValueError("complete baseline must have partial_reason=null")
113
+
114
+ for label_profile in self.graph_schema.labels:
115
+ if label_profile.degree_distribution is None:
116
+ raise ValueError(
117
+ f"label {label_profile.name!r} must carry "
118
+ "degree_distribution in a complete profile"
119
+ )
120
+
121
+ for relationship_type in self.graph_schema.relationship_types:
122
+ if relationship_type.count > self.statistics.relationship_count:
123
+ raise ValueError(
124
+ f"schema.relationship_types[{relationship_type.name!r}].count "
125
+ f"({relationship_type.count}) exceeds "
126
+ f"statistics.relationship_count "
127
+ f"({self.statistics.relationship_count})"
128
+ )
129
+
130
+ elif not self.partial_reason:
131
+ raise ValueError("partial baseline must carry a non-empty partial_reason")
132
+
133
+ for label_profile in self.graph_schema.labels:
134
+ if label_profile.count > self.statistics.node_count:
135
+ raise ValueError(
136
+ f"schema.labels[{label_profile.name!r}].count "
137
+ f"({label_profile.count}) exceeds "
138
+ f"statistics.node_count "
139
+ f"({self.statistics.node_count})"
140
+ )
141
+
142
+ expected_fingerprint = profile_fingerprint(self.graph_schema, self.statistics)
143
+ if self.fingerprint != expected_fingerprint:
144
+ raise ValueError(
145
+ f"fingerprint must be {expected_fingerprint!r} for the v0 profile fingerprint input"
146
+ )
147
+
148
+ label_names = [label_profile.name for label_profile in self.graph_schema.labels]
149
+
150
+ _require_sorted("schema.labels", label_names)
151
+ _require_unique("schema.labels", label_names)
152
+
153
+ for label_profile in self.graph_schema.labels:
154
+ property_names = [prop.name for prop in label_profile.properties]
155
+
156
+ _require_sorted(
157
+ f"schema.labels[{label_profile.name!r}].properties",
158
+ property_names,
159
+ )
160
+ _require_unique(
161
+ f"schema.labels[{label_profile.name!r}].properties",
162
+ property_names,
163
+ )
164
+
165
+ relationship_names = [rel.name for rel in self.graph_schema.relationship_types]
166
+
167
+ _require_sorted(
168
+ "schema.relationship_types",
169
+ relationship_names,
170
+ )
171
+ _require_unique(
172
+ "schema.relationship_types",
173
+ relationship_names,
174
+ )
175
+
176
+ constraint_names = [constraint.name for constraint in self.graph_schema.constraints]
177
+
178
+ _require_sorted(
179
+ "schema.constraints",
180
+ constraint_names,
181
+ )
182
+ _require_unique(
183
+ "schema.constraints",
184
+ constraint_names,
185
+ )
186
+
187
+ for constraint in self.graph_schema.constraints:
188
+ _require_sorted(
189
+ f"schema.constraints[{constraint.name!r}].labels_or_types",
190
+ constraint.labels_or_types,
191
+ )
192
+
193
+ _require_unique(
194
+ f"schema.constraints[{constraint.name!r}].labels_or_types",
195
+ constraint.labels_or_types,
196
+ )
197
+ _require_sorted(
198
+ f"schema.constraints[{constraint.name!r}].properties",
199
+ constraint.properties,
200
+ )
201
+
202
+ _require_unique(
203
+ f"schema.constraints[{constraint.name!r}].properties",
204
+ constraint.properties,
205
+ )
206
+
207
+ index_names = [index.name for index in self.graph_schema.indexes]
208
+
209
+ _require_sorted(
210
+ "schema.indexes",
211
+ index_names,
212
+ )
213
+ _require_unique(
214
+ "schema.indexes",
215
+ index_names,
216
+ )
217
+
218
+ for index in self.graph_schema.indexes:
219
+ _require_sorted(
220
+ f"schema.indexes[{index.name!r}].labels_or_types",
221
+ index.labels_or_types,
222
+ )
223
+
224
+ _require_unique(
225
+ f"schema.indexes[{index.name!r}].labels_or_types",
226
+ index.labels_or_types,
227
+ )
228
+ _require_sorted(
229
+ f"schema.indexes[{index.name!r}].properties",
230
+ index.properties,
231
+ )
232
+ _require_unique(
233
+ f"schema.indexes[{index.name!r}].properties",
234
+ index.properties,
235
+ )
236
+
237
+ coverage_identities = [
238
+ (
239
+ coverage.owner,
240
+ coverage.owner_name,
241
+ coverage.property,
242
+ )
243
+ for coverage in self.statistics.property_coverage
244
+ ]
245
+
246
+ _require_sorted(
247
+ "statistics.property_coverage",
248
+ coverage_identities,
249
+ )
250
+
251
+ _require_unique(
252
+ "statistics.property_coverage",
253
+ coverage_identities,
254
+ )
255
+
256
+ return self
257
+
258
+
259
+ def _require_sorted(field: str, values: list[object]) -> None:
260
+ """Ensure collections are canonically ordered."""
261
+ if values != sorted(values):
262
+ raise ValueError(f"{field} must be canonically sorted")
263
+
264
+
265
+ def _require_unique(field: str, values: list[Hashable]) -> None:
266
+ """Ensure collections do not contain duplicate identities."""
267
+ if len(values) != len(set(values)):
268
+ raise ValueError(f"{field} must contain unique values")
269
+
270
+
271
+ def fingerprint_input(
272
+ graph_schema: GraphSchema, statistics: ProfileStatistics
273
+ ) -> dict[str, object]:
274
+ return {
275
+ "labels": [label.model_dump() for label in graph_schema.labels],
276
+ "relationship_types": [
277
+ relationship_type.model_dump() for relationship_type in graph_schema.relationship_types
278
+ ],
279
+ "node_count": statistics.node_count,
280
+ "relationship_count": statistics.relationship_count,
281
+ }
282
+
283
+
284
+ def profile_fingerprint(graph_schema: GraphSchema, statistics: ProfileStatistics) -> str:
285
+ raw = json.dumps(
286
+ fingerprint_input(graph_schema, statistics),
287
+ sort_keys=True,
288
+ separators=(",", ":"),
289
+ ).encode()
290
+ return f"{FINGERPRINT_ALGORITHM}:{hashlib.sha256(raw).hexdigest()}"