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,95 @@
1
+ """Schema manager — orchestrates schema registration and validation.
2
+
3
+ Coordinates the SchemaRegistry with schema definitions, providing
4
+ a high-level interface for registering schemas, validating nodes
5
+ against their schemas, and answering "what schema governs this kind?"
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from katalyst_engine.core.definitive import ValidationResult
11
+ from katalyst_engine.core.evolvable import Evolvable
12
+ from katalyst_engine.schema.definition import (
13
+ NodeDefinition,
14
+ RelationshipDefinition,
15
+ SchemaDefinition,
16
+ WingDefinition,
17
+ )
18
+ from katalyst_engine.schema.registry import SchemaRegistry
19
+
20
+
21
+ class SchemaManager:
22
+ """Orchestrator for schema registration and instance validation.
23
+
24
+ Usage:
25
+ manager = SchemaManager()
26
+ manager.register_schema(my_schema, node_defs, wing_defs, rel_defs)
27
+ result = manager.validate(some_node)
28
+ node_def = manager.definition_for_kind("layer")
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self._registry = SchemaRegistry()
33
+ self._schemas: dict[str, SchemaDefinition] = {} # keyed by api_version
34
+
35
+ @property
36
+ def registry(self) -> SchemaRegistry:
37
+ """Access the underlying SchemaRegistry."""
38
+ return self._registry
39
+
40
+ def register_schema(
41
+ self,
42
+ schema: SchemaDefinition,
43
+ node_definitions: list[NodeDefinition] | None = None,
44
+ wing_definitions: list[WingDefinition] | None = None,
45
+ relationship_definitions: list[RelationshipDefinition] | None = None,
46
+ ) -> None:
47
+ """Register a schema and its associated definitions.
48
+
49
+ Args:
50
+ schema: The top-level SchemaDefinition.
51
+ node_definitions: NodeDefinitions to register.
52
+ wing_definitions: WingDefinitions to register.
53
+ relationship_definitions: RelationshipDefinitions to register.
54
+
55
+ After registering explicit relationship definitions, calls
56
+ ``derive_relationships_from_nodes()`` to auto-derive stubs for
57
+ any cross-reference rel_types not covered by the explicit set.
58
+ """
59
+ self._schemas[schema.api_version] = schema
60
+
61
+ for node_def in node_definitions or []:
62
+ self._registry.register_node(node_def)
63
+
64
+ for wing_def in wing_definitions or []:
65
+ self._registry.register_wing(wing_def)
66
+
67
+ for rel_def in relationship_definitions or []:
68
+ self._registry.register_relationship(rel_def)
69
+
70
+ # Auto-derive stubs for any FieldRef.rel_type not yet registered
71
+ self._registry.derive_relationships_from_nodes()
72
+
73
+ def validate(self, instance: Evolvable) -> ValidationResult:
74
+ """Validate an Evolvable against its kind's schema definition.
75
+
76
+ Returns a passing result if no definition is registered for the kind.
77
+ """
78
+ return self._registry.validate_against_schema(instance)
79
+
80
+ def definition_for_kind(self, kind: str) -> NodeDefinition | None:
81
+ """Look up the NodeDefinition governing a kind."""
82
+ return self._registry.get_node_definition(kind)
83
+
84
+ def schema_for_api_version(self, api_version: str) -> SchemaDefinition | None:
85
+ """Look up a SchemaDefinition by its api_version string."""
86
+ return self._schemas.get(api_version)
87
+
88
+ def all_api_versions(self) -> list[str]:
89
+ """Return all registered api_version strings, sorted."""
90
+ return sorted(self._schemas.keys())
91
+
92
+ @property
93
+ def schema_count(self) -> int:
94
+ """Number of registered schemas."""
95
+ return len(self._schemas)
@@ -0,0 +1,367 @@
1
+ """Schema registry — runtime index of node definitions, wing lookups, and relationship types.
2
+
3
+ Maps kind strings to NodeDefinitions, provides wing membership
4
+ queries, enforces parent rules, and indexes relationship definitions.
5
+ This is the single source of truth for all node type and relationship
6
+ type metadata at runtime.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from katalyst_engine.core.definitive import ValidationResult
14
+ from katalyst_engine.core.evolvable import Evolvable
15
+ from katalyst_engine.schema.definition import (
16
+ NodeDefinition,
17
+ ParentRule,
18
+ RelationshipDefinition,
19
+ WingDefinition,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class SchemaRegistry:
26
+ """Runtime registry mapping kind strings to their definitions.
27
+
28
+ Provides fast lookups for:
29
+ - kind → NodeDefinition
30
+ - wing → member kinds
31
+ - kind → allowed parent kinds
32
+ - directory_name → kind (reverse lookup)
33
+ - target_kind → extension kinds
34
+
35
+ This is a mutable container populated by the SchemaManager
36
+ or directly by Bundle.register().
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ self._node_defs: dict[str, NodeDefinition] = {} # keyed by describes_kind
41
+ self._wing_defs: dict[str, WingDefinition] = {} # keyed by describes_kind
42
+ self._wing_members: dict[str, list[str]] = {} # wing name → kinds
43
+ self._by_directory: dict[str, str] = {} # directory_name → kind
44
+ self._extension_index: dict[str, list[str]] = {} # target_kind → extension kinds
45
+ self._rel_defs: dict[str, RelationshipDefinition] = {} # keyed by describes_kind
46
+ self._rel_by_wing: dict[str, list[str]] = {} # rel wing → rel kinds
47
+ self._rel_by_graph_label: dict[str, str] = {} # graph_label → describes_kind
48
+
49
+ # ── Registration ──────────────────────────────────────────────────
50
+
51
+ def register_node(self, node_def: NodeDefinition) -> None:
52
+ """Register a NodeDefinition, indexed by its describes_kind.
53
+
54
+ Updates all internal indexes (wing membership, directory lookup,
55
+ extension index). Re-registering the same kind overwrites cleanly.
56
+ """
57
+ kind = node_def.describes_kind
58
+ self._node_defs[kind] = node_def
59
+
60
+ # Wing membership index
61
+ if node_def.wing:
62
+ if node_def.wing not in self._wing_members:
63
+ self._wing_members[node_def.wing] = []
64
+ # Remove stale entry if re-registering
65
+ self._wing_members[node_def.wing] = [
66
+ k for k in self._wing_members[node_def.wing] if k != kind
67
+ ]
68
+ self._wing_members[node_def.wing].append(kind)
69
+
70
+ # Directory name reverse lookup
71
+ if node_def.directory_name:
72
+ self._by_directory[node_def.directory_name] = kind
73
+
74
+ # Extension index
75
+ if node_def.extends_kinds:
76
+ for target_kind in node_def.extends_kinds:
77
+ targets = self._extension_index.setdefault(target_kind, [])
78
+ if kind not in targets:
79
+ targets.append(kind)
80
+
81
+ def register_wing(self, wing_def: WingDefinition) -> None:
82
+ """Register a WingDefinition."""
83
+ self._wing_defs[wing_def.describes_kind] = wing_def
84
+
85
+ def register_relationship(self, rel_def: RelationshipDefinition) -> None:
86
+ """Register a RelationshipDefinition, indexed by describes_kind.
87
+
88
+ Updates wing and graph_label indexes. Re-registering the same
89
+ kind overwrites cleanly.
90
+ """
91
+ kind = rel_def.describes_kind
92
+ self._rel_defs[kind] = rel_def
93
+
94
+ # Graph label reverse lookup
95
+ self._rel_by_graph_label[rel_def.graph_label] = kind
96
+
97
+ # Relationship wing index
98
+ if rel_def.wing:
99
+ if rel_def.wing not in self._rel_by_wing:
100
+ self._rel_by_wing[rel_def.wing] = []
101
+ # Remove stale entry if re-registering
102
+ self._rel_by_wing[rel_def.wing] = [
103
+ k for k in self._rel_by_wing[rel_def.wing] if k != kind
104
+ ]
105
+ self._rel_by_wing[rel_def.wing].append(kind)
106
+
107
+ # ── Kind lookups ──────────────────────────────────────────────────
108
+
109
+ def get_node_definition(self, kind: str) -> NodeDefinition | None:
110
+ """Look up the NodeDefinition for a kind."""
111
+ return self._node_defs.get(kind)
112
+
113
+ def require_node_definition(self, kind: str) -> NodeDefinition:
114
+ """Look up the NodeDefinition for a kind, raising if not found."""
115
+ node_def = self._node_defs.get(kind)
116
+ if node_def is None:
117
+ raise KeyError(f"Unknown node kind: {kind!r}")
118
+ return node_def
119
+
120
+ def __contains__(self, kind: str) -> bool:
121
+ """Check if a kind is registered."""
122
+ return kind in self._node_defs
123
+
124
+ # ── Wing lookups ──────────────────────────────────────────────────
125
+
126
+ def get_wing_definition(self, wing: str) -> WingDefinition | None:
127
+ """Look up a WingDefinition by its describes_kind."""
128
+ return self._wing_defs.get(wing)
129
+
130
+ def kinds_in_wing(self, wing: str) -> list[str]:
131
+ """Return all node kinds belonging to a wing, sorted by display order."""
132
+ kinds = self._wing_members.get(wing, [])
133
+ # Sort by display_order of the registered NodeDefinition
134
+ return sorted(
135
+ kinds,
136
+ key=lambda k: self._node_defs[k].display_order if k in self._node_defs else 0,
137
+ )
138
+
139
+ def all_kinds(self) -> list[str]:
140
+ """Return all registered node kind strings, sorted alphabetically."""
141
+ return sorted(self._node_defs.keys())
142
+
143
+ def sorted_kinds(self) -> list[str]:
144
+ """Return all kinds sorted by display order."""
145
+ return [
146
+ nd.describes_kind
147
+ for nd in sorted(self._node_defs.values(), key=lambda nd: nd.display_order)
148
+ ]
149
+
150
+ def all_wings(self) -> list[str]:
151
+ """Return all registered wing names (from wing_members index), sorted."""
152
+ return sorted(self._wing_members.keys())
153
+
154
+ # ── Type classification queries ───────────────────────────────────
155
+
156
+ def is_root(self, kind: str) -> bool:
157
+ """Whether this kind is a root node (no parent required)."""
158
+ nd = self._node_defs.get(kind)
159
+ return nd.is_root if nd else False
160
+
161
+ def is_leaf(self, kind: str) -> bool:
162
+ """Whether this kind is a leaf node (no children)."""
163
+ nd = self._node_defs.get(kind)
164
+ return nd.is_leaf if nd else False
165
+
166
+ def is_extension(self, kind: str) -> bool:
167
+ """Whether this kind is an extension type."""
168
+ nd = self._node_defs.get(kind)
169
+ return nd.is_extension if nd else False
170
+
171
+ def is_scaffoldable(self, kind: str) -> bool:
172
+ """Whether this kind supports project scaffolding."""
173
+ nd = self._node_defs.get(kind)
174
+ return nd.scaffoldable if nd else False
175
+
176
+ def is_clonable(self, kind: str) -> bool:
177
+ """Whether this kind can be cloned."""
178
+ nd = self._node_defs.get(kind)
179
+ return nd.clonable if nd else False
180
+
181
+ def scaffoldable_kinds(self) -> list[str]:
182
+ """Return all kinds that support scaffolding, sorted by display_order."""
183
+ return [
184
+ nd.describes_kind
185
+ for nd in sorted(self._node_defs.values(), key=lambda d: d.display_order)
186
+ if nd.scaffoldable
187
+ ]
188
+
189
+ def clonable_kinds(self) -> list[str]:
190
+ """Return all kinds that can be cloned, sorted by display_order."""
191
+ return [
192
+ nd.describes_kind
193
+ for nd in sorted(self._node_defs.values(), key=lambda d: d.display_order)
194
+ if nd.clonable
195
+ ]
196
+
197
+ def creatable_kinds(self) -> list[str]:
198
+ """Return all non-extension kinds that can be created, sorted by display_order."""
199
+ return [
200
+ nd.describes_kind
201
+ for nd in sorted(self._node_defs.values(), key=lambda d: d.display_order)
202
+ if not nd.is_extension
203
+ ]
204
+
205
+ def display_label_for(self, kind: str) -> str:
206
+ """Return the display label for a kind, with fallback to title-cased kind."""
207
+ nd = self._node_defs.get(kind)
208
+ if nd is not None:
209
+ return nd.effective_display_label
210
+ return kind.replace("_", " ").title()
211
+
212
+ def display_label_plural_for(self, kind: str) -> str:
213
+ """Return the plural display label for a kind."""
214
+ nd = self._node_defs.get(kind)
215
+ if nd is not None:
216
+ return nd.effective_display_label_plural
217
+ return kind.replace("_", " ").title() + "s"
218
+
219
+ # ── Parent / child queries ────────────────────────────────────────
220
+
221
+ def parent_rules_for(self, kind: str) -> tuple[ParentRule, ...]:
222
+ """Return parent rules for a node kind."""
223
+ node_def = self._node_defs.get(kind)
224
+ if node_def is None:
225
+ return ()
226
+ return node_def.parent_rules
227
+
228
+ def allowed_parent_kinds(self, kind: str) -> frozenset[str]:
229
+ """Return the set of kinds allowed as parents for the given kind.
230
+
231
+ Returns an empty frozenset if unconstrained or kind is unknown.
232
+ """
233
+ nd = self._node_defs.get(kind)
234
+ if nd is None:
235
+ return frozenset()
236
+ return nd.allowed_parent_kinds
237
+
238
+ # ── Extension queries ─────────────────────────────────────────────
239
+
240
+ def extensions_for_kind(self, target_kind: str) -> list[str]:
241
+ """Return extension kinds that can target the given kind."""
242
+ return list(self._extension_index.get(target_kind, []))
243
+
244
+ # ── Directory reverse lookup ──────────────────────────────────────
245
+
246
+ def kind_for_directory(self, directory_name: str) -> str | None:
247
+ """Reverse lookup: directory name → kind."""
248
+ return self._by_directory.get(directory_name)
249
+
250
+ # ── Validation ────────────────────────────────────────────────────
251
+
252
+ def validate_against_schema(self, instance: Evolvable) -> ValidationResult:
253
+ """Validate an Evolvable against its kind's NodeDefinition.
254
+
255
+ Returns a passing result if no definition is registered for the kind.
256
+ """
257
+ node_def = self._node_defs.get(instance.identity.kind)
258
+ if node_def is None:
259
+ return ValidationResult(valid=True)
260
+ return node_def.validate_instance(instance)
261
+
262
+ # ── Relationship lookups ──────────────────────────────────────────
263
+
264
+ def get_relationship(self, kind: str) -> RelationshipDefinition | None:
265
+ """Look up a RelationshipDefinition by its describes_kind."""
266
+ return self._rel_defs.get(kind)
267
+
268
+ def get_relationship_by_graph_label(self, label: str) -> RelationshipDefinition | None:
269
+ """Look up a RelationshipDefinition by its SCREAMING_SNAKE graph label."""
270
+ kind = self._rel_by_graph_label.get(label)
271
+ if kind is None:
272
+ return None
273
+ return self._rel_defs.get(kind)
274
+
275
+ def all_relationships(self) -> list[RelationshipDefinition]:
276
+ """Return all registered RelationshipDefinitions, sorted by display_order."""
277
+ return sorted(self._rel_defs.values(), key=lambda rd: rd.display_order)
278
+
279
+ def relationships_in_wing(self, wing: str) -> list[RelationshipDefinition]:
280
+ """Return RelationshipDefinitions in a wing, sorted by display_order."""
281
+ kinds = self._rel_by_wing.get(wing, [])
282
+ defs = [self._rel_defs[k] for k in kinds if k in self._rel_defs]
283
+ return sorted(defs, key=lambda rd: rd.display_order)
284
+
285
+ def relationship_wings(self) -> list[str]:
286
+ """Return all registered relationship wing names, sorted."""
287
+ return sorted(self._rel_by_wing.keys())
288
+
289
+ # ── Relationship derivation ───────────────────────────────────────
290
+
291
+ def derive_relationships_from_nodes(self) -> list[str]:
292
+ """Scan all registered NodeDefinitions for cross-reference rel_types.
293
+
294
+ For any FieldRef.rel_type that is not already registered as a
295
+ RelationshipDefinition, creates a stub definition (no wing, no
296
+ description) and logs a warning. Returns the list of auto-derived
297
+ kind strings.
298
+
299
+ This enables a "derive, don't duplicate" workflow: bundles
300
+ declare enriched RelationshipDefinitions for classified types,
301
+ and this method fills in stubs for any that were missed.
302
+ """
303
+ from katalyst_engine.core.identity import Identity
304
+
305
+ derived: list[str] = []
306
+ seen_rel_types: set[str] = set()
307
+
308
+ for node_def in self._node_defs.values():
309
+ for ref in node_def.cross_references:
310
+ if not ref.rel_type:
311
+ continue
312
+ # Normalise to snake_case canonical kind
313
+ canonical = ref.rel_type.lower()
314
+ if canonical in seen_rel_types:
315
+ continue
316
+ seen_rel_types.add(canonical)
317
+
318
+ if canonical not in self._rel_defs:
319
+ stub = RelationshipDefinition(
320
+ identity=Identity(
321
+ kind="relationship_definition",
322
+ name=canonical,
323
+ ),
324
+ describes_kind=canonical,
325
+ description=f"Auto-derived from FieldRef.rel_type={ref.rel_type!r}",
326
+ )
327
+ self.register_relationship(stub)
328
+ derived.append(canonical)
329
+ logger.warning(
330
+ "Auto-derived RelationshipDefinition for %r "
331
+ "(no explicit definition registered)",
332
+ canonical,
333
+ )
334
+
335
+ return derived
336
+
337
+ # ── Counts / iteration ────────────────────────────────────────────
338
+
339
+ @property
340
+ def node_count(self) -> int:
341
+ """Number of registered node definitions."""
342
+ return len(self._node_defs)
343
+
344
+ @property
345
+ def wing_count(self) -> int:
346
+ """Number of registered wing definitions."""
347
+ return len(self._wing_defs)
348
+
349
+ @property
350
+ def relationship_count(self) -> int:
351
+ """Number of registered relationship definitions."""
352
+ return len(self._rel_defs)
353
+
354
+ def all_node_definitions(self) -> list[NodeDefinition]:
355
+ """Return all registered NodeDefinitions, sorted by describes_kind."""
356
+ return sorted(self._node_defs.values(), key=lambda nd: nd.describes_kind)
357
+
358
+ def clear(self) -> None:
359
+ """Remove all registered definitions."""
360
+ self._node_defs.clear()
361
+ self._wing_defs.clear()
362
+ self._wing_members.clear()
363
+ self._by_directory.clear()
364
+ self._extension_index.clear()
365
+ self._rel_defs.clear()
366
+ self._rel_by_wing.clear()
367
+ self._rel_by_graph_label.clear()
@@ -0,0 +1,115 @@
1
+ """Schema versioning — comparison and migration path planning.
2
+
3
+ Provides helpers for comparing schema versions and computing the
4
+ sequence of MigrationPaths needed to move between versions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import Enum
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from katalyst_engine.core.compatibility import MigrationPath
14
+ from katalyst_engine.core.version import Version
15
+
16
+
17
+ class SchemaVersionChange(str, Enum):
18
+ """Classification of the change between two schema versions."""
19
+
20
+ NONE = "none"
21
+ PATCH = "patch"
22
+ MINOR = "minor"
23
+ MAJOR = "major"
24
+
25
+
26
+ class SchemaVersionComparison(BaseModel, frozen=True):
27
+ """Result of comparing two schema versions.
28
+
29
+ Captures the direction and magnitude of the change, and whether
30
+ the versions are compatible.
31
+ """
32
+
33
+ from_version: Version
34
+ to_version: Version
35
+ change: SchemaVersionChange
36
+ compatible: bool
37
+ """Whether the two versions are semver-compatible."""
38
+
39
+ upgrade: bool
40
+ """True if to_version > from_version."""
41
+
42
+
43
+ def compare_versions(from_v: Version, to_v: Version) -> SchemaVersionComparison:
44
+ """Compare two schema versions and classify the change.
45
+
46
+ Returns a SchemaVersionComparison describing the magnitude,
47
+ direction, and compatibility of the change.
48
+ """
49
+ if from_v == to_v:
50
+ change = SchemaVersionChange.NONE
51
+ elif from_v.major != to_v.major:
52
+ change = SchemaVersionChange.MAJOR
53
+ elif from_v.minor != to_v.minor:
54
+ change = SchemaVersionChange.MINOR
55
+ else:
56
+ change = SchemaVersionChange.PATCH
57
+
58
+ return SchemaVersionComparison(
59
+ from_version=from_v,
60
+ to_version=to_v,
61
+ change=change,
62
+ compatible=from_v.is_compatible_with(to_v),
63
+ upgrade=to_v > from_v,
64
+ )
65
+
66
+
67
+ def plan_migration_path(
68
+ from_version: Version,
69
+ to_version: Version,
70
+ available_paths: list[MigrationPath],
71
+ ) -> list[MigrationPath]:
72
+ """Find an ordered sequence of migrations from one version to another.
73
+
74
+ Uses a simple BFS over available MigrationPaths. Returns an empty
75
+ list if no path exists.
76
+
77
+ Args:
78
+ from_version: Starting version.
79
+ to_version: Target version.
80
+ available_paths: All known migration paths.
81
+
82
+ Returns:
83
+ Ordered list of MigrationPaths from from_version to to_version,
84
+ or empty list if unreachable.
85
+ """
86
+ if from_version == to_version:
87
+ return []
88
+
89
+ adjacency: dict[str, list[MigrationPath]] = {}
90
+ for path in available_paths:
91
+ key = str(path.from_version)
92
+ if key not in adjacency:
93
+ adjacency[key] = []
94
+ adjacency[key].append(path)
95
+
96
+ visited: set[str] = set()
97
+ queue: list[list[MigrationPath]] = [[]]
98
+ current_versions = [str(from_version)]
99
+
100
+ while queue:
101
+ chain = queue.pop(0)
102
+ current = current_versions.pop(0)
103
+
104
+ if current in visited:
105
+ continue
106
+ visited.add(current)
107
+
108
+ for path in adjacency.get(current, []):
109
+ new_chain = [*chain, path]
110
+ if str(path.to_version) == str(to_version):
111
+ return new_chain
112
+ queue.append(new_chain)
113
+ current_versions.append(str(path.to_version))
114
+
115
+ return []
@@ -0,0 +1,18 @@
1
+ """Snapshot — frozen model copies and diffing.
2
+
3
+ .. note:: FUTURE: Wire into taxonomy when audit trail / change history features
4
+ are built. Snapshots are frozen model copies that can be diffed.
5
+ Current taxonomy sync uses sync_id pruning (database-native, more
6
+ efficient for the sync use case). Snapshots serve "show me what
7
+ changed between two points in time" which is a different need.
8
+ """
9
+
10
+ from katalyst_engine.snapshot.diff import SnapshotDiff, diff_snapshots
11
+ from katalyst_engine.snapshot.snapshot import Snapshot, SnapshotManifest
12
+
13
+ __all__ = [
14
+ "Snapshot",
15
+ "SnapshotDiff",
16
+ "SnapshotManifest",
17
+ "diff_snapshots",
18
+ ]
@@ -0,0 +1,94 @@
1
+ """Snapshot diff — comparison of two snapshots.
2
+
3
+ Computes the set of identities added, removed, and changed
4
+ between two snapshots.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from katalyst_engine.core.identity import Identity
12
+ from katalyst_engine.snapshot.snapshot import Snapshot
13
+
14
+
15
+ class SnapshotDiff(BaseModel, frozen=True):
16
+ """Comparison of two snapshots showing what changed.
17
+
18
+ Captures which identities were added, removed, are present
19
+ in both, or have changed content (different checksums).
20
+ """
21
+
22
+ from_snapshot: Identity
23
+ """Identity of the older snapshot."""
24
+
25
+ to_snapshot: Identity
26
+ """Identity of the newer snapshot."""
27
+
28
+ added: frozenset[Identity] = frozenset()
29
+ """Identities present in the newer snapshot but not the older."""
30
+
31
+ removed: frozenset[Identity] = frozenset()
32
+ """Identities present in the older snapshot but not the newer."""
33
+
34
+ common: frozenset[Identity] = frozenset()
35
+ """Identities present in both snapshots (may have changed)."""
36
+
37
+ changed: frozenset[Identity] = frozenset()
38
+ """Identities whose content checksums differ between snapshots."""
39
+
40
+ @property
41
+ def has_changes(self) -> bool:
42
+ """True if any identities were added, removed, or changed."""
43
+ return bool(self.added) or bool(self.removed) or bool(self.changed)
44
+
45
+ @property
46
+ def added_count(self) -> int:
47
+ """Number of added identities."""
48
+ return len(self.added)
49
+
50
+ @property
51
+ def removed_count(self) -> int:
52
+ """Number of removed identities."""
53
+ return len(self.removed)
54
+
55
+ @property
56
+ def changed_count(self) -> int:
57
+ """Number of changed identities."""
58
+ return len(self.changed)
59
+
60
+
61
+ def diff_snapshots(older: Snapshot, newer: Snapshot) -> SnapshotDiff:
62
+ """Compute the diff between two snapshots.
63
+
64
+ Args:
65
+ older: The baseline snapshot.
66
+ newer: The comparison snapshot.
67
+
68
+ Returns:
69
+ A SnapshotDiff describing what changed.
70
+ """
71
+ older_set = frozenset(older.member_refs)
72
+ newer_set = frozenset(newer.member_refs)
73
+
74
+ common = older_set & newer_set
75
+
76
+ # Compute content-changed identities when both have checksums
77
+ changed: frozenset[Identity] = frozenset()
78
+ older_checksums = older.manifest.node_checksums
79
+ newer_checksums = newer.manifest.node_checksums
80
+ if older_checksums and newer_checksums:
81
+ changed = frozenset(
82
+ ident
83
+ for ident in common
84
+ if older_checksums.get(ident.fqn) != newer_checksums.get(ident.fqn)
85
+ )
86
+
87
+ return SnapshotDiff(
88
+ from_snapshot=older.identity,
89
+ to_snapshot=newer.identity,
90
+ added=newer_set - older_set,
91
+ removed=older_set - newer_set,
92
+ common=common,
93
+ changed=changed,
94
+ )