ski-kg-validator 3.0.3__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.
@@ -0,0 +1,45 @@
1
+ """kg-validator — schema and §3.6 cross-cutting validation for v3 KGs."""
2
+
3
+ from .loader import KnowledgeGraphV3, load_v3_kg
4
+ from .models import (
5
+ Citation,
6
+ Definition,
7
+ Edge,
8
+ EdgeType,
9
+ Exemption,
10
+ Jurisdiction,
11
+ Obligation,
12
+ ObligationType,
13
+ Precedent,
14
+ RiskTier,
15
+ Rule,
16
+ Subject,
17
+ V3IssueType,
18
+ V3ValidationIssue,
19
+ V3ValidationResult,
20
+ )
21
+ from .validator import V3Validator
22
+
23
+ __version__ = "3.0.3"
24
+ __author__ = "KpiFinity"
25
+
26
+ __all__ = [
27
+ "Citation",
28
+ "Definition",
29
+ "Edge",
30
+ "EdgeType",
31
+ "Exemption",
32
+ "Jurisdiction",
33
+ "KnowledgeGraphV3",
34
+ "Obligation",
35
+ "ObligationType",
36
+ "Precedent",
37
+ "RiskTier",
38
+ "Rule",
39
+ "Subject",
40
+ "V3IssueType",
41
+ "V3ValidationIssue",
42
+ "V3ValidationResult",
43
+ "V3Validator",
44
+ "load_v3_kg",
45
+ ]
kg_validator/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ """Command-line interface for kg-validator (v3-only).
2
+
3
+ The validator now accepts a single subcommand — ``validate`` — which
4
+ loads a v3 KG JSON file and runs the spec §3.6 validation passes.
5
+
6
+ The v2 ``review``, ``detect-conflicts``, ``detect-duplicates``, and
7
+ HTML ``report`` subcommands were retired with PR 10e: they targeted
8
+ the flat-rule list shape that is no longer the framework's currency.
9
+ v3 conflict/duplicate detection happens at the typed-obligation level
10
+ inside :mod:`kg_validator.validator` and surfaces as issues in the
11
+ :class:`V3ValidationResult`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Optional
18
+
19
+ import click
20
+ from pydantic import ValidationError
21
+
22
+ from . import __version__
23
+ from .loader import load_v3_kg
24
+ from .validator import V3Validator
25
+
26
+
27
+ @click.group()
28
+ def main() -> None:
29
+ """Validate v3 KG files against SKI Framework spec v3.0 §3."""
30
+
31
+
32
+ @main.command()
33
+ @click.option("--input", "-i", required=True, help="Input v3 KG JSON file.")
34
+ @click.option(
35
+ "--output",
36
+ "-o",
37
+ default=None,
38
+ help="Output JSON file with validation results. If omitted, results are printed to stdout when issues are found.",
39
+ )
40
+ def validate(input: str, output: Optional[str]) -> None:
41
+ """Validate a v3 KG against spec §3 schema and §3.6 cross-cutting passes."""
42
+ try:
43
+ click.echo(f"Loading v3 KG from: {input}")
44
+ kg = load_v3_kg(input)
45
+ except FileNotFoundError as e:
46
+ click.echo(f"Error: {e!s}", err=True)
47
+ raise SystemExit(1) from e
48
+ except ValidationError as e:
49
+ click.echo("Schema validation failed:", err=True)
50
+ click.echo(str(e), err=True)
51
+ raise SystemExit(2) from e
52
+
53
+ click.echo(
54
+ f"Loaded {len(kg.nodes.rules)} rules, {len(kg.nodes.obligations)} obligations, {len(kg.edges)} edges"
55
+ )
56
+
57
+ result = V3Validator(kg).run()
58
+
59
+ click.echo("\nValidation complete:")
60
+ click.echo(f" Nodes: {result.total_nodes}")
61
+ click.echo(f" Edges: {result.total_edges}")
62
+ click.echo(f" Issues: {result.total_issues}")
63
+ if result.total_issues:
64
+ click.echo("\nIssues by type:")
65
+ by_type: dict[str, int] = {}
66
+ for issue in result.issues:
67
+ key = str(issue.issue_type)
68
+ by_type[key] = by_type.get(key, 0) + 1
69
+ for key in sorted(by_type):
70
+ click.echo(f" {key}: {by_type[key]}")
71
+
72
+ payload = result.model_dump(mode="json")
73
+ if output:
74
+ with open(output, "w", encoding="utf-8") as fh:
75
+ json.dump(payload, fh, indent=2)
76
+ click.echo(f"\nResults saved to: {output}")
77
+ elif result.total_issues:
78
+ click.echo("\n" + json.dumps(payload, indent=2))
79
+
80
+ # Exit non-zero on any CRITICAL or HIGH issue so CI flows can gate on it.
81
+ if not result.is_clean:
82
+ raise SystemExit(3)
83
+
84
+
85
+ @main.command()
86
+ def examples() -> None:
87
+ """Show usage examples."""
88
+ examples_text = """
89
+ kg-validator examples (v3):
90
+
91
+ 1. Validate a v3 KG file:
92
+ kg-validator validate --input kg-energy-v3.json
93
+
94
+ 2. Validate and save the issue report:
95
+ kg-validator validate --input kg-energy-v3.json --output report.json
96
+
97
+ 3. Compile-extract-validate flow:
98
+ kg-extractor extract --file regulation.txt --output kg-extracted.json --jurisdiction us.federal
99
+ kg-validator validate --input kg-extracted.json --output validation-report.json
100
+ """
101
+ click.echo(examples_text)
102
+
103
+
104
+ @main.command()
105
+ def version() -> None:
106
+ """Show version."""
107
+ click.echo(f"kg-validator {__version__}")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
kg_validator/loader.py ADDED
@@ -0,0 +1,117 @@
1
+ """Loader for v3 KG JSON files.
2
+
3
+ The on-disk v3 KG format is:
4
+
5
+ {
6
+ "metadata": {"name": "...", "schema_version": "3.0", ...},
7
+ "nodes": {
8
+ "subjects": [...], "rules": [...], "obligations": [...],
9
+ "definitions": [...], "exemptions": [...], "precedents": [...],
10
+ "jurisdictions": [...], "citations": [...]
11
+ },
12
+ "edges": [
13
+ {"type": "applies_to", "from": "...", "to": "..."},
14
+ ...
15
+ ]
16
+ }
17
+
18
+ Each node array contains objects matching the corresponding Pydantic
19
+ model in :mod:`.models`. Edges use ``from`` / ``to`` keys; the
20
+ :class:`Edge` model declares aliases so the keys can be used directly.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from pathlib import Path
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from pydantic import BaseModel, ConfigDict, Field
30
+
31
+ from .models import (
32
+ Citation,
33
+ Definition,
34
+ Edge,
35
+ Exemption,
36
+ Jurisdiction,
37
+ Obligation,
38
+ Precedent,
39
+ Rule,
40
+ Subject,
41
+ )
42
+
43
+
44
+ class KGV3Metadata(BaseModel):
45
+ """KG-level metadata (informative; not used in validation passes)."""
46
+
47
+ name: str
48
+ schema_version: str = "3.0"
49
+ sector: Optional[str] = None
50
+ description: Optional[str] = None
51
+ compiled_at: Optional[str] = None
52
+
53
+ model_config = ConfigDict(extra="allow")
54
+
55
+
56
+ class KGV3Nodes(BaseModel):
57
+ """Container for all node arrays. All keys default to empty lists."""
58
+
59
+ subjects: List[Subject] = Field(default_factory=list)
60
+ rules: List[Rule] = Field(default_factory=list)
61
+ obligations: List[Obligation] = Field(default_factory=list)
62
+ definitions: List[Definition] = Field(default_factory=list)
63
+ exemptions: List[Exemption] = Field(default_factory=list)
64
+ precedents: List[Precedent] = Field(default_factory=list)
65
+ jurisdictions: List[Jurisdiction] = Field(default_factory=list)
66
+ citations: List[Citation] = Field(default_factory=list)
67
+
68
+ model_config = ConfigDict(extra="forbid")
69
+
70
+
71
+ class KnowledgeGraphV3(BaseModel):
72
+ """Top-level v3 KG container."""
73
+
74
+ metadata: KGV3Metadata
75
+ nodes: KGV3Nodes = Field(default_factory=KGV3Nodes)
76
+ edges: List[Edge] = Field(default_factory=list)
77
+
78
+ model_config = ConfigDict(extra="forbid")
79
+
80
+ def all_node_ids(self) -> Dict[str, str]:
81
+ """Map every node id to its type name. Useful for edge validation."""
82
+ out: Dict[str, str] = {}
83
+ for n in self.nodes.subjects:
84
+ out[n.id] = "Subject"
85
+ for n in self.nodes.rules:
86
+ out[n.id] = "Rule"
87
+ for n in self.nodes.obligations:
88
+ out[n.id] = "Obligation"
89
+ for n in self.nodes.definitions:
90
+ out[n.id] = "Definition"
91
+ for n in self.nodes.exemptions:
92
+ out[n.id] = "Exemption"
93
+ for n in self.nodes.precedents:
94
+ out[n.id] = "Precedent"
95
+ for n in self.nodes.jurisdictions:
96
+ out[n.id] = "Jurisdiction"
97
+ for n in self.nodes.citations:
98
+ out[n.id] = "Citation"
99
+ return out
100
+
101
+
102
+ def load_v3_kg(path: str) -> KnowledgeGraphV3:
103
+ """Load and validate the Pydantic shape of a v3 KG JSON file.
104
+
105
+ Raises :class:`pydantic.ValidationError` if the file does not
106
+ conform to the schema. Returns the parsed :class:`KnowledgeGraphV3`
107
+ on success.
108
+
109
+ Schema-level validity is necessary but not sufficient. Run
110
+ :class:`kg_validator.V3Validator` against the loaded KG to
111
+ surface the cross-cutting issues from spec §3.6.
112
+ """
113
+ text = Path(path).read_text(encoding="utf-8")
114
+ raw: Any = json.loads(text)
115
+ if not isinstance(raw, dict):
116
+ raise ValueError(f"Top-level JSON in {path} must be an object, got {type(raw).__name__}")
117
+ return KnowledgeGraphV3.model_validate(raw)
kg_validator/models.py ADDED
@@ -0,0 +1,247 @@
1
+ """Pydantic v2 models for the v3 KG schema.
2
+
3
+ Mirrors SKI Framework specification v3.0 §3:
4
+
5
+ * §3.1 Node types: Subject, Rule, Obligation, Definition, Exemption,
6
+ Precedent, Jurisdiction, Citation.
7
+ * §3.2 Edge types: applies_to, consists_of, defined_by, exempted_by,
8
+ amended_by, interpreted_by, scoped_to, cited_by.
9
+ * §3.3 Typed obligations: the closed enumeration.
10
+ * §3.4 Jurisdictional scope and effective-date intervals.
11
+
12
+ These models are the source of truth for kg-validator's schema parsing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime
18
+ from enum import Enum
19
+ from typing import Any, List, Literal, Optional, Union
20
+
21
+ from pydantic import BaseModel, ConfigDict, Field
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Enumerations
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ class ObligationType(str, Enum):
29
+ """Spec v3.0 §3.3 — typed obligations (closed enumeration)."""
30
+
31
+ MUST = "must"
32
+ MUST_NOT = "must_not"
33
+ MUST_NOT_EXCEED = "must_not_exceed"
34
+ MUST_BE_AT_LEAST = "must_be_at_least"
35
+ MUST_BE_BELOW = "must_be_below"
36
+ MUST_BE_ABOVE = "must_be_above"
37
+ MUST_BE_WITHIN = "must_be_within"
38
+ MUST_BE_ONE_OF = "must_be_one_of"
39
+ MUST_NOT_BE_ONE_OF = "must_not_be_one_of"
40
+ MUST_BE_RECORDED_WITHIN = "must_be_recorded_within"
41
+ SHOULD = "should"
42
+ DISCRETIONARY = "discretionary"
43
+
44
+
45
+ class RiskTier(str, Enum):
46
+ """Spec v3.0 §5.4 — risk tier governor."""
47
+
48
+ LOW = "low"
49
+ MEDIUM = "medium"
50
+ HIGH = "high"
51
+
52
+
53
+ class EdgeType(str, Enum):
54
+ """Spec v3.0 §3.2 — edge types."""
55
+
56
+ APPLIES_TO = "applies_to"
57
+ CONSISTS_OF = "consists_of"
58
+ DEFINED_BY = "defined_by"
59
+ EXEMPTED_BY = "exempted_by"
60
+ AMENDED_BY = "amended_by"
61
+ INTERPRETED_BY = "interpreted_by"
62
+ SCOPED_TO = "scoped_to"
63
+ CITED_BY = "cited_by"
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Node base
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ class _NodeBase(BaseModel):
72
+ """Common fields every node carries (spec §3.1)."""
73
+
74
+ id: str = Field(..., description="Stable globally-unique identifier")
75
+ version: str = Field(
76
+ ...,
77
+ description=(
78
+ "Content-addressed SHA-256 of the canonical serialization of this "
79
+ "node's content excluding the version field itself."
80
+ ),
81
+ )
82
+
83
+ model_config = ConfigDict(use_enum_values=True, extra="forbid")
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Node types
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ class Subject(_NodeBase):
92
+ """A telemetry subject a rule can apply to."""
93
+
94
+ type: Literal["Subject"] = "Subject"
95
+ name: str = Field(..., description="Human-readable label")
96
+ description: Optional[str] = None
97
+
98
+
99
+ class Rule(_NodeBase):
100
+ """A regulatory requirement composed of one or more obligations.
101
+
102
+ Carries the ``risk_tier`` field consumed by the risk-tier governor
103
+ (spec §5.4).
104
+ """
105
+
106
+ type: Literal["Rule"] = "Rule"
107
+ name: str
108
+ risk_tier: RiskTier = RiskTier.MEDIUM
109
+ description: Optional[str] = None
110
+
111
+
112
+ class Obligation(_NodeBase):
113
+ """A typed normative statement (spec §3.3).
114
+
115
+ The operand (``value``) is loosely typed because different
116
+ obligation types carry different operand shapes: a numeric value
117
+ for bound-style relations, a list for set membership, a
118
+ duration-in-seconds for temporal predicates.
119
+ """
120
+
121
+ type: Literal["Obligation"] = "Obligation"
122
+ obligation_type: ObligationType
123
+ metric: str = Field(
124
+ ...,
125
+ description="Dotted-path identifier into the telemetry record's measurement object",
126
+ )
127
+ value: Union[float, int, str, List[Any], None] = None
128
+ unit: Optional[str] = None
129
+ effective_date_start: datetime = Field(
130
+ ...,
131
+ description="RFC 3339 timestamp; required per spec §3.4.",
132
+ )
133
+ effective_date_end: Optional[datetime] = None
134
+ summary: Optional[str] = None
135
+
136
+
137
+ class Definition(_NodeBase):
138
+ """A regulatory term and its scope-restricted meaning."""
139
+
140
+ type: Literal["Definition"] = "Definition"
141
+ term: str
142
+ definition_text: str
143
+ scope: Optional[str] = Field(
144
+ default=None,
145
+ description="The scope within which this definition applies (e.g., a regulation section).",
146
+ )
147
+
148
+
149
+ class Exemption(_NodeBase):
150
+ """A condition under which an obligation does not apply."""
151
+
152
+ type: Literal["Exemption"] = "Exemption"
153
+ condition: str
154
+ description: Optional[str] = None
155
+
156
+
157
+ class Precedent(_NodeBase):
158
+ """A prior obligation that the present one amends, supersedes, or interprets."""
159
+
160
+ type: Literal["Precedent"] = "Precedent"
161
+ summary: str
162
+ relation: Literal["amends", "supersedes", "interprets"]
163
+
164
+
165
+ class Jurisdiction(_NodeBase):
166
+ """A scope identifier (country, state, regulatory body, internal policy)."""
167
+
168
+ type: Literal["Jurisdiction"] = "Jurisdiction"
169
+ name: str
170
+ parent: Optional[str] = Field(
171
+ default=None,
172
+ description="Parent jurisdiction id (e.g., 'us.federal' is parent of 'us.federal.epa').",
173
+ )
174
+
175
+
176
+ class Citation(_NodeBase):
177
+ """A reference to the originating regulatory text."""
178
+
179
+ type: Literal["Citation"] = "Citation"
180
+ source_document: str
181
+ source_clause: str
182
+ url: Optional[str] = None
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Edges
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ class Edge(BaseModel):
191
+ """A typed, directed edge between two nodes (spec §3.2)."""
192
+
193
+ type: EdgeType
194
+ from_id: str = Field(..., alias="from")
195
+ to_id: str = Field(..., alias="to")
196
+
197
+ model_config = ConfigDict(
198
+ use_enum_values=True,
199
+ extra="forbid",
200
+ populate_by_name=True,
201
+ )
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Validation result types
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ class V3IssueType(str, Enum):
210
+ """Issue categories surfaced by the v3 validator."""
211
+
212
+ DUPLICATE_NODE_ID = "DUPLICATE_NODE_ID"
213
+ UNKNOWN_OBLIGATION_TYPE = "UNKNOWN_OBLIGATION_TYPE"
214
+ MISSING_EFFECTIVE_DATE = "MISSING_EFFECTIVE_DATE"
215
+ DANGLING_EDGE = "DANGLING_EDGE"
216
+ INVALID_EDGE_TARGET_TYPE = "INVALID_EDGE_TARGET_TYPE"
217
+ RULE_WITHOUT_OBLIGATION = "RULE_WITHOUT_OBLIGATION"
218
+ OBLIGATION_WITHOUT_RULE = "OBLIGATION_WITHOUT_RULE"
219
+ UNKNOWN_RISK_TIER = "UNKNOWN_RISK_TIER"
220
+
221
+
222
+ class V3ValidationIssue(BaseModel):
223
+ """A single finding produced by the v3 validator."""
224
+
225
+ issue_type: V3IssueType
226
+ severity: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"] = "HIGH"
227
+ node_id: Optional[str] = None
228
+ edge: Optional[Edge] = None
229
+ message: str
230
+ suggested_action: Optional[str] = None
231
+
232
+ model_config = ConfigDict(use_enum_values=True)
233
+
234
+
235
+ class V3ValidationResult(BaseModel):
236
+ """The aggregated output of one v3 validation run."""
237
+
238
+ schema_version: str = "3.0"
239
+ total_nodes: int = 0
240
+ total_edges: int = 0
241
+ total_issues: int = 0
242
+ issues: List[V3ValidationIssue] = Field(default_factory=list)
243
+
244
+ @property
245
+ def is_clean(self) -> bool:
246
+ """True iff no CRITICAL or HIGH severity issues were found."""
247
+ return not any(i.severity in ("CRITICAL", "HIGH") for i in self.issues)
kg_validator/py.typed ADDED
File without changes
@@ -0,0 +1,238 @@
1
+ """V3 validator — runs the spec §3.6 validation passes against a loaded KG.
2
+
3
+ Implements the subset that does not require sophisticated unit handling:
4
+
5
+ * Duplicate node IDs across all node types.
6
+ * Edges pointing at undefined node IDs (dangling edges).
7
+ * Edge target type matches what the edge type expects
8
+ (``applies_to`` must point at a Subject, ``consists_of`` at an
9
+ Obligation, etc.).
10
+ * Rules with no ``consists_of`` edge (every rule must have at least
11
+ one obligation per spec §2.1.2).
12
+ * Obligations with no incoming ``consists_of`` edge from a Rule
13
+ (orphan obligations).
14
+
15
+ Deferred to follow-up PRs:
16
+
17
+ * Contradictory obligations (needs sophisticated unit handling).
18
+ * Date-interval overlaps for mutually exclusive obligations.
19
+ * Cyclic precedent edges.
20
+ * Definition scope checking.
21
+
22
+ Obligation-type validity and missing ``effective_date_start`` are
23
+ already enforced at the Pydantic layer by :mod:`kg_validator.models`,
24
+ so they surface as :class:`pydantic.ValidationError` at
25
+ :func:`load_v3_kg` time rather than as runtime issues here.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Dict, List, Set, Tuple
31
+
32
+ from .loader import KnowledgeGraphV3
33
+ from .models import (
34
+ EdgeType,
35
+ V3IssueType,
36
+ V3ValidationIssue,
37
+ V3ValidationResult,
38
+ )
39
+
40
+ # Expected target-type per edge type. ``cited_by`` accepts any source
41
+ # node, so it does not appear here.
42
+ _EDGE_TARGET_TYPE: Dict[EdgeType, str] = {
43
+ EdgeType.APPLIES_TO: "Subject",
44
+ EdgeType.CONSISTS_OF: "Obligation",
45
+ EdgeType.DEFINED_BY: "Definition",
46
+ EdgeType.EXEMPTED_BY: "Exemption",
47
+ EdgeType.AMENDED_BY: "Obligation",
48
+ EdgeType.INTERPRETED_BY: "Precedent",
49
+ EdgeType.SCOPED_TO: "Jurisdiction",
50
+ EdgeType.CITED_BY: "Citation",
51
+ }
52
+
53
+
54
+ class V3Validator:
55
+ """Run the §3.6 validation passes against a loaded v3 KG."""
56
+
57
+ def __init__(self, kg: KnowledgeGraphV3) -> None:
58
+ self._kg = kg
59
+ self._issues: List[V3ValidationIssue] = []
60
+
61
+ def run(self) -> V3ValidationResult:
62
+ """Execute every validation pass and return the aggregated result."""
63
+ self._issues = []
64
+ self._check_duplicate_node_ids()
65
+ node_types = self._kg.all_node_ids()
66
+ self._check_dangling_edges(node_types)
67
+ self._check_edge_target_types(node_types)
68
+ self._check_rule_obligation_coverage()
69
+
70
+ total_nodes = sum(
71
+ (
72
+ len(self._kg.nodes.subjects),
73
+ len(self._kg.nodes.rules),
74
+ len(self._kg.nodes.obligations),
75
+ len(self._kg.nodes.definitions),
76
+ len(self._kg.nodes.exemptions),
77
+ len(self._kg.nodes.precedents),
78
+ len(self._kg.nodes.jurisdictions),
79
+ len(self._kg.nodes.citations),
80
+ )
81
+ )
82
+ return V3ValidationResult(
83
+ total_nodes=total_nodes,
84
+ total_edges=len(self._kg.edges),
85
+ total_issues=len(self._issues),
86
+ issues=list(self._issues),
87
+ )
88
+
89
+ # ------------------------------------------------------------------ #
90
+ # Individual validation passes #
91
+ # ------------------------------------------------------------------ #
92
+
93
+ def _check_duplicate_node_ids(self) -> None:
94
+ seen: Set[str] = set()
95
+ for node_id in self._kg.all_node_ids():
96
+ if node_id in seen:
97
+ self._issues.append(
98
+ V3ValidationIssue(
99
+ issue_type=V3IssueType.DUPLICATE_NODE_ID,
100
+ severity="CRITICAL",
101
+ node_id=node_id,
102
+ message=f"Node id '{node_id}' appears more than once across node arrays.",
103
+ suggested_action="Rename one of the duplicates, then re-sign the KG.",
104
+ )
105
+ )
106
+ seen.add(node_id)
107
+
108
+ # all_node_ids() is a dict so duplicates within the same array
109
+ # are already collapsed. Detect them explicitly by counting.
110
+ for collection in (
111
+ self._kg.nodes.subjects,
112
+ self._kg.nodes.rules,
113
+ self._kg.nodes.obligations,
114
+ self._kg.nodes.definitions,
115
+ self._kg.nodes.exemptions,
116
+ self._kg.nodes.precedents,
117
+ self._kg.nodes.jurisdictions,
118
+ self._kg.nodes.citations,
119
+ ):
120
+ counts: Dict[str, int] = {}
121
+ for node in collection:
122
+ counts[node.id] = counts.get(node.id, 0) + 1
123
+ for node_id, count in counts.items():
124
+ if count > 1:
125
+ self._issues.append(
126
+ V3ValidationIssue(
127
+ issue_type=V3IssueType.DUPLICATE_NODE_ID,
128
+ severity="CRITICAL",
129
+ node_id=node_id,
130
+ message=(
131
+ f"Node id '{node_id}' appears {count} times within the same node array."
132
+ ),
133
+ suggested_action="Rename one of the duplicates, then re-sign the KG.",
134
+ )
135
+ )
136
+
137
+ def _check_dangling_edges(self, node_types: Dict[str, str]) -> None:
138
+ for edge in self._kg.edges:
139
+ if edge.from_id not in node_types:
140
+ self._issues.append(
141
+ V3ValidationIssue(
142
+ issue_type=V3IssueType.DANGLING_EDGE,
143
+ severity="HIGH",
144
+ edge=edge,
145
+ message=(
146
+ f"Edge {edge.type} from '{edge.from_id}' references an undefined source node."
147
+ ),
148
+ suggested_action="Add the missing source node or remove the edge.",
149
+ )
150
+ )
151
+ if edge.to_id not in node_types:
152
+ self._issues.append(
153
+ V3ValidationIssue(
154
+ issue_type=V3IssueType.DANGLING_EDGE,
155
+ severity="HIGH",
156
+ edge=edge,
157
+ message=(f"Edge {edge.type} to '{edge.to_id}' references an undefined target node."),
158
+ suggested_action="Add the missing target node or remove the edge.",
159
+ )
160
+ )
161
+
162
+ def _check_edge_target_types(self, node_types: Dict[str, str]) -> None:
163
+ for edge in self._kg.edges:
164
+ expected = _EDGE_TARGET_TYPE.get(EdgeType(edge.type))
165
+ if expected is None:
166
+ continue
167
+ actual = node_types.get(edge.to_id)
168
+ if actual is None:
169
+ # Already flagged by _check_dangling_edges.
170
+ continue
171
+ if actual != expected:
172
+ self._issues.append(
173
+ V3ValidationIssue(
174
+ issue_type=V3IssueType.INVALID_EDGE_TARGET_TYPE,
175
+ severity="HIGH",
176
+ edge=edge,
177
+ message=(
178
+ f"Edge '{edge.type}' from '{edge.from_id}' targets a "
179
+ f"'{actual}' node ('{edge.to_id}') but spec §3.2 requires a "
180
+ f"'{expected}' target."
181
+ ),
182
+ suggested_action=(
183
+ f"Repoint the edge at a {expected} node, or use a different edge type."
184
+ ),
185
+ )
186
+ )
187
+
188
+ def _check_rule_obligation_coverage(self) -> None:
189
+ rule_ids: Set[str] = {r.id for r in self._kg.nodes.rules}
190
+ obligation_ids: Set[str] = {o.id for o in self._kg.nodes.obligations}
191
+ consists_of: List[Tuple[str, str]] = [
192
+ (e.from_id, e.to_id) for e in self._kg.edges if e.type == EdgeType.CONSISTS_OF.value
193
+ ]
194
+ rules_with_obligation: Set[str] = {src for src, _ in consists_of if src in rule_ids}
195
+ obligations_with_rule: Set[str] = {dst for _, dst in consists_of if dst in obligation_ids}
196
+
197
+ for rule_id in rule_ids - rules_with_obligation:
198
+ self._issues.append(
199
+ V3ValidationIssue(
200
+ issue_type=V3IssueType.RULE_WITHOUT_OBLIGATION,
201
+ severity="HIGH",
202
+ node_id=rule_id,
203
+ message=(
204
+ f"Rule '{rule_id}' has no consists_of edge to any Obligation. "
205
+ "Spec §2.1.2 requires every rule to reference at least one typed "
206
+ "obligation."
207
+ ),
208
+ suggested_action="Add a consists_of edge to one or more Obligation nodes.",
209
+ )
210
+ )
211
+
212
+ for obligation_id in obligation_ids - obligations_with_rule:
213
+ self._issues.append(
214
+ V3ValidationIssue(
215
+ issue_type=V3IssueType.OBLIGATION_WITHOUT_RULE,
216
+ severity="MEDIUM",
217
+ node_id=obligation_id,
218
+ message=(
219
+ f"Obligation '{obligation_id}' is not referenced by any Rule via a "
220
+ "consists_of edge. The obligation is unreachable from the runtime."
221
+ ),
222
+ suggested_action=("Either add a consists_of edge from a Rule, or remove the obligation."),
223
+ )
224
+ )
225
+
226
+ # ------------------------------------------------------------------ #
227
+ # Convenience accessors #
228
+ # ------------------------------------------------------------------ #
229
+
230
+ @property
231
+ def issues(self) -> List[V3ValidationIssue]:
232
+ """The issues from the most recent :meth:`run`."""
233
+ return list(self._issues)
234
+
235
+ @staticmethod
236
+ def edge_target_type(edge_type: EdgeType) -> str:
237
+ """Public accessor for the expected target type of an edge."""
238
+ return _EDGE_TARGET_TYPE[edge_type]
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: ski-kg-validator
3
+ Version: 3.0.3
4
+ Summary: Validate and review extracted compliance rules with mandatory human expert oversight.
5
+ Author-email: KpiFinity <hello@kpifinity.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/kpifinity/ski-framework
8
+ Project-URL: Documentation, https://kpifinity.github.io/ski-framework/
9
+ Project-URL: Issues, https://github.com/kpifinity/ski-framework/issues
10
+ Keywords: ski-framework,compliance,knowledge-graph,validation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Legal Industry
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: pydantic<3,>=2.13.4
23
+ Requires-Dist: click<9,>=8.1.7
24
+ Requires-Dist: python-dotenv<2,>=1.2.2
25
+ Requires-Dist: rapidfuzz<4,>=3.7
26
+ Requires-Dist: jinja2<4,>=3.1.6
27
+
28
+ # kg-validator
29
+
30
+ > Install from PyPI: `pip install ski-kg-validator` (publishing starts with the first release after June 2026).
31
+
32
+ > **Status:** v3.0 — first production-target release.
33
+
34
+ Validate v3 Knowledge Graphs against SKI Framework specification v3.0
35
+ §3 (schema) and §3.6 (cross-cutting validation passes). Phase 1
36
+ (compilation) tool.
37
+
38
+ ## What it does
39
+
40
+ - **Schema validation.** Parses a v3 KG JSON file into the typed-node
41
+ Pydantic models. Unknown obligation types, unknown edge types,
42
+ unknown risk tiers, missing required fields, and extra unknown keys
43
+ are all rejected at load time.
44
+ - **Cross-cutting validation (§3.6).** Detects duplicate node IDs,
45
+ dangling edges, edges pointing at the wrong target node type, rules
46
+ without any obligation, and orphan obligations.
47
+
48
+ ## What it no longer does (v2 retired in PR 10e)
49
+
50
+ - The v2 flat-rule-list format is gone. v3 typed obligations are the
51
+ only currency.
52
+ - Interactive `review`, `detect-conflicts`, `detect-duplicates`, and
53
+ HTML `report` subcommands were retired with the v2 path — they
54
+ worked on the flat-rule shape and have no v3 equivalent. v3
55
+ conflict and duplicate detection happen at the typed-obligation
56
+ level inside the validation passes and surface as issues in the
57
+ output JSON.
58
+ - The `ConfidenceLevel` enum is gone. The extractor now carries an
59
+ `ExtractionQuality` value (separate concept; see `kg-extractor`).
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install -e tools/kg-validator
65
+ ```
66
+
67
+ ## Quick start
68
+
69
+ ```bash
70
+ # Validate a v3 KG and print the issue summary:
71
+ kg-validator validate --input examples/energy/knowledge-graphs/kg-energy-v3-demo.json
72
+
73
+ # Validate and persist the full issue report:
74
+ kg-validator validate \
75
+ --input kg-energy-v3.json \
76
+ --output validation-report.json
77
+
78
+ # Show usage examples:
79
+ kg-validator examples
80
+
81
+ # Show version:
82
+ kg-validator version
83
+ ```
84
+
85
+ The `validate` command exits with code 0 on a clean KG, code 2 on a
86
+ schema-level Pydantic validation failure, and code 3 on any CRITICAL
87
+ or HIGH severity §3.6 issue.
88
+
89
+ ## Architecture
90
+
91
+ ```
92
+ kg_validator/
93
+ ├── __init__.py public API: KnowledgeGraphV3, V3Validator, load_v3_kg, models
94
+ ├── loader.py KnowledgeGraphV3 container + load_v3_kg(path) entry point
95
+ ├── models.py typed Pydantic models per spec v3.0 §3 (nodes, edges, enums)
96
+ ├── validator.py V3Validator runs the §3.6 cross-cutting passes
97
+ └── cli.py click-based CLI (`validate`, `examples`, `version`)
98
+ ```
99
+
100
+ ## Conformance
101
+
102
+ The `examples/energy/knowledge-graphs/kg-energy-v3-demo.json` sample
103
+ KG must load cleanly and produce zero validation issues. This is
104
+ exercised by `tests/test_v3_validator.py` and by the CI durability
105
+ suite.
@@ -0,0 +1,11 @@
1
+ kg_validator/__init__.py,sha256=Q5dKadHu3jmR9ozkWqSd9LUo8ZOXqKIXNOctJ4UrT_U,818
2
+ kg_validator/cli.py,sha256=a0MtREfXw7r-QmtAxorCEQCIJHOJxbfmcHF3u1b2pv0,3514
3
+ kg_validator/loader.py,sha256=7slXE54-knuIEmmebA4-fj-XB858Nb3FreatQqzKR0o,3689
4
+ kg_validator/models.py,sha256=ZkHKwYOvmuJvipYFzevHb8CxAN1BxTQG3h3vz7wnYSc,7391
5
+ kg_validator/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ kg_validator/validator.py,sha256=hOghfsHsueHFwGFCiZitKjEpRYDK-bVnqwh2UjI4Q1A,9920
7
+ ski_kg_validator-3.0.3.dist-info/METADATA,sha256=oJaw_77L9EZaNSSsvBc6RXwyIFgAoxqzMeTMcs4yVXU,3883
8
+ ski_kg_validator-3.0.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ ski_kg_validator-3.0.3.dist-info/entry_points.txt,sha256=HCO5XDLYv5yVZQoNPvVrkKFMiTKggq1TUuXQjFIJpfs,55
10
+ ski_kg_validator-3.0.3.dist-info/top_level.txt,sha256=yn3SfyTuRJQnT4rO60D6iofSUSh27k6ac6Hu_oFfuvU,13
11
+ ski_kg_validator-3.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kg-validator = kg_validator.cli:main
@@ -0,0 +1 @@
1
+ kg_validator