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.
- kg_validator/__init__.py +45 -0
- kg_validator/cli.py +111 -0
- kg_validator/loader.py +117 -0
- kg_validator/models.py +247 -0
- kg_validator/py.typed +0 -0
- kg_validator/validator.py +238 -0
- ski_kg_validator-3.0.3.dist-info/METADATA +105 -0
- ski_kg_validator-3.0.3.dist-info/RECORD +11 -0
- ski_kg_validator-3.0.3.dist-info/WHEEL +5 -0
- ski_kg_validator-3.0.3.dist-info/entry_points.txt +2 -0
- ski_kg_validator-3.0.3.dist-info/top_level.txt +1 -0
kg_validator/__init__.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
kg_validator
|