katalyst-engine 2.0.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.
- katalyst_engine/__init__.py +6 -0
- katalyst_engine/bundle/__init__.py +30 -0
- katalyst_engine/bundle/discovery.py +158 -0
- katalyst_engine/bundle/loader.py +134 -0
- katalyst_engine/bundle/protocol.py +209 -0
- katalyst_engine/core/__init__.py +62 -0
- katalyst_engine/core/compatibility.py +58 -0
- katalyst_engine/core/compositional.py +103 -0
- katalyst_engine/core/definitive.py +195 -0
- katalyst_engine/core/evolvable.py +89 -0
- katalyst_engine/core/identity.py +95 -0
- katalyst_engine/core/lifecycle.py +62 -0
- katalyst_engine/core/relation.py +151 -0
- katalyst_engine/core/version.py +203 -0
- katalyst_engine/discovery/__init__.py +20 -0
- katalyst_engine/discovery/declaration.py +74 -0
- katalyst_engine/discovery/dispatcher.py +83 -0
- katalyst_engine/discovery/protocol.py +69 -0
- katalyst_engine/events/__init__.py +10 -0
- katalyst_engine/events/bus.py +102 -0
- katalyst_engine/events/event.py +82 -0
- katalyst_engine/extensions/__init__.py +32 -0
- katalyst_engine/extensions/capability.py +45 -0
- katalyst_engine/extensions/discovery.py +85 -0
- katalyst_engine/extensions/effector.py +54 -0
- katalyst_engine/extensions/provider.py +33 -0
- katalyst_engine/extensions/registry.py +77 -0
- katalyst_engine/extensions/trigger.py +64 -0
- katalyst_engine/model/__init__.py +25 -0
- katalyst_engine/model/manager.py +85 -0
- katalyst_engine/model/materializer.py +78 -0
- katalyst_engine/model/node.py +49 -0
- katalyst_engine/model/query.py +186 -0
- katalyst_engine/model/store.py +119 -0
- katalyst_engine/py.typed +0 -0
- katalyst_engine/replication/__init__.py +30 -0
- katalyst_engine/replication/engine.py +104 -0
- katalyst_engine/replication/job.py +88 -0
- katalyst_engine/replication/transform.py +111 -0
- katalyst_engine/resolution/__init__.py +32 -0
- katalyst_engine/resolution/conflict.py +91 -0
- katalyst_engine/resolution/engine.py +131 -0
- katalyst_engine/resolution/strategies.py +122 -0
- katalyst_engine/schema/__init__.py +35 -0
- katalyst_engine/schema/definition.py +281 -0
- katalyst_engine/schema/manager.py +95 -0
- katalyst_engine/schema/registry.py +367 -0
- katalyst_engine/schema/versioning.py +115 -0
- katalyst_engine/snapshot/__init__.py +18 -0
- katalyst_engine/snapshot/diff.py +94 -0
- katalyst_engine/snapshot/snapshot.py +111 -0
- katalyst_engine/source/__init__.py +26 -0
- katalyst_engine/source/manifest.py +45 -0
- katalyst_engine/source/registry.py +122 -0
- katalyst_engine/source/source.py +92 -0
- katalyst_engine/toolkit/__init__.py +22 -0
- katalyst_engine/toolkit/file_ops.py +194 -0
- katalyst_engine/toolkit/rendering.py +58 -0
- katalyst_engine-2.0.0.dist-info/METADATA +50 -0
- katalyst_engine-2.0.0.dist-info/RECORD +61 -0
- katalyst_engine-2.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Grouping and namespacing — Compositionals contain other Evolvables.
|
|
2
|
+
|
|
3
|
+
Models, Wings, Packages, and Snapshots are Compositionals. Members
|
|
4
|
+
are stored as Identity references, not embedded objects. Resolution
|
|
5
|
+
of references to live objects is handled by the model layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from katalyst_engine.core.evolvable import Evolvable
|
|
13
|
+
from katalyst_engine.core.identity import Identity
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemberConstraint(BaseModel, frozen=True):
|
|
17
|
+
"""Rules governing what can be a member of a Compositional.
|
|
18
|
+
|
|
19
|
+
Declares allowed kinds and cardinality bounds.
|
|
20
|
+
An empty allowed_kinds set means any kind is permitted.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
allowed_kinds: frozenset[str] = frozenset()
|
|
24
|
+
"""Kinds allowed as members. Empty means any kind."""
|
|
25
|
+
|
|
26
|
+
min_count: int = 0
|
|
27
|
+
"""Minimum number of members required."""
|
|
28
|
+
|
|
29
|
+
max_count: int | None = None
|
|
30
|
+
"""Maximum number of members allowed. None means unbounded."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Compositional(Evolvable, frozen=True):
|
|
34
|
+
"""An Evolvable that groups related Evolvables.
|
|
35
|
+
|
|
36
|
+
Models, Wings, Packages, Snapshots are Compositionals.
|
|
37
|
+
|
|
38
|
+
Members are stored as Identity references, not embedded objects.
|
|
39
|
+
Resolution of references to live objects is handled by the
|
|
40
|
+
model layer, not by Compositional itself.
|
|
41
|
+
|
|
42
|
+
Uses the mixin pattern: a type can be both Compositional
|
|
43
|
+
and Definitive (e.g., a Schema).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
member_refs: tuple[Identity, ...] = ()
|
|
47
|
+
"""Ordered references to member Evolvables."""
|
|
48
|
+
|
|
49
|
+
member_constraint: MemberConstraint = MemberConstraint()
|
|
50
|
+
"""Rules governing membership."""
|
|
51
|
+
|
|
52
|
+
namespace_strategy: str = "hierarchical"
|
|
53
|
+
"""How children derive their namespace. Default is hierarchical (parent FQN)."""
|
|
54
|
+
|
|
55
|
+
def has_member(self, identity: Identity) -> bool:
|
|
56
|
+
"""Check if the given identity is a member of this Compositional."""
|
|
57
|
+
return identity in self.member_refs
|
|
58
|
+
|
|
59
|
+
def with_member(self, identity: Identity) -> Compositional:
|
|
60
|
+
"""Return a new Compositional with the given identity added.
|
|
61
|
+
|
|
62
|
+
If the identity is already a member, returns self unchanged.
|
|
63
|
+
This is an immutable operation — a new instance is returned.
|
|
64
|
+
"""
|
|
65
|
+
if identity in self.member_refs:
|
|
66
|
+
return self
|
|
67
|
+
return self.model_copy(update={"member_refs": (*self.member_refs, identity)})
|
|
68
|
+
|
|
69
|
+
def without_member(self, identity: Identity) -> Compositional:
|
|
70
|
+
"""Return a new Compositional with the given identity removed.
|
|
71
|
+
|
|
72
|
+
If the identity is not a member, returns self unchanged.
|
|
73
|
+
This is an immutable operation — a new instance is returned.
|
|
74
|
+
"""
|
|
75
|
+
new_refs = tuple(ref for ref in self.member_refs if ref != identity)
|
|
76
|
+
if len(new_refs) == len(self.member_refs):
|
|
77
|
+
return self
|
|
78
|
+
return self.model_copy(update={"member_refs": new_refs})
|
|
79
|
+
|
|
80
|
+
def member_count(self) -> int:
|
|
81
|
+
"""Return the number of members."""
|
|
82
|
+
return len(self.member_refs)
|
|
83
|
+
|
|
84
|
+
def validate_membership(self, candidate: Evolvable) -> bool:
|
|
85
|
+
"""Check if a candidate Evolvable is allowed as a member.
|
|
86
|
+
|
|
87
|
+
Validates against:
|
|
88
|
+
- allowed_kinds (if non-empty, candidate.kind must be in the set)
|
|
89
|
+
- max_count (if set, current member_count must be below it)
|
|
90
|
+
|
|
91
|
+
Does NOT check min_count (that's a post-hoc validation).
|
|
92
|
+
"""
|
|
93
|
+
constraint = self.member_constraint
|
|
94
|
+
|
|
95
|
+
# Check kind constraint
|
|
96
|
+
if constraint.allowed_kinds and candidate.identity.kind not in constraint.allowed_kinds:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
# Check max cardinality
|
|
100
|
+
if constraint.max_count is not None and len(self.member_refs) >= constraint.max_count:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
return True
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Meta-level descriptions — Definitives define the structure of other Evolvables.
|
|
2
|
+
|
|
3
|
+
Schemas, NodeDefinitions, and WingDefinitions are Definitives. A Definitive
|
|
4
|
+
can describe other Definitives, making the system self-describing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from katalyst_engine.core.evolvable import Evolvable
|
|
16
|
+
from katalyst_engine.core.lifecycle import LifecyclePolicy
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConstraintSeverity(str, Enum):
|
|
20
|
+
"""Severity level for constraint violations."""
|
|
21
|
+
|
|
22
|
+
ERROR = "error"
|
|
23
|
+
WARNING = "warning"
|
|
24
|
+
INFO = "info"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Constraint(BaseModel, frozen=True):
|
|
28
|
+
"""A declarative validation rule.
|
|
29
|
+
|
|
30
|
+
Describes what must be true, not how to check it.
|
|
31
|
+
Built-in kinds: "required_field", "regex", "range".
|
|
32
|
+
Unknown kinds are skipped during validation — consumers
|
|
33
|
+
handle those via custom validators.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
kind: str
|
|
37
|
+
"""Constraint kind: "required_field", "regex", "range", or custom."""
|
|
38
|
+
|
|
39
|
+
target: str
|
|
40
|
+
"""Field or path this constraint applies to."""
|
|
41
|
+
|
|
42
|
+
rule: dict[str, Any] = {}
|
|
43
|
+
"""Kind-specific rule definition (e.g. {"pattern": "^[a-z]+$"} for regex)."""
|
|
44
|
+
|
|
45
|
+
severity: ConstraintSeverity = ConstraintSeverity.ERROR
|
|
46
|
+
message: str = ""
|
|
47
|
+
"""Human-readable message shown when violated."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConstraintViolation(BaseModel, frozen=True):
|
|
51
|
+
"""A specific constraint that was violated during validation."""
|
|
52
|
+
|
|
53
|
+
constraint: Constraint
|
|
54
|
+
actual_value: Any = None
|
|
55
|
+
message: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ValidationResult(BaseModel, frozen=True):
|
|
59
|
+
"""Result of validating an Evolvable against a Definitive."""
|
|
60
|
+
|
|
61
|
+
valid: bool
|
|
62
|
+
violations: tuple[ConstraintViolation, ...] = ()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Definitive(Evolvable, frozen=True):
|
|
66
|
+
"""An Evolvable that describes the valid structure of other Evolvables.
|
|
67
|
+
|
|
68
|
+
Schemas, NodeDefinitions, WingDefinitions are Definitives.
|
|
69
|
+
A Definitive can describe other Definitives — this is what
|
|
70
|
+
makes the system self-describing.
|
|
71
|
+
|
|
72
|
+
Uses the mixin pattern: a type can be both Definitive and
|
|
73
|
+
Compositional (e.g., a Schema).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
describes_kind: str
|
|
77
|
+
"""The kind of Evolvable this definition governs."""
|
|
78
|
+
|
|
79
|
+
constraints: tuple[Constraint, ...] = ()
|
|
80
|
+
"""Declarative validation rules for instances."""
|
|
81
|
+
|
|
82
|
+
defaults: dict[str, Any] = {}
|
|
83
|
+
"""Default values applied to new instances of the described kind."""
|
|
84
|
+
|
|
85
|
+
attribute_schema: dict[str, Any] = {}
|
|
86
|
+
"""Dict-based schema for spec fields (e.g. JSON Schema fragment)."""
|
|
87
|
+
|
|
88
|
+
lifecycle_policy: LifecyclePolicy | None = None
|
|
89
|
+
"""If set, governs lifecycle transitions for instances."""
|
|
90
|
+
|
|
91
|
+
def validate_instance(self, instance: Evolvable) -> ValidationResult:
|
|
92
|
+
"""Validate an Evolvable against this definition's constraints.
|
|
93
|
+
|
|
94
|
+
Evaluates built-in constraint kinds:
|
|
95
|
+
- "required_field": checks that labels[target] is non-empty
|
|
96
|
+
- "regex": checks that labels[target] matches rule["pattern"]
|
|
97
|
+
- "range": checks that numeric labels[target] is within rule["min"]/rule["max"]
|
|
98
|
+
|
|
99
|
+
Unknown constraint kinds are skipped (consumers handle those).
|
|
100
|
+
|
|
101
|
+
Returns a ValidationResult with all violations found.
|
|
102
|
+
"""
|
|
103
|
+
violations: list[ConstraintViolation] = []
|
|
104
|
+
|
|
105
|
+
for constraint in self.constraints:
|
|
106
|
+
violation = self._evaluate_constraint(constraint, instance)
|
|
107
|
+
if violation is not None:
|
|
108
|
+
violations.append(violation)
|
|
109
|
+
|
|
110
|
+
return ValidationResult(
|
|
111
|
+
valid=len(violations) == 0,
|
|
112
|
+
violations=tuple(violations),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def is_satisfied_by(self, instance: Evolvable) -> bool:
|
|
116
|
+
"""Shortcut: True if validate_instance returns valid."""
|
|
117
|
+
return self.validate_instance(instance).valid
|
|
118
|
+
|
|
119
|
+
def _evaluate_constraint(
|
|
120
|
+
self, constraint: Constraint, instance: Evolvable
|
|
121
|
+
) -> ConstraintViolation | None:
|
|
122
|
+
"""Evaluate a single constraint against an instance.
|
|
123
|
+
|
|
124
|
+
Returns a ConstraintViolation if the constraint is violated, else None.
|
|
125
|
+
Unknown constraint kinds are silently skipped (return None).
|
|
126
|
+
"""
|
|
127
|
+
if constraint.kind == "required_field":
|
|
128
|
+
return self._check_required_field(constraint, instance)
|
|
129
|
+
elif constraint.kind == "regex":
|
|
130
|
+
return self._check_regex(constraint, instance)
|
|
131
|
+
elif constraint.kind == "range":
|
|
132
|
+
return self._check_range(constraint, instance)
|
|
133
|
+
# Unknown kinds are skipped — consumers handle via custom validators
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def _check_required_field(
|
|
137
|
+
self, constraint: Constraint, instance: Evolvable
|
|
138
|
+
) -> ConstraintViolation | None:
|
|
139
|
+
"""Check that a required field exists and is non-empty in labels."""
|
|
140
|
+
value = instance.labels.get(constraint.target)
|
|
141
|
+
if not value:
|
|
142
|
+
return ConstraintViolation(
|
|
143
|
+
constraint=constraint,
|
|
144
|
+
actual_value=value,
|
|
145
|
+
message=constraint.message or f"Required field '{constraint.target}' is missing",
|
|
146
|
+
)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def _check_regex(
|
|
150
|
+
self, constraint: Constraint, instance: Evolvable
|
|
151
|
+
) -> ConstraintViolation | None:
|
|
152
|
+
"""Check that a field matches a regex pattern."""
|
|
153
|
+
pattern = constraint.rule.get("pattern", "")
|
|
154
|
+
value = instance.labels.get(constraint.target, "")
|
|
155
|
+
if not re.match(pattern, value):
|
|
156
|
+
return ConstraintViolation(
|
|
157
|
+
constraint=constraint,
|
|
158
|
+
actual_value=value,
|
|
159
|
+
message=constraint.message
|
|
160
|
+
or f"Field '{constraint.target}' does not match pattern '{pattern}'",
|
|
161
|
+
)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _check_range(
|
|
165
|
+
self, constraint: Constraint, instance: Evolvable
|
|
166
|
+
) -> ConstraintViolation | None:
|
|
167
|
+
"""Check that a numeric field is within a range."""
|
|
168
|
+
value_str = instance.labels.get(constraint.target, "")
|
|
169
|
+
try:
|
|
170
|
+
value = float(value_str)
|
|
171
|
+
except (ValueError, TypeError):
|
|
172
|
+
return ConstraintViolation(
|
|
173
|
+
constraint=constraint,
|
|
174
|
+
actual_value=value_str,
|
|
175
|
+
message=constraint.message or f"Field '{constraint.target}' is not a valid number",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
min_val = constraint.rule.get("min")
|
|
179
|
+
max_val = constraint.rule.get("max")
|
|
180
|
+
|
|
181
|
+
if min_val is not None and value < float(min_val):
|
|
182
|
+
return ConstraintViolation(
|
|
183
|
+
constraint=constraint,
|
|
184
|
+
actual_value=value,
|
|
185
|
+
message=constraint.message
|
|
186
|
+
or f"Field '{constraint.target}' value {value} is below minimum {min_val}",
|
|
187
|
+
)
|
|
188
|
+
if max_val is not None and value > float(max_val):
|
|
189
|
+
return ConstraintViolation(
|
|
190
|
+
constraint=constraint,
|
|
191
|
+
actual_value=value,
|
|
192
|
+
message=constraint.message
|
|
193
|
+
or f"Field '{constraint.target}' value {value} is above maximum {max_val}",
|
|
194
|
+
)
|
|
195
|
+
return None
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The root base model for every artifact in the engine.
|
|
2
|
+
|
|
3
|
+
Evolvable is the universal supertype. Every artifact — nodes, schemas,
|
|
4
|
+
definitions, relations, extensions, snapshots — is an Evolvable. It
|
|
5
|
+
carries identity, version, lifecycle state, and compatibility contracts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, computed_field
|
|
13
|
+
|
|
14
|
+
from katalyst_engine.core.compatibility import CompatibilityContract
|
|
15
|
+
from katalyst_engine.core.identity import Identity
|
|
16
|
+
from katalyst_engine.core.lifecycle import Lifecycle
|
|
17
|
+
from katalyst_engine.core.version import Version
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from katalyst_engine.core.relation import NodeSelector
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Evolvable(BaseModel, frozen=True):
|
|
24
|
+
"""The root base model for every artifact in the engine.
|
|
25
|
+
|
|
26
|
+
Every artifact — nodes, schemas, definitions, relations,
|
|
27
|
+
extensions, snapshots — is an Evolvable. It has:
|
|
28
|
+
|
|
29
|
+
- identity: what it is (kind + name + namespace)
|
|
30
|
+
- version: which version of itself it is
|
|
31
|
+
- lifecycle: what state it's in
|
|
32
|
+
- compatibility: what guarantees it makes
|
|
33
|
+
- labels: key-value metadata for selector matching
|
|
34
|
+
- annotations: metadata not used for matching
|
|
35
|
+
|
|
36
|
+
Evolvable is immutable. Version N and version N+1 are two
|
|
37
|
+
distinct instances connected by a MigrationPath.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
identity: Identity
|
|
41
|
+
version: Version = Version()
|
|
42
|
+
lifecycle: Lifecycle = Lifecycle.DRAFT
|
|
43
|
+
compatibility: CompatibilityContract = CompatibilityContract()
|
|
44
|
+
labels: dict[str, str] = {}
|
|
45
|
+
annotations: dict[str, str] = {}
|
|
46
|
+
|
|
47
|
+
@computed_field # type: ignore[prop-decorator]
|
|
48
|
+
@property
|
|
49
|
+
def fqn(self) -> str:
|
|
50
|
+
"""Shortcut to identity.fqn."""
|
|
51
|
+
return self.identity.fqn
|
|
52
|
+
|
|
53
|
+
@computed_field # type: ignore[prop-decorator]
|
|
54
|
+
@property
|
|
55
|
+
def kind(self) -> str:
|
|
56
|
+
"""Shortcut to identity.kind."""
|
|
57
|
+
return self.identity.kind
|
|
58
|
+
|
|
59
|
+
@computed_field # type: ignore[prop-decorator]
|
|
60
|
+
@property
|
|
61
|
+
def name(self) -> str:
|
|
62
|
+
"""Shortcut to identity.name."""
|
|
63
|
+
return self.identity.name
|
|
64
|
+
|
|
65
|
+
def with_version(self, version: Version) -> Evolvable:
|
|
66
|
+
"""Return a new Evolvable with the given version, preserving all other fields."""
|
|
67
|
+
return self.model_copy(update={"version": version})
|
|
68
|
+
|
|
69
|
+
def with_lifecycle(self, lifecycle: Lifecycle) -> Evolvable:
|
|
70
|
+
"""Return a new Evolvable with the given lifecycle, preserving all other fields."""
|
|
71
|
+
return self.model_copy(update={"lifecycle": lifecycle})
|
|
72
|
+
|
|
73
|
+
def matches_selector(self, selector: NodeSelector) -> bool:
|
|
74
|
+
"""Check if this Evolvable matches a NodeSelector.
|
|
75
|
+
|
|
76
|
+
Delegates to selector.matches(self) to keep matching logic
|
|
77
|
+
centralized in the selector.
|
|
78
|
+
"""
|
|
79
|
+
return selector.matches(self)
|
|
80
|
+
|
|
81
|
+
def with_labels(self, **labels: str) -> Evolvable:
|
|
82
|
+
"""Return a new Evolvable with additional/updated labels."""
|
|
83
|
+
merged = {**self.labels, **labels}
|
|
84
|
+
return self.model_copy(update={"labels": merged})
|
|
85
|
+
|
|
86
|
+
def with_annotations(self, **annotations: str) -> Evolvable:
|
|
87
|
+
"""Return a new Evolvable with additional/updated annotations."""
|
|
88
|
+
merged: dict[str, Any] = {**self.annotations, **annotations}
|
|
89
|
+
return self.model_copy(update={"annotations": merged})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Identity and Fully Qualified Name (FQN) computation.
|
|
2
|
+
|
|
3
|
+
Every Evolvable artifact in the engine has an Identity. The combination
|
|
4
|
+
of (kind, name, namespace) is globally unique. FQN is the dot-separated
|
|
5
|
+
canonical address: namespace + "." + name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, computed_field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Identity(BaseModel, frozen=True):
|
|
14
|
+
"""Unique identity for any artifact in the system.
|
|
15
|
+
|
|
16
|
+
Every Evolvable has an Identity. The combination of
|
|
17
|
+
(kind, name, namespace) is globally unique.
|
|
18
|
+
|
|
19
|
+
FQN (Fully Qualified Name) is computed as the dot-separated
|
|
20
|
+
path: namespace + "." + name. For root-level artifacts with
|
|
21
|
+
no namespace, FQN equals name.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
Identity(kind="system", name="backend", namespace="org")
|
|
25
|
+
→ fqn = "org.backend"
|
|
26
|
+
|
|
27
|
+
Identity(kind="layer", name="app", namespace="org.backend.api")
|
|
28
|
+
→ fqn = "org.backend.api.app"
|
|
29
|
+
|
|
30
|
+
Identity(kind="schema", name="v2alpha")
|
|
31
|
+
→ fqn = "v2alpha" (no namespace)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
kind: str
|
|
35
|
+
"""What type of thing this is (e.g. "node_definition", "schema", "node")."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
"""Local name within the namespace."""
|
|
39
|
+
|
|
40
|
+
namespace: str = ""
|
|
41
|
+
"""Dot-separated scoping context. Empty string means root level."""
|
|
42
|
+
|
|
43
|
+
@computed_field # type: ignore[prop-decorator]
|
|
44
|
+
@property
|
|
45
|
+
def fqn(self) -> str:
|
|
46
|
+
"""Fully Qualified Name — dot-separated path.
|
|
47
|
+
|
|
48
|
+
Returns namespace.name if namespace is non-empty, else just name.
|
|
49
|
+
"""
|
|
50
|
+
if self.namespace:
|
|
51
|
+
return f"{self.namespace}.{self.name}"
|
|
52
|
+
return self.name
|
|
53
|
+
|
|
54
|
+
def is_in_namespace(self, ns: str) -> bool:
|
|
55
|
+
"""Check if this identity is within the given namespace (prefix match).
|
|
56
|
+
|
|
57
|
+
An empty namespace argument matches everything. An identity with an
|
|
58
|
+
empty namespace is only in the empty namespace.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
Identity(namespace="org.system").is_in_namespace("org") → True
|
|
62
|
+
Identity(namespace="org.system").is_in_namespace("other") → False
|
|
63
|
+
Identity(namespace="").is_in_namespace("") → True
|
|
64
|
+
Identity(namespace="org").is_in_namespace("") → True
|
|
65
|
+
"""
|
|
66
|
+
if not ns:
|
|
67
|
+
return True
|
|
68
|
+
if not self.namespace:
|
|
69
|
+
return False
|
|
70
|
+
return self.namespace == ns or self.namespace.startswith(ns + ".")
|
|
71
|
+
|
|
72
|
+
def child(self, kind: str, name: str) -> Identity:
|
|
73
|
+
"""Create a child identity nested under this one's FQN as namespace.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
parent = Identity(kind="system", name="backend", namespace="org")
|
|
77
|
+
parent.child("stack", "api")
|
|
78
|
+
→ Identity(kind="stack", name="api", namespace="org.backend")
|
|
79
|
+
"""
|
|
80
|
+
return Identity(kind=kind, name=name, namespace=self.fqn)
|
|
81
|
+
|
|
82
|
+
def parent_namespace(self) -> str:
|
|
83
|
+
"""Return the parent namespace (strip last segment).
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
"org.system.stack" → "org.system"
|
|
87
|
+
"org" → ""
|
|
88
|
+
"" → ""
|
|
89
|
+
"""
|
|
90
|
+
if not self.namespace:
|
|
91
|
+
return ""
|
|
92
|
+
parts = self.namespace.rsplit(".", maxsplit=1)
|
|
93
|
+
if len(parts) == 1:
|
|
94
|
+
return ""
|
|
95
|
+
return parts[0]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Lifecycle state machine for Evolvable artifacts.
|
|
2
|
+
|
|
3
|
+
Defines the lifecycle states and transition policies that govern
|
|
4
|
+
how artifacts move through their life from draft to removal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Lifecycle(str, Enum):
|
|
15
|
+
"""Lifecycle state of any Evolvable artifact.
|
|
16
|
+
|
|
17
|
+
States form a directed graph of allowed transitions.
|
|
18
|
+
The default policy allows:
|
|
19
|
+
DRAFT → STABLE → DEPRECATED → REMOVED
|
|
20
|
+
DRAFT → REMOVED (skip directly)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
DRAFT = "draft"
|
|
24
|
+
STABLE = "stable"
|
|
25
|
+
DEPRECATED = "deprecated"
|
|
26
|
+
REMOVED = "removed"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Default allowed transitions as a module-level constant for reuse.
|
|
30
|
+
DEFAULT_TRANSITIONS: tuple[tuple[Lifecycle, Lifecycle], ...] = (
|
|
31
|
+
(Lifecycle.DRAFT, Lifecycle.STABLE),
|
|
32
|
+
(Lifecycle.DRAFT, Lifecycle.REMOVED),
|
|
33
|
+
(Lifecycle.STABLE, Lifecycle.DEPRECATED),
|
|
34
|
+
(Lifecycle.DEPRECATED, Lifecycle.REMOVED),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LifecyclePolicy(BaseModel, frozen=True):
|
|
39
|
+
"""Allowed lifecycle transitions and rules.
|
|
40
|
+
|
|
41
|
+
Attached to a Definitive to govern how instances
|
|
42
|
+
of that definition can transition between states.
|
|
43
|
+
|
|
44
|
+
The default policy permits the standard progression:
|
|
45
|
+
DRAFT → STABLE → DEPRECATED → REMOVED, with a shortcut
|
|
46
|
+
from DRAFT → REMOVED.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
allowed_transitions: tuple[tuple[Lifecycle, Lifecycle], ...] = DEFAULT_TRANSITIONS
|
|
50
|
+
|
|
51
|
+
require_migration_on_deprecation: bool = True
|
|
52
|
+
"""If True, deprecating an artifact requires a MigrationPath to exist."""
|
|
53
|
+
|
|
54
|
+
deprecation_message_required: bool = True
|
|
55
|
+
"""If True, deprecation must include a message explaining why."""
|
|
56
|
+
|
|
57
|
+
def can_transition(self, from_state: Lifecycle, to_state: Lifecycle) -> bool:
|
|
58
|
+
"""Check if a transition is allowed by this policy.
|
|
59
|
+
|
|
60
|
+
Returns True if (from_state, to_state) is in the allowed_transitions list.
|
|
61
|
+
"""
|
|
62
|
+
return (from_state, to_state) in self.allowed_transitions
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Edges, selectors, and relationship claims.
|
|
2
|
+
|
|
3
|
+
Relations are first-class Evolvable artifacts — typed, directed,
|
|
4
|
+
versioned edges between two Evolvables. NodeSelectors enable
|
|
5
|
+
pattern-based soft references that the system materializes into
|
|
6
|
+
concrete Relations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from fnmatch import fnmatch
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from katalyst_engine.core.evolvable import Evolvable
|
|
17
|
+
from katalyst_engine.core.identity import Identity
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Cardinality(str, Enum):
|
|
21
|
+
"""Cardinality of a relationship between Evolvables."""
|
|
22
|
+
|
|
23
|
+
ONE_TO_ONE = "1:1"
|
|
24
|
+
ONE_TO_MANY = "1:N"
|
|
25
|
+
MANY_TO_ONE = "N:1"
|
|
26
|
+
MANY_TO_MANY = "N:M"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NodeSelector(BaseModel, frozen=True):
|
|
30
|
+
"""Pattern-based reference to Evolvables.
|
|
31
|
+
|
|
32
|
+
Enables soft references: a node declares relational intent
|
|
33
|
+
using selectors instead of hard names. The system materializes
|
|
34
|
+
the actual edges.
|
|
35
|
+
|
|
36
|
+
All patterns use fnmatch-style globs.
|
|
37
|
+
All criteria are ANDed — every field must match.
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
NodeSelector(kind_pattern="layer.*")
|
|
41
|
+
→ matches any Evolvable whose kind starts with "layer."
|
|
42
|
+
|
|
43
|
+
NodeSelector(label_matchers={"env": "prod"})
|
|
44
|
+
→ matches any Evolvable with label env=prod
|
|
45
|
+
|
|
46
|
+
NodeSelector() (all defaults)
|
|
47
|
+
→ matches everything (universal selector)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
kind_pattern: str = "*"
|
|
51
|
+
"""Glob pattern for matching Evolvable.identity.kind."""
|
|
52
|
+
|
|
53
|
+
namespace_pattern: str = "*"
|
|
54
|
+
"""Glob pattern for matching Evolvable.identity.namespace."""
|
|
55
|
+
|
|
56
|
+
label_matchers: dict[str, str] = {}
|
|
57
|
+
"""Label key-value pairs that must all match exactly."""
|
|
58
|
+
|
|
59
|
+
name_pattern: str = "*"
|
|
60
|
+
"""Glob pattern for matching Evolvable.identity.name."""
|
|
61
|
+
|
|
62
|
+
def matches(self, evolvable: Evolvable) -> bool:
|
|
63
|
+
"""Check if an Evolvable matches all selector criteria.
|
|
64
|
+
|
|
65
|
+
All criteria are ANDed. A universal selector (all defaults) matches everything.
|
|
66
|
+
"""
|
|
67
|
+
if not fnmatch(evolvable.identity.kind, self.kind_pattern):
|
|
68
|
+
return False
|
|
69
|
+
if not fnmatch(evolvable.identity.namespace, self.namespace_pattern):
|
|
70
|
+
return False
|
|
71
|
+
if not fnmatch(evolvable.identity.name, self.name_pattern):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
for key, value in self.label_matchers.items():
|
|
75
|
+
if evolvable.labels.get(key) != value:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
def is_universal(self) -> bool:
|
|
81
|
+
"""True if this selector matches everything.
|
|
82
|
+
|
|
83
|
+
A selector is universal when all patterns are "*" and
|
|
84
|
+
there are no label matchers.
|
|
85
|
+
"""
|
|
86
|
+
return (
|
|
87
|
+
self.kind_pattern == "*"
|
|
88
|
+
and self.namespace_pattern == "*"
|
|
89
|
+
and self.name_pattern == "*"
|
|
90
|
+
and not self.label_matchers
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RelationshipClaim(BaseModel, frozen=True):
|
|
95
|
+
"""What a node DECLARES about its relationships.
|
|
96
|
+
|
|
97
|
+
The declaration side — what a node says about its connections
|
|
98
|
+
before the system materializes actual edges. The source_identity
|
|
99
|
+
is the node making the claim; the selector describes the target(s).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
source_identity: Identity
|
|
103
|
+
"""The identity of the node making this claim."""
|
|
104
|
+
|
|
105
|
+
kind: str
|
|
106
|
+
"""Relationship kind, e.g. "parent", "depends_on", "extends"."""
|
|
107
|
+
|
|
108
|
+
selector: NodeSelector
|
|
109
|
+
"""Pattern describing the target(s) of this relationship."""
|
|
110
|
+
|
|
111
|
+
cardinality: Cardinality = Cardinality.MANY_TO_ONE
|
|
112
|
+
"""Expected cardinality of the relationship."""
|
|
113
|
+
|
|
114
|
+
optional: bool = False
|
|
115
|
+
"""If True, zero matches is valid (the relationship is not required)."""
|
|
116
|
+
|
|
117
|
+
annotations: dict[str, str] = {}
|
|
118
|
+
"""Additional metadata on this claim."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Relation(Evolvable, frozen=True):
|
|
122
|
+
"""A materialized, typed, directed edge between two Evolvables.
|
|
123
|
+
|
|
124
|
+
Relations are first-class Evolvables — they have their own
|
|
125
|
+
identity, version, lifecycle, and compatibility contract.
|
|
126
|
+
|
|
127
|
+
Relations are produced by the RelationshipMaterializer from
|
|
128
|
+
RelationshipClaims. They should not normally be constructed
|
|
129
|
+
directly by consumers.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
source: Identity
|
|
133
|
+
"""The identity of the source Evolvable."""
|
|
134
|
+
|
|
135
|
+
target: Identity
|
|
136
|
+
"""The identity of the target Evolvable."""
|
|
137
|
+
|
|
138
|
+
relation_kind: str
|
|
139
|
+
"""The type of relationship, e.g. "parent", "depends_on"."""
|
|
140
|
+
|
|
141
|
+
cardinality: Cardinality = Cardinality.MANY_TO_ONE
|
|
142
|
+
"""Cardinality of this materialized relationship."""
|
|
143
|
+
|
|
144
|
+
directed: bool = True
|
|
145
|
+
"""Whether this is a directed edge. Almost always True."""
|
|
146
|
+
|
|
147
|
+
materialized_from: RelationshipClaim | None = None
|
|
148
|
+
"""The claim that produced this relation, if any."""
|
|
149
|
+
|
|
150
|
+
confidence: float = 1.0
|
|
151
|
+
"""1.0 = exact match, <1.0 = fuzzy/inferred."""
|