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,91 @@
1
+ """Conflict detection models for multi-source declaration resolution.
2
+
3
+ When multiple sources declare the same identity, a Conflict captures
4
+ the competing declarations and their severity. A ConflictReport
5
+ aggregates all conflicts from a resolution pass.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+
12
+ from pydantic import BaseModel, computed_field
13
+
14
+ from katalyst_engine.core.identity import Identity
15
+ from katalyst_engine.discovery.declaration import Declaration
16
+
17
+
18
+ class ConflictSeverity(str, Enum):
19
+ """How serious a conflict between declarations is.
20
+
21
+ INFO: cosmetic differences only (e.g. whitespace, ordering).
22
+ WARNING: non-critical field differences that may be auto-merged.
23
+ ERROR: contradictory values that require explicit resolution.
24
+ """
25
+
26
+ INFO = "info"
27
+ WARNING = "warning"
28
+ ERROR = "error"
29
+
30
+
31
+ class Conflict(BaseModel, frozen=True):
32
+ """A detected conflict between declarations from different sources.
33
+
34
+ Two or more sources have provided declarations for the same identity.
35
+ The conflict captures which declarations disagree and which fields
36
+ are in contention.
37
+ """
38
+
39
+ identity: Identity
40
+ """The identity that multiple sources declare."""
41
+
42
+ declarations: tuple[Declaration, ...]
43
+ """The competing declarations, ordered by source priority (highest first)."""
44
+
45
+ severity: ConflictSeverity = ConflictSeverity.ERROR
46
+ """How serious this conflict is."""
47
+
48
+ conflicting_fields: frozenset[str] = frozenset()
49
+ """Field names where the declarations disagree."""
50
+
51
+ message: str = ""
52
+ """Human-readable description of the conflict."""
53
+
54
+
55
+ class ConflictReport(BaseModel, frozen=True):
56
+ """Aggregation of all conflicts from a resolution pass.
57
+
58
+ Provides summary counts by severity and lookup by identity.
59
+ """
60
+
61
+ conflicts: tuple[Conflict, ...] = ()
62
+ """All detected conflicts."""
63
+
64
+ @computed_field # type: ignore[prop-decorator]
65
+ @property
66
+ def error_count(self) -> int:
67
+ """Number of ERROR-severity conflicts."""
68
+ return sum(1 for c in self.conflicts if c.severity == ConflictSeverity.ERROR)
69
+
70
+ @computed_field # type: ignore[prop-decorator]
71
+ @property
72
+ def warning_count(self) -> int:
73
+ """Number of WARNING-severity conflicts."""
74
+ return sum(1 for c in self.conflicts if c.severity == ConflictSeverity.WARNING)
75
+
76
+ @computed_field # type: ignore[prop-decorator]
77
+ @property
78
+ def total(self) -> int:
79
+ """Total number of conflicts."""
80
+ return len(self.conflicts)
81
+
82
+ def has_errors(self) -> bool:
83
+ """True if any ERROR-severity conflicts exist."""
84
+ return self.error_count > 0
85
+
86
+ def for_identity(self, identity: Identity) -> Conflict | None:
87
+ """Look up the conflict for a specific identity, if any."""
88
+ for conflict in self.conflicts:
89
+ if conflict.identity == identity:
90
+ return conflict
91
+ return None
@@ -0,0 +1,131 @@
1
+ """Resolution engine — groups declarations, detects conflicts, applies strategies.
2
+
3
+ The ResolutionEngine takes declarations from multiple sources, groups
4
+ them by identity, detects where sources disagree, and applies resolution
5
+ strategies in priority order to produce a single resolved declaration
6
+ per identity.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from katalyst_engine.discovery.declaration import Declaration
14
+ from katalyst_engine.resolution.conflict import (
15
+ Conflict,
16
+ ConflictReport,
17
+ ConflictSeverity,
18
+ )
19
+ from katalyst_engine.resolution.strategies import ResolutionStrategy
20
+
21
+
22
+ class ResolutionResult(BaseModel, frozen=True):
23
+ """Output of a resolution pass.
24
+
25
+ Contains the resolved declarations (one per identity) and the
26
+ conflict report describing any disagreements found.
27
+ """
28
+
29
+ resolved: tuple[Declaration, ...] = ()
30
+ """One resolved declaration per identity."""
31
+
32
+ conflict_report: ConflictReport = ConflictReport()
33
+ """All conflicts detected, including those that were resolved."""
34
+
35
+ unresolved_identities: frozenset[str] = frozenset()
36
+ """FQNs of identities that could not be resolved by any strategy."""
37
+
38
+
39
+ class ResolutionEngine:
40
+ """Groups declarations by identity, detects conflicts, applies strategies.
41
+
42
+ Usage:
43
+ engine = ResolutionEngine()
44
+ engine.add_strategy(CanonicalSourceStrategy(registry))
45
+ engine.add_strategy(MergeStrategy())
46
+ result = engine.resolve(declarations)
47
+ """
48
+
49
+ def __init__(self) -> None:
50
+ self._strategies: list[ResolutionStrategy] = []
51
+
52
+ def add_strategy(self, strategy: ResolutionStrategy) -> None:
53
+ """Add a resolution strategy. Strategies are tried in insertion order."""
54
+ self._strategies.append(strategy)
55
+
56
+ def resolve(self, declarations: list[Declaration]) -> ResolutionResult:
57
+ """Resolve a list of declarations from potentially multiple sources.
58
+
59
+ Groups declarations by identity FQN, detects conflicts where
60
+ multiple sources declare the same identity, and applies strategies
61
+ in order until one resolves each conflict.
62
+
63
+ Declarations with no conflicts pass through unchanged.
64
+ """
65
+ groups = self._group_by_identity(declarations)
66
+
67
+ resolved: list[Declaration] = []
68
+ conflicts: list[Conflict] = []
69
+ unresolved: list[str] = []
70
+
71
+ for fqn, group in groups.items():
72
+ if len(group) == 1:
73
+ resolved.append(group[0])
74
+ continue
75
+
76
+ conflict = self._detect_conflict(group)
77
+ conflicts.append(conflict)
78
+
79
+ winner = self._apply_strategies(conflict)
80
+ if winner is not None:
81
+ resolved.append(winner)
82
+ else:
83
+ unresolved.append(fqn)
84
+
85
+ return ResolutionResult(
86
+ resolved=tuple(resolved),
87
+ conflict_report=ConflictReport(conflicts=tuple(conflicts)),
88
+ unresolved_identities=frozenset(unresolved),
89
+ )
90
+
91
+ def _group_by_identity(self, declarations: list[Declaration]) -> dict[str, list[Declaration]]:
92
+ """Group declarations by identity FQN."""
93
+ groups: dict[str, list[Declaration]] = {}
94
+ for decl in declarations:
95
+ fqn = decl.identity.fqn
96
+ if fqn not in groups:
97
+ groups[fqn] = []
98
+ groups[fqn].append(decl)
99
+ return groups
100
+
101
+ def _detect_conflict(self, declarations: list[Declaration]) -> Conflict:
102
+ """Detect which fields conflict between declarations sharing an identity."""
103
+ identity = declarations[0].identity
104
+ all_keys: set[str] = set()
105
+ for decl in declarations:
106
+ all_keys.update(decl.raw_data.keys())
107
+
108
+ conflicting: set[str] = set()
109
+ for key in all_keys:
110
+ values = {decl.raw_data.get(key) for decl in declarations if key in decl.raw_data}
111
+ if len(values) > 1:
112
+ conflicting.add(key)
113
+
114
+ severity = ConflictSeverity.ERROR if conflicting else ConflictSeverity.WARNING
115
+ sources = ", ".join(d.source_fqn for d in declarations)
116
+
117
+ return Conflict(
118
+ identity=identity,
119
+ declarations=tuple(declarations),
120
+ severity=severity,
121
+ conflicting_fields=frozenset(conflicting),
122
+ message=f"Identity {identity.fqn} declared by multiple sources: {sources}",
123
+ )
124
+
125
+ def _apply_strategies(self, conflict: Conflict) -> Declaration | None:
126
+ """Try each strategy in order; return the first successful resolution."""
127
+ for strategy in self._strategies:
128
+ result = strategy.resolve(conflict)
129
+ if result is not None:
130
+ return result
131
+ return None
@@ -0,0 +1,122 @@
1
+ """Resolution strategies for choosing between conflicting declarations.
2
+
3
+ Each strategy implements a different policy for picking a winner
4
+ when multiple sources declare the same identity. Strategies are
5
+ applied in priority order by the ResolutionEngine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+
12
+ from katalyst_engine.discovery.declaration import Declaration
13
+ from katalyst_engine.resolution.conflict import Conflict
14
+ from katalyst_engine.source.registry import SourceRegistry
15
+
16
+
17
+ class ResolutionStrategy(ABC):
18
+ """Abstract base for conflict resolution strategies.
19
+
20
+ Implementations examine a Conflict and return the winning
21
+ Declaration, or None if they cannot resolve it.
22
+ """
23
+
24
+ @abstractmethod
25
+ def resolve(self, conflict: Conflict) -> Declaration | None:
26
+ """Attempt to resolve a conflict.
27
+
28
+ Args:
29
+ conflict: The conflict to resolve.
30
+
31
+ Returns:
32
+ The winning Declaration, or None if this strategy
33
+ cannot resolve the conflict.
34
+ """
35
+
36
+
37
+ class CanonicalSourceStrategy(ResolutionStrategy):
38
+ """Resolve by choosing the declaration from the highest-priority source.
39
+
40
+ Uses the SourceRegistry to look up source priorities. The declaration
41
+ from the source with the highest canonicity priority wins.
42
+ """
43
+
44
+ def __init__(self, source_registry: SourceRegistry) -> None:
45
+ self._registry = source_registry
46
+
47
+ def resolve(self, conflict: Conflict) -> Declaration | None:
48
+ """Pick the declaration from the highest-priority canonical source."""
49
+ best: Declaration | None = None
50
+ best_priority = -1
51
+
52
+ for decl in conflict.declarations:
53
+ source = self._registry.get_by_fqn(decl.source_fqn)
54
+ if source is None:
55
+ continue
56
+ priority = source.canonicity.priority
57
+ if priority > best_priority:
58
+ best = decl
59
+ best_priority = priority
60
+
61
+ return best
62
+
63
+
64
+ class LatestVersionStrategy(ResolutionStrategy):
65
+ """Resolve by choosing the declaration with the highest version in raw_data.
66
+
67
+ Expects raw_data to contain a "version" key parseable as a semver string.
68
+ Falls back to string comparison if parsing fails.
69
+ """
70
+
71
+ def resolve(self, conflict: Conflict) -> Declaration | None:
72
+ """Pick the declaration with the highest version string."""
73
+ from katalyst_engine.core.version import Version
74
+
75
+ best: Declaration | None = None
76
+ best_version: Version | None = None
77
+
78
+ for decl in conflict.declarations:
79
+ version_str = decl.raw_data.get("version", "")
80
+ if not isinstance(version_str, str) or not version_str:
81
+ continue
82
+ try:
83
+ version = Version.parse(version_str)
84
+ except ValueError:
85
+ continue
86
+
87
+ if best_version is None or version > best_version:
88
+ best = decl
89
+ best_version = version
90
+
91
+ return best
92
+
93
+
94
+ class MergeStrategy(ResolutionStrategy):
95
+ """Resolve by merging non-conflicting fields from all declarations.
96
+
97
+ If all declarations agree on every field (or a field is only set
98
+ by one source), produces a merged declaration. Returns None if
99
+ any field has genuinely conflicting values.
100
+ """
101
+
102
+ def resolve(self, conflict: Conflict) -> Declaration | None:
103
+ """Merge declarations where fields don't conflict.
104
+
105
+ Returns a merged Declaration using the first declaration as
106
+ the base, or None if any field values genuinely conflict.
107
+ """
108
+ if not conflict.declarations:
109
+ return None
110
+
111
+ merged_data: dict[str, object] = {}
112
+
113
+ for decl in conflict.declarations:
114
+ for key, value in decl.raw_data.items():
115
+ if key in merged_data:
116
+ if merged_data[key] != value:
117
+ return None # genuine conflict
118
+ else:
119
+ merged_data[key] = value
120
+
121
+ base = conflict.declarations[0]
122
+ return base.model_copy(update={"raw_data": merged_data})
@@ -0,0 +1,35 @@
1
+ """Schema — definitions, registry, versioning, and management."""
2
+
3
+ from katalyst_engine.schema.definition import (
4
+ FieldRef,
5
+ FieldSpec,
6
+ NodeDefinition,
7
+ ParentRule,
8
+ RelationshipDefinition,
9
+ SchemaDefinition,
10
+ WingDefinition,
11
+ )
12
+ from katalyst_engine.schema.manager import SchemaManager
13
+ from katalyst_engine.schema.registry import SchemaRegistry
14
+ from katalyst_engine.schema.versioning import (
15
+ SchemaVersionChange,
16
+ SchemaVersionComparison,
17
+ compare_versions,
18
+ plan_migration_path,
19
+ )
20
+
21
+ __all__ = [
22
+ "FieldRef",
23
+ "FieldSpec",
24
+ "NodeDefinition",
25
+ "ParentRule",
26
+ "RelationshipDefinition",
27
+ "SchemaDefinition",
28
+ "SchemaManager",
29
+ "SchemaRegistry",
30
+ "SchemaVersionChange",
31
+ "SchemaVersionComparison",
32
+ "WingDefinition",
33
+ "compare_versions",
34
+ "plan_migration_path",
35
+ ]
@@ -0,0 +1,281 @@
1
+ """Schema definitions — describe families of node types and their structure.
2
+
3
+ SchemaDefinition is a Definitive that describes an entire schema family.
4
+ NodeDefinition describes a single node kind with field specifications.
5
+ WingDefinition describes a grouping/namespace structure.
6
+ RelationshipDefinition describes a relationship type with classification.
7
+ FieldRef captures a cross-reference from one field to a target kind.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from katalyst_engine.core.definitive import Definitive
17
+ from katalyst_engine.core.identity import Identity
18
+ from katalyst_engine.core.relation import Cardinality
19
+ from katalyst_engine.core.version import Version
20
+
21
+
22
+ class FieldSpec(BaseModel, frozen=True):
23
+ """Specification of a single field within a NodeDefinition.
24
+
25
+ Describes the field's type, requirements, and defaults without
26
+ coupling to any particular serialization format.
27
+ """
28
+
29
+ name: str
30
+ """Field name as it appears in the spec dict."""
31
+
32
+ field_type: str = "string"
33
+ """Type hint: "string", "integer", "boolean", "list", "map", "ref"."""
34
+
35
+ required: bool = False
36
+ """Whether this field must be present."""
37
+
38
+ default: Any = None
39
+ """Default value if not provided."""
40
+
41
+ description: str = ""
42
+ """Human-readable description of this field."""
43
+
44
+ ref_target_kind: str = ""
45
+ """If field_type is 'ref', the kind of Evolvable it references."""
46
+
47
+
48
+ class ParentRule(BaseModel, frozen=True):
49
+ """Declares what kinds are allowed as parents of a node kind.
50
+
51
+ Used by the SchemaRegistry to enforce parent–child relationships.
52
+ """
53
+
54
+ child_kind: str
55
+ """The kind that this rule applies to."""
56
+
57
+ allowed_parent_kinds: frozenset[str] = frozenset()
58
+ """Kinds allowed as parents. Empty means any kind (unconstrained)."""
59
+
60
+ required: bool = True
61
+ """Whether a parent is required (True) or optional (False)."""
62
+
63
+
64
+ class FieldRef(BaseModel, frozen=True):
65
+ """A cross-reference from a field to a target node kind.
66
+
67
+ Captures typed relationships between node kinds that are declared
68
+ via fields (e.g., a ``tool`` field that references node kind ``tool``
69
+ with relationship type ``USES_TOOL``).
70
+ """
71
+
72
+ field_name: str
73
+ """The field on the source node that holds the reference."""
74
+
75
+ target_kind: str
76
+ """The node kind being referenced."""
77
+
78
+ rel_type: str = ""
79
+ """Optional relationship type name (e.g. "USES_TOOL", "BELONGS_TO")."""
80
+
81
+
82
+ class NodeDefinition(Definitive, frozen=True):
83
+ """A Definitive describing a single node kind.
84
+
85
+ Specifies the fields that instances of this kind carry, which
86
+ wing they belong to, what parent rules apply, and whether this
87
+ kind is a root, leaf, or extension type.
88
+
89
+ This is the canonical location for all node type metadata in the
90
+ engine.
91
+ """
92
+
93
+ fields: tuple[FieldSpec, ...] = ()
94
+ """Ordered field specifications for this kind."""
95
+
96
+ wing: str = ""
97
+ """Wing this node kind belongs to (e.g. "system", "platform")."""
98
+
99
+ parent_rules: tuple[ParentRule, ...] = ()
100
+ """Rules governing parent relationships."""
101
+
102
+ directory_name: str = ""
103
+ """Conventional directory name for filesystem layout (e.g. "layers")."""
104
+
105
+ display_order: int = 0
106
+ """Ordering hint for UI display."""
107
+
108
+ is_root: bool = False
109
+ """Whether this is a root node kind (no parent required)."""
110
+
111
+ is_leaf: bool = False
112
+ """Whether this is a leaf node kind (no children allowed)."""
113
+
114
+ is_extension: bool = False
115
+ """Whether this kind extends other node kinds (like a plugin/overlay)."""
116
+
117
+ extends_kinds: tuple[str, ...] = ()
118
+ """If is_extension, the node kinds this extension can target."""
119
+
120
+ cross_references: tuple[FieldRef, ...] = ()
121
+ """Typed references from fields on this kind to other node kinds."""
122
+
123
+ scaffoldable: bool = False
124
+ """Whether instances of this kind support scaffolding (project generation)."""
125
+
126
+ clonable: bool = False
127
+ """Whether instances of this kind can be cloned."""
128
+
129
+ display_label: str = ""
130
+ """Human-readable singular label (e.g. "System", "Practice Area").
131
+ Falls back to describes_kind.replace('_', ' ').title() if empty."""
132
+
133
+ display_label_plural: str = ""
134
+ """Human-readable plural label (e.g. "Systems", "Practice Areas").
135
+ Falls back to display_label + 's' if empty."""
136
+
137
+ @property
138
+ def effective_display_label(self) -> str:
139
+ """Resolved display label, with fallback to title-cased kind."""
140
+ if self.display_label:
141
+ return self.display_label
142
+ return self.describes_kind.replace("_", " ").title()
143
+
144
+ @property
145
+ def effective_display_label_plural(self) -> str:
146
+ """Resolved plural display label, with fallback."""
147
+ if self.display_label_plural:
148
+ return self.display_label_plural
149
+ label = self.effective_display_label
150
+ # Simple English pluralization
151
+ if label.endswith("y") and not label.endswith("ey"):
152
+ return label[:-1] + "ies"
153
+ if label.endswith(("s", "sh", "ch", "x", "z")):
154
+ return label + "es"
155
+ return label + "s"
156
+
157
+ def field_by_name(self, name: str) -> FieldSpec | None:
158
+ """Look up a field spec by name."""
159
+ for field in self.fields:
160
+ if field.name == name:
161
+ return field
162
+ return None
163
+
164
+ def required_fields(self) -> tuple[FieldSpec, ...]:
165
+ """Return only the required fields."""
166
+ return tuple(f for f in self.fields if f.required)
167
+
168
+ @property
169
+ def has_parent(self) -> bool:
170
+ """Whether this node kind can have a parent (has any parent rules)."""
171
+ return len(self.parent_rules) > 0
172
+
173
+ @property
174
+ def allowed_parent_kinds(self) -> frozenset[str]:
175
+ """Union of all allowed parent kinds from all parent rules."""
176
+ result: set[str] = set()
177
+ for rule in self.parent_rules:
178
+ result.update(rule.allowed_parent_kinds)
179
+ return frozenset(result)
180
+
181
+ @property
182
+ def parent_required(self) -> bool:
183
+ """Whether any parent rule requires a parent."""
184
+ return any(rule.required for rule in self.parent_rules)
185
+
186
+ def cross_ref_for_field(self, field_name: str) -> FieldRef | None:
187
+ """Look up a cross-reference by field name."""
188
+ for ref in self.cross_references:
189
+ if ref.field_name == field_name:
190
+ return ref
191
+ return None
192
+
193
+
194
+ class WingDefinition(Definitive, frozen=True):
195
+ """A Definitive describing a grouping/wing.
196
+
197
+ Wings organize node kinds into logical groups (e.g. "system" wing
198
+ contains systems, stacks, layers). A WingDefinition declares which
199
+ node kinds belong to it and their display ordering.
200
+ """
201
+
202
+ member_kinds: tuple[str, ...] = ()
203
+ """Node kinds belonging to this wing, in display order."""
204
+
205
+ display_name: str = ""
206
+ """Human-readable name for the wing."""
207
+
208
+ icon: str = ""
209
+ """Optional icon identifier for UI rendering."""
210
+
211
+
212
+ class RelationshipDefinition(Definitive, frozen=True):
213
+ """A Definitive describing a relationship type.
214
+
215
+ Describes a type of relationship (edge) that can exist between
216
+ nodes in the taxonomy graph. Relationship definitions carry
217
+ classification metadata (wing, cardinality, labels) that enables
218
+ grouping, filtering, and display without constraining graph
219
+ operations.
220
+
221
+ Relationship wings are independent of node wings — they describe
222
+ the semantic domain of the relationship itself (e.g. "structural",
223
+ "capability", "verification"), not the wings of the connected nodes.
224
+
225
+ The ``describes_kind`` is the canonical snake_case identifier
226
+ (e.g. "provides_capability"). The graph label (SCREAMING_SNAKE_CASE)
227
+ is derived via the ``graph_label`` property.
228
+ """
229
+
230
+ description: str = ""
231
+ """Human-readable description of this relationship type."""
232
+
233
+ wing: str = ""
234
+ """Relationship wing for classification (e.g. "structural", "capability")."""
235
+
236
+ display_order: int = 0
237
+ """Ordering hint for UI display within a wing."""
238
+
239
+ cardinality: Cardinality = Cardinality.MANY_TO_MANY
240
+ """Expected cardinality of instances of this relationship type."""
241
+
242
+ directed: bool = True
243
+ """Whether this is a directed relationship. Almost always True."""
244
+
245
+ structural: bool = False
246
+ """Whether this is a structural relationship (parent, extends, depends_on, deploys_to)."""
247
+
248
+ @property
249
+ def graph_label(self) -> str:
250
+ """SCREAMING_SNAKE_CASE label for graph export.
251
+
252
+ Derived from ``describes_kind`` by upper-casing.
253
+
254
+ Examples:
255
+ provides_capability -> PROVIDES_CAPABILITY
256
+ child_of -> CHILD_OF
257
+ depends_on -> DEPENDS_ON
258
+ """
259
+ return self.describes_kind.upper()
260
+
261
+
262
+ class SchemaDefinition(Definitive, frozen=True):
263
+ """A Definitive describing an entire schema family.
264
+
265
+ A SchemaDefinition is the top-level container that groups
266
+ NodeDefinitions and WingDefinitions into a coherent schema
267
+ version. It is itself a Definitive so it can be versioned,
268
+ deprecated, and migrated.
269
+ """
270
+
271
+ schema_version: Version = Version()
272
+ """The version of this schema."""
273
+
274
+ node_definitions: tuple[Identity, ...] = ()
275
+ """References to NodeDefinitions belonging to this schema."""
276
+
277
+ wing_definitions: tuple[Identity, ...] = ()
278
+ """References to WingDefinitions belonging to this schema."""
279
+
280
+ api_version: str = ""
281
+ """API version string (e.g. "v2alpha") for document compatibility."""