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.
Files changed (61) hide show
  1. katalyst_engine/__init__.py +6 -0
  2. katalyst_engine/bundle/__init__.py +30 -0
  3. katalyst_engine/bundle/discovery.py +158 -0
  4. katalyst_engine/bundle/loader.py +134 -0
  5. katalyst_engine/bundle/protocol.py +209 -0
  6. katalyst_engine/core/__init__.py +62 -0
  7. katalyst_engine/core/compatibility.py +58 -0
  8. katalyst_engine/core/compositional.py +103 -0
  9. katalyst_engine/core/definitive.py +195 -0
  10. katalyst_engine/core/evolvable.py +89 -0
  11. katalyst_engine/core/identity.py +95 -0
  12. katalyst_engine/core/lifecycle.py +62 -0
  13. katalyst_engine/core/relation.py +151 -0
  14. katalyst_engine/core/version.py +203 -0
  15. katalyst_engine/discovery/__init__.py +20 -0
  16. katalyst_engine/discovery/declaration.py +74 -0
  17. katalyst_engine/discovery/dispatcher.py +83 -0
  18. katalyst_engine/discovery/protocol.py +69 -0
  19. katalyst_engine/events/__init__.py +10 -0
  20. katalyst_engine/events/bus.py +102 -0
  21. katalyst_engine/events/event.py +82 -0
  22. katalyst_engine/extensions/__init__.py +32 -0
  23. katalyst_engine/extensions/capability.py +45 -0
  24. katalyst_engine/extensions/discovery.py +85 -0
  25. katalyst_engine/extensions/effector.py +54 -0
  26. katalyst_engine/extensions/provider.py +33 -0
  27. katalyst_engine/extensions/registry.py +77 -0
  28. katalyst_engine/extensions/trigger.py +64 -0
  29. katalyst_engine/model/__init__.py +25 -0
  30. katalyst_engine/model/manager.py +85 -0
  31. katalyst_engine/model/materializer.py +78 -0
  32. katalyst_engine/model/node.py +49 -0
  33. katalyst_engine/model/query.py +186 -0
  34. katalyst_engine/model/store.py +119 -0
  35. katalyst_engine/py.typed +0 -0
  36. katalyst_engine/replication/__init__.py +30 -0
  37. katalyst_engine/replication/engine.py +104 -0
  38. katalyst_engine/replication/job.py +88 -0
  39. katalyst_engine/replication/transform.py +111 -0
  40. katalyst_engine/resolution/__init__.py +32 -0
  41. katalyst_engine/resolution/conflict.py +91 -0
  42. katalyst_engine/resolution/engine.py +131 -0
  43. katalyst_engine/resolution/strategies.py +122 -0
  44. katalyst_engine/schema/__init__.py +35 -0
  45. katalyst_engine/schema/definition.py +281 -0
  46. katalyst_engine/schema/manager.py +95 -0
  47. katalyst_engine/schema/registry.py +367 -0
  48. katalyst_engine/schema/versioning.py +115 -0
  49. katalyst_engine/snapshot/__init__.py +18 -0
  50. katalyst_engine/snapshot/diff.py +94 -0
  51. katalyst_engine/snapshot/snapshot.py +111 -0
  52. katalyst_engine/source/__init__.py +26 -0
  53. katalyst_engine/source/manifest.py +45 -0
  54. katalyst_engine/source/registry.py +122 -0
  55. katalyst_engine/source/source.py +92 -0
  56. katalyst_engine/toolkit/__init__.py +22 -0
  57. katalyst_engine/toolkit/file_ops.py +194 -0
  58. katalyst_engine/toolkit/rendering.py +58 -0
  59. katalyst_engine-2.0.0.dist-info/METADATA +50 -0
  60. katalyst_engine-2.0.0.dist-info/RECORD +61 -0
  61. 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."""