cgcardona / muse public
domain.py python
874 lines 33.2 KB
dfaf1b77 refactor: rename muse-work/ → state/ Gabriel Cardona <gabriel@tellurstori.com> 8h ago
1 """MuseDomainPlugin — the six-interface protocol that defines a Muse domain.
2
3 Muse provides the DAG engine, content-addressed object store, branching,
4 lineage walking, topological log graph, and merge base finder. A domain plugin
5 implements these six interfaces and Muse does the rest.
6
7 The MIDI plugin (``muse.plugins.midi``) is the reference implementation.
8 Every other domain — scientific simulation, genomics, 3D spatial design,
9 spacetime — is a new plugin.
10
11 Typed Delta Algebra
12 -------------------
13 ``StateDelta`` is a ``StructuredDelta`` carrying a typed operation list rather
14 than an opaque path list. Each operation knows its kind (insert / delete /
15 move / replace / patch), the address it touched, and a content-addressed ID
16 for the before/after content.
17
18 Domain Schema
19 -------------
20 ``schema()`` is the sixth protocol method. Plugins return a ``DomainSchema``
21 declaring their data structure. The core engine uses this declaration to drive
22 diff algorithm selection via :func:`~muse.core.diff_algorithms.diff_by_schema`.
23
24 Operational Transformation Merge
25 ---------------------------------
26 Plugins may optionally implement :class:`StructuredMergePlugin`, a sub-protocol
27 that adds ``merge_ops()``. When both branches have produced ``StructuredDelta``
28 from ``diff()``, the merge engine checks
29 ``isinstance(plugin, StructuredMergePlugin)`` and calls ``merge_ops()`` for
30 fine-grained, operation-level conflict detection. Non-supporting plugins fall
31 back to the existing file-level ``merge()`` path.
32
33 CRDT Convergent Merge
34 ---------------------
35 Plugins may optionally implement :class:`CRDTPlugin`, a sub-protocol that
36 replaces ``merge()`` with ``join()``. ``join`` always succeeds — no conflict
37 state ever exists. Given any two :class:`CRDTSnapshotManifest` values,
38 ``join`` produces a deterministic merged result regardless of message delivery
39 order.
40
41 The core engine detects ``CRDTPlugin`` via ``isinstance`` at merge time.
42 ``DomainSchema.merge_mode == "crdt"`` signals that the CRDT path should be
43 taken.
44 """
45
46 from __future__ import annotations
47
48 import pathlib
49 from dataclasses import dataclass, field
50 from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, runtime_checkable
51
52 # Public re-exports so callers can do ``from muse.domain import MutateOp`` etc.
53 __all__ = [
54 "SnapshotManifest",
55 "DomainAddress",
56 "InsertOp",
57 "DeleteOp",
58 "MoveOp",
59 "ReplaceOp",
60 "FieldMutation",
61 "MutateOp",
62 "EntityProvenance",
63 "LeafDomainOp",
64 "PatchOp",
65 "DomainOp",
66 "SemVerBump",
67 "StructuredDelta",
68 "infer_sem_ver_bump",
69 "LiveState",
70 "StateSnapshot",
71 "StateDelta",
72 "ConflictRecord",
73 "MergeResult",
74 "DriftReport",
75 "MuseDomainPlugin",
76 "StructuredMergePlugin",
77 "CRDTSnapshotManifest",
78 "CRDTPlugin",
79 ]
80
81 if TYPE_CHECKING:
82 from muse.core.schema import CRDTDimensionSpec, DomainSchema
83
84
85 # ---------------------------------------------------------------------------
86 # Snapshot types (unchanged from pre-Phase-1)
87 # ---------------------------------------------------------------------------
88
89
90 class SnapshotManifest(TypedDict):
91 """Content-addressed snapshot of domain state.
92
93 ``files`` maps workspace-relative POSIX paths to their SHA-256 content
94 digests. ``domain`` identifies which plugin produced this snapshot.
95 """
96
97 files: dict[str, str]
98 domain: str
99
100
101 # ---------------------------------------------------------------------------
102 # Typed delta algebra
103 # ---------------------------------------------------------------------------
104
105 #: A domain-specific address identifying a location within the state graph.
106 #: For file-level ops this is a workspace-relative POSIX path.
107 #: For sub-file ops this is a domain-specific coordinate (e.g. "note:42").
108 DomainAddress = str
109
110
111 class InsertOp(TypedDict):
112 """An element was inserted into a collection.
113
114 For ordered sequences ``position`` is the integer index at which the
115 element was inserted. For unordered sets ``position`` is ``None``.
116 ``content_id`` is the SHA-256 of the inserted content — either a blob
117 already in the object store (for file-level ops) or a deterministic hash
118 of the element's canonical serialisation (for sub-file ops).
119 """
120
121 op: Literal["insert"]
122 address: DomainAddress
123 position: int | None
124 content_id: str
125 content_summary: str
126
127
128 class DeleteOp(TypedDict):
129 """An element was removed from a collection.
130
131 ``position`` is the integer index that was removed for ordered sequences,
132 or ``None`` for unordered sets. ``content_id`` is the SHA-256 of the
133 deleted content so that the operation can be applied idempotently (already-
134 absent elements can be skipped). ``content_summary`` is the human-readable
135 description of what was removed, for ``muse show``.
136 """
137
138 op: Literal["delete"]
139 address: DomainAddress
140 position: int | None
141 content_id: str
142 content_summary: str
143
144
145 class MoveOp(TypedDict):
146 """An element was repositioned within an ordered sequence.
147
148 ``from_position`` is the source index (in the pre-move sequence) and
149 ``to_position`` is the destination index (in the post-move sequence).
150 Both are mandatory — moves are only meaningful in ordered collections.
151 ``content_id`` identifies the element being moved so that the operation
152 can be validated during replay.
153 """
154
155 op: Literal["move"]
156 address: DomainAddress
157 from_position: int
158 to_position: int
159 content_id: str
160
161
162 class ReplaceOp(TypedDict):
163 """An element's value changed (atomic, leaf-level replacement).
164
165 ``old_content_id`` and ``new_content_id`` are SHA-256 hashes of the
166 before- and after-content. They enable three-way merge engines to detect
167 concurrent conflicting modifications (both changed from the same
168 ``old_content_id`` to different ``new_content_id`` values).
169 ``old_summary`` and ``new_summary`` are human-readable strings for display,
170 analogous to ``content_summary`` on :class:`InsertOp`.
171 ``position`` is the index within the container (``None`` for unordered).
172 """
173
174 op: Literal["replace"]
175 address: DomainAddress
176 position: int | None
177 old_content_id: str
178 new_content_id: str
179 old_summary: str
180 new_summary: str
181
182
183 class FieldMutation(TypedDict):
184 """The string-serialised before/after of a single field in a :class:`MutateOp`.
185
186 Values are always strings so that typed primitives (int, float, bool) can
187 be compared uniformly without carrying domain-specific type information in
188 the generic delta algebra. Plugins format them according to their domain
189 conventions (e.g. ``"80"`` for a MIDI velocity, ``"C4"`` for a pitch name).
190 """
191
192 old: str
193 new: str
194
195
196 class MutateOp(TypedDict):
197 """A named entity's specific fields were updated.
198
199 Unlike :class:`ReplaceOp` — which replaces an entire element atomically —
200 ``MutateOp`` records *which* specific fields of a domain entity changed.
201 This enables mutation tracking for domains that maintain stable entity
202 identity separate from content equality.
203
204 Example: a MIDI note's velocity changed from 80 to 100. Under a pure
205 content-hash model that becomes ``DeleteOp + InsertOp`` (two different
206 content hashes). With ``MutateOp`` and a stable ``entity_id`` the diff
207 reports "velocity 80→100 on entity C4@bar4" — lineage is preserved.
208
209 ``entity_id``
210 Stable identifier for the mutated entity, assigned at first insertion
211 and reused across all subsequent mutations (regardless of content
212 changes).
213 ``fields``
214 Mapping from field name (e.g. ``"velocity"``, ``"start_tick"``) to a
215 :class:`FieldMutation` recording the serialised old and new values.
216 ``old_content_id`` / ``new_content_id``
217 SHA-256 of the full element state before and after the mutation,
218 enabling three-way merge conflict detection identical to
219 :class:`ReplaceOp`.
220 ``position``
221 Index within the containing ordered sequence (``None`` for unordered).
222 """
223
224 op: Literal["mutate"]
225 address: DomainAddress
226 entity_id: str
227 old_content_id: str
228 new_content_id: str
229 fields: dict[str, FieldMutation]
230 old_summary: str
231 new_summary: str
232 position: int | None
233
234
235 class EntityProvenance(TypedDict, total=False):
236 """Causal metadata attached to ops that create or modify tracked entities.
237
238 All fields are optional (``total=False``) because entity tracking is an
239 opt-in capability. Plugins that implement stable entity identity populate
240 these fields when constructing :class:`InsertOp`, :class:`MutateOp`, or
241 :class:`DeleteOp` entries. Consumers that do not understand entity
242 provenance can safely ignore them.
243
244 ``entity_id``
245 Stable domain-specific identifier for the entity (e.g. a UUID assigned
246 at the note's first insertion).
247 ``origin_op_id``
248 The ``op_id`` of the op that first created this entity.
249 ``last_modified_op_id``
250 The ``op_id`` of the most recent op that touched this entity.
251 ``created_at_commit``
252 Short-form commit ID where this entity was first introduced.
253 ``actor_id``
254 The agent or human identity that performed this op.
255 """
256
257 entity_id: str
258 origin_op_id: str
259 last_modified_op_id: str
260 created_at_commit: str
261 actor_id: str
262
263
264 #: The five non-recursive (leaf) operation types.
265 LeafDomainOp = InsertOp | DeleteOp | MoveOp | ReplaceOp | MutateOp
266
267
268 class PatchOp(TypedDict):
269 """A container element was internally modified.
270
271 ``address`` names the container (e.g. a file path). ``child_ops`` lists
272 the sub-element changes inside that container. These are always
273 leaf ops in the current implementation; true recursion via a nested
274 ``StructuredDelta`` is reserved for a future release.
275
276 ``child_domain`` identifies the sub-element domain (e.g. ``"midi_notes"``
277 for note-level ops inside a ``.mid`` file). ``child_summary`` is a
278 human-readable description of the child changes for ``muse show``.
279 """
280
281 op: Literal["patch"]
282 address: DomainAddress
283 child_ops: list[DomainOp]
284 child_domain: str
285 child_summary: str
286
287
288 #: Union of all operation types — the atoms of a ``StructuredDelta``.
289 type DomainOp = LeafDomainOp | PatchOp
290
291
292 SemVerBump = Literal["major", "minor", "patch", "none"]
293 """Semantic version impact of a delta.
294
295 ``major`` Breaking change: public symbol deleted, renamed, or signature changed.
296 ``minor`` Additive: new public symbol inserted.
297 ``patch`` Implementation-only change: body changed, signature stable.
298 ``none`` No semantic change (formatting, whitespace, metadata only).
299 """
300
301 class StructuredDelta(TypedDict, total=False):
302 """Rich, composable delta between two domain snapshots.
303
304 ``ops`` is an ordered list of operations that transforms ``base`` into
305 ``target`` when applied in sequence. The core engine stores this alongside
306 commit records so that ``muse show`` and ``muse diff`` can display it
307 without reloading full blobs.
308
309 ``summary`` is a precomputed human-readable string — for example
310 ``"3 notes added, 1 note removed"``. Plugins compute it because only they
311 understand their domain semantics.
312
313 ``sem_ver_bump`` (v2, optional) is the semantic version impact of this
314 delta, computed by :func:`infer_sem_ver_bump`. Absent for legacy records
315 or non-code domains that do not compute it.
316
317 ``breaking_changes`` (v2, optional) lists the symbol addresses whose
318 public interface was removed or incompatibly changed.
319 """
320
321 domain: str
322 ops: list[DomainOp]
323 summary: str
324 sem_ver_bump: SemVerBump
325 breaking_changes: list[str]
326
327
328 # ---------------------------------------------------------------------------
329 # SemVer inference helper
330 # ---------------------------------------------------------------------------
331
332
333 def infer_sem_ver_bump(delta: "StructuredDelta") -> tuple[SemVerBump, list[str]]:
334 """Infer the semantic version bump and breaking-change list from a delta.
335
336 Reads the ``ops`` list and applies the following rules:
337
338 * Any public symbol (name not starting with ``_``) that is deleted or
339 renamed → **major** (breaking: callers will fail).
340 * Any public symbol whose ``signature_id`` changed (signature_only or
341 full_rewrite with new signature) → **major** (breaking: call-site
342 compatibility broken).
343 * Any public symbol inserted → **minor** (additive).
344 * Any symbol whose only change is the body (``impl_only``) → **patch**.
345 * No semantic ops → **none**.
346
347 Returns:
348 A ``(bump, breaking_changes)`` tuple where ``breaking_changes`` is a
349 sorted list of symbol addresses whose public contract changed.
350
351 This function is domain-agnostic; it relies on the op address format used
352 by code plugins (``<file>::<symbol>``) and the ``new_summary`` / ``old_summary``
353 conventions from :func:`~muse.plugins.code.symbol_diff.diff_symbol_trees`.
354 For non-code domains the heuristics may not apply — plugins should override
355 by setting ``sem_ver_bump`` directly when constructing the delta.
356 """
357 ops = delta.get("ops", [])
358 bump: SemVerBump = "none"
359 breaking: list[str] = []
360
361 def _is_public(address: str) -> bool:
362 """Return True if the innermost symbol name does not start with ``_``."""
363 parts = address.split("::")
364 name = parts[-1].split(".")[-1] if parts else ""
365 return not name.startswith("_")
366
367 def _promote(current: SemVerBump, candidate: SemVerBump) -> SemVerBump:
368 order: list[SemVerBump] = ["none", "patch", "minor", "major"]
369 return candidate if order.index(candidate) > order.index(current) else current
370
371 for op in ops:
372 op_type = op.get("op", "")
373 address = str(op.get("address", ""))
374
375 if op_type == "patch":
376 # Recurse into child_ops. We know op is a PatchOp here.
377 if op["op"] == "patch":
378 child_ops_raw: list[DomainOp] = op["child_ops"]
379 sub_delta: StructuredDelta = {"domain": "", "ops": child_ops_raw, "summary": ""}
380 sub_bump, sub_breaking = infer_sem_ver_bump(sub_delta)
381 bump = _promote(bump, sub_bump)
382 breaking.extend(sub_breaking)
383 continue
384
385 if not _is_public(address):
386 continue
387
388 if op_type == "delete":
389 bump = _promote(bump, "major")
390 breaking.append(address)
391
392 elif op_type == "insert":
393 bump = _promote(bump, "minor")
394
395 elif op_type == "replace":
396 new_summary: str = str(op.get("new_summary", ""))
397 old_summary: str = str(op.get("old_summary", ""))
398 if (
399 new_summary.startswith("renamed to ")
400 or "signature" in new_summary
401 or "signature" in old_summary
402 ):
403 bump = _promote(bump, "major")
404 breaking.append(address)
405 elif "implementation" in new_summary or "implementation" in old_summary:
406 bump = _promote(bump, "patch")
407 else:
408 bump = _promote(bump, "major")
409 breaking.append(address)
410
411 return bump, sorted(set(breaking))
412
413
414 # ---------------------------------------------------------------------------
415 # Type aliases used in the protocol signatures
416 # ---------------------------------------------------------------------------
417
418 #: Live state is either an already-snapshotted manifest dict or a workdir path.
419 #: The MIDI plugin accepts both: a Path (for CLI commit/status) and a
420 #: SnapshotManifest dict (for in-memory merge and diff operations).
421 type LiveState = SnapshotManifest | pathlib.Path
422
423 #: A content-addressed, immutable snapshot of state at a point in time.
424 type StateSnapshot = SnapshotManifest
425
426 #: The minimal change between two snapshots — a list of typed domain operations.
427 type StateDelta = StructuredDelta
428
429
430 # ---------------------------------------------------------------------------
431 # Merge and drift result types
432 # ---------------------------------------------------------------------------
433
434
435 @dataclass
436 class ConflictRecord:
437 """Structured conflict record in a merge result (v2 taxonomy).
438
439 ``path`` The workspace-relative file path in conflict.
440 ``conflict_type`` One of: ``symbol_edit_overlap``, ``rename_edit``,
441 ``move_edit``, ``delete_use``, ``dependency_conflict``,
442 ``file_level`` (legacy, no symbol info).
443 ``ours_summary`` Short description of ours-side change.
444 ``theirs_summary`` Short description of theirs-side change.
445 ``addresses`` Symbol addresses involved (empty for file-level).
446 """
447
448 path: str
449 conflict_type: str = "file_level"
450 ours_summary: str = ""
451 theirs_summary: str = ""
452 addresses: list[str] = field(default_factory=list)
453
454 def to_dict(self) -> dict[str, str | list[str]]:
455 return {
456 "path": self.path,
457 "conflict_type": self.conflict_type,
458 "ours_summary": self.ours_summary,
459 "theirs_summary": self.theirs_summary,
460 "addresses": self.addresses,
461 }
462
463
464 @dataclass
465 class MergeResult:
466 """Outcome of a three-way merge between two divergent state lines.
467
468 ``merged`` is the reconciled snapshot. ``conflicts`` is a list of
469 workspace-relative file paths that could not be auto-merged and require
470 manual resolution. An empty ``conflicts`` list means the merge was clean.
471 The CLI is responsible for formatting user-facing messages from these paths.
472
473 ``applied_strategies`` maps each path where a ``.museattributes`` rule
474 overrode the default conflict behaviour to the strategy that was applied.
475
476 ``dimension_reports`` maps conflicting paths to their per-dimension
477 resolution detail.
478
479 ``op_log`` is the ordered list of ``DomainOp`` entries applied to produce
480 the merged snapshot. Empty for file-level merges; populated by plugins
481 that implement operation-level OT merge.
482
483 ``conflict_records`` (v2) provides structured conflict metadata with a
484 semantic taxonomy per conflicting path. Populated by plugins that
485 implement :class:`StructuredMergePlugin`. May be empty even when
486 ``conflicts`` is non-empty (legacy file-level conflict).
487 """
488
489 merged: StateSnapshot
490 conflicts: list[str] = field(default_factory=list)
491 applied_strategies: dict[str, str] = field(default_factory=dict)
492 dimension_reports: dict[str, dict[str, str]] = field(default_factory=dict)
493 op_log: list[DomainOp] = field(default_factory=list)
494 conflict_records: list[ConflictRecord] = field(default_factory=list)
495
496 @property
497 def is_clean(self) -> bool:
498 """``True`` when no unresolvable conflicts remain."""
499 return len(self.conflicts) == 0
500
501
502 @dataclass
503 class DriftReport:
504 """Gap between committed state and current live state.
505
506 ``has_drift`` is ``True`` when the live state differs from the committed
507 snapshot. ``summary`` is a human-readable description of what changed.
508 ``delta`` is the machine-readable structured delta for programmatic consumers.
509 """
510
511 has_drift: bool
512 summary: str = ""
513 delta: StateDelta = field(default_factory=lambda: StructuredDelta(
514 domain="", ops=[], summary="working tree clean",
515 ))
516
517
518 # ---------------------------------------------------------------------------
519 # The plugin protocol
520 # ---------------------------------------------------------------------------
521
522
523 @runtime_checkable
524 class MuseDomainPlugin(Protocol):
525 """The six interfaces a domain plugin must implement.
526
527 Muse provides everything else: the DAG, branching, checkout, lineage
528 walking, ASCII log graph, and merge base finder. Implement these six
529 methods and your domain gets the full Muse VCS for free.
530
531 Music is the reference implementation (``muse.plugins.midi``).
532 """
533
534 def snapshot(self, live_state: LiveState) -> StateSnapshot:
535 """Capture current live state as a serialisable, hashable snapshot.
536
537 The returned ``SnapshotManifest`` must be JSON-serialisable. Muse will
538 compute a SHA-256 content address from the canonical JSON form and
539 store the snapshot as a blob in ``.muse/objects/``.
540
541 **``.museignore`` contract** — when *live_state* is a
542 ``pathlib.Path`` (the ``state/`` directory), domain plugin
543 implementations **must** honour ``.museignore`` by calling
544 :func:`muse.core.ignore.load_ignore_config` on the repository root,
545 then :func:`muse.core.ignore.resolve_patterns` with the active domain
546 name, and finally filtering paths with :func:`muse.core.ignore.is_ignored`.
547 Domain-specific patterns (``[domain.<name>]`` sections) are applied
548 only when the active domain matches.
549 """
550 ...
551
552 def diff(
553 self,
554 base: StateSnapshot,
555 target: StateSnapshot,
556 *,
557 repo_root: pathlib.Path | None = None,
558 ) -> StateDelta:
559 """Compute the structured delta between two snapshots.
560
561 Returns a ``StructuredDelta`` where ``ops`` is a minimal list of
562 typed operations that transforms ``base`` into ``target``. Plugins
563 should:
564
565 1. Compute ops at the finest granularity they can interpret.
566 2. Assign meaningful ``content_summary`` strings to each op.
567 3. When ``repo_root`` is provided, load sub-file content from the
568 object store and produce ``PatchOp`` entries with note/element-level
569 ``child_ops`` instead of coarse ``ReplaceOp`` entries.
570 4. Compute a human-readable ``summary`` across all ops.
571
572 The core engine stores this delta alongside the commit record so that
573 ``muse show`` and ``muse diff`` can display it without reloading blobs.
574 """
575 ...
576
577 def merge(
578 self,
579 base: StateSnapshot,
580 left: StateSnapshot,
581 right: StateSnapshot,
582 *,
583 repo_root: pathlib.Path | None = None,
584 ) -> MergeResult:
585 """Three-way merge two divergent state lines against a common base.
586
587 ``base`` is the common ancestor (merge base). ``left`` and ``right``
588 are the two divergent snapshots. Returns a ``MergeResult`` with the
589 reconciled snapshot and any unresolvable conflicts.
590
591 **``.museattributes`` and multidimensional merge contract** — when
592 *repo_root* is provided, domain plugin implementations should:
593
594 1. Load ``.museattributes`` via
595 :func:`muse.core.attributes.load_attributes`.
596 2. For each conflicting path, call
597 :func:`muse.core.attributes.resolve_strategy` with the relevant
598 dimension name (or ``"*"`` for file-level resolution).
599 3. Apply the returned strategy:
600
601 - ``"ours"`` — take the *left* version; remove from conflict list.
602 - ``"theirs"`` — take the *right* version; remove from conflict list.
603 - ``"manual"`` — force into conflict list even if the engine would
604 auto-resolve.
605 - ``"auto"`` / ``"union"`` — defer to the engine's default logic.
606
607 4. For domain formats that support true multidimensional content (e.g.
608 MIDI: notes, pitch_bend, cc_volume, track_structure), attempt
609 sub-file dimension merge before falling back to a file-level conflict.
610 """
611 ...
612
613 def drift(
614 self,
615 committed: StateSnapshot,
616 live: LiveState,
617 ) -> DriftReport:
618 """Compare committed state against current live state.
619
620 Used by ``muse status`` to detect uncommitted changes. Returns a
621 ``DriftReport`` describing whether the live state has diverged from
622 the last committed snapshot and, if so, by how much.
623 """
624 ...
625
626 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
627 """Apply a delta to produce a new live state.
628
629 Used by ``muse checkout`` to reconstruct a historical state. Applies
630 ``delta`` on top of ``live_state`` and returns the resulting state.
631
632 For ``InsertOp`` and ``ReplaceOp``, the new content is identified by
633 ``content_id`` (a SHA-256 hash). When ``live_state`` is a
634 ``pathlib.Path``, the plugin reads the content from the object store.
635 When ``live_state`` is a ``SnapshotManifest``, only ``DeleteOp`` and
636 ``ReplaceOp`` at the file level can be applied in-memory.
637 """
638 ...
639
640 def schema(self) -> DomainSchema:
641 """Declare the structural schema of this domain's state.
642
643 The core engine calls this once at plugin registration time. Plugins
644 must return a stable, deterministic :class:`~muse.core.schema.DomainSchema`
645 describing:
646
647 - ``top_level`` — the primary collection structure (e.g. a set of
648 files, a map of chromosome names to sequences).
649 - ``dimensions`` — the semantic sub-dimensions of state (e.g. notes, pitch_bend, cc_volume, track_structure for MIDI).
650 - ``merge_mode`` — ``"three_way"`` (OT merge) or ``"crdt"`` (CRDT convergent join).
651
652 The schema drives :func:`~muse.core.diff_algorithms.diff_by_schema`
653 algorithm selection and the OT merge engine's conflict detection.
654
655 See :mod:`muse.core.schema` for all available element schema types.
656 """
657 ...
658
659
660 # ---------------------------------------------------------------------------
661 # Operational Transformation optional extension — structured (operation-level) merge
662 # ---------------------------------------------------------------------------
663
664
665 @runtime_checkable
666 class StructuredMergePlugin(MuseDomainPlugin, Protocol):
667 """Optional extension for plugins that support operation-level merging.
668
669 Plugins that implement this sub-protocol gain sub-file auto-merge: two
670 agents inserting notes at non-overlapping bars never produce a conflict,
671 because the merge engine reasons over ``DomainOp`` trees rather than file
672 paths.
673
674 The merge engine detects support at runtime via::
675
676 isinstance(plugin, StructuredMergePlugin)
677
678 Plugins that do not implement ``merge_ops`` fall back to the existing
679 file-level ``merge()`` path automatically — no changes required.
680
681 The :class:`~muse.plugins.midi.plugin.MidiPlugin` is the reference
682 implementation for OT-based merge.
683 """
684
685 def merge_ops(
686 self,
687 base: StateSnapshot,
688 ours_snap: StateSnapshot,
689 theirs_snap: StateSnapshot,
690 ours_ops: list[DomainOp],
691 theirs_ops: list[DomainOp],
692 *,
693 repo_root: pathlib.Path | None = None,
694 ) -> MergeResult:
695 """Merge two op lists against a common base using domain knowledge.
696
697 The core merge engine calls this when both branches have produced
698 ``StructuredDelta`` from ``diff()``. The plugin:
699
700 1. Calls :func:`muse.core.op_transform.merge_op_lists` to detect
701 conflicting ``DomainOp`` pairs.
702 2. For clean pairs, builds the merged ``SnapshotManifest`` by applying
703 the adjusted merged ops to *base*. The plugin uses *ours_snap* and
704 *theirs_snap* to look up the final content IDs for files touched only
705 by one side (necessary for ``PatchOp`` entries, which do not carry a
706 ``new_content_id`` directly).
707 3. For conflicting pairs, consults ``.museattributes`` (when
708 *repo_root* is provided) and either auto-resolves via the declared
709 strategy or adds the address to ``MergeResult.conflicts``.
710
711 Implementations must be domain-aware: a ``.museattributes`` rule of
712 ``merge=ours`` should take this plugin's understanding of "ours" (the
713 left branch content), not a raw file-level copy.
714
715 Args:
716 base: Common ancestor snapshot.
717 ours_snap: Final snapshot of our branch.
718 theirs_snap: Final snapshot of their branch.
719 ours_ops: Operations from our branch delta (base → ours).
720 theirs_ops: Operations from their branch delta (base → theirs).
721 repo_root: Repository root for ``.museattributes`` lookup.
722
723 Returns:
724 A :class:`MergeResult` with the reconciled snapshot and any
725 remaining unresolvable conflicts.
726 """
727 ...
728
729
730 # ---------------------------------------------------------------------------
731 # CRDT convergent merge — snapshot manifest and CRDTPlugin protocol
732 # ---------------------------------------------------------------------------
733
734
735 class CRDTSnapshotManifest(TypedDict):
736 """Extended snapshot manifest for CRDT-mode plugins.
737
738 Carries all the fields of a standard snapshot manifest plus CRDT-specific
739 metadata. The ``files`` mapping has the same semantics as
740 :class:`SnapshotManifest` — path → content hash. The additional fields
741 persist CRDT state between commits.
742
743 ``vclock`` records the causal state of the snapshot as a vector clock
744 ``{agent_id: event_count}``. It is used to detect concurrent writes and
745 to resolve LWW tiebreaks when two agents write at the same logical time.
746
747 ``crdt_state`` maps per-file-path CRDT state blobs to their SHA-256 hashes
748 in the object store. CRDT metadata (tombstones, RGA element IDs, OR-Set
749 tokens) lives here, separate from content hashes, so the content-addressed
750 store remains valid.
751
752 ``schema_version`` is always ``1``.
753 """
754
755 files: dict[str, str]
756 domain: str
757 vclock: dict[str, int]
758 crdt_state: dict[str, str]
759 schema_version: Literal[1]
760
761
762 @runtime_checkable
763 class CRDTPlugin(MuseDomainPlugin, Protocol):
764 """Optional extension for plugins that want convergent CRDT merge semantics.
765
766 Plugins implementing this protocol replace the three-way ``merge()`` with
767 a mathematical ``join()`` on a lattice. ``join`` always succeeds:
768
769 - **No conflict state ever exists.**
770 - Any two replicas that have received the same set of writes converge to
771 the same state, regardless of delivery order.
772 - Millions of agents can write concurrently without coordination.
773
774 The three lattice laws guaranteed by ``join``:
775
776 1. **Commutativity**: ``join(a, b) == join(b, a)``
777 2. **Associativity**: ``join(join(a, b), c) == join(a, join(b, c))``
778 3. **Idempotency**: ``join(a, a) == a``
779
780 The core engine detects support at runtime via::
781
782 isinstance(plugin, CRDTPlugin)
783
784 and routes to ``join`` when ``DomainSchema.merge_mode == "crdt"``.
785 Plugins that do not implement ``CRDTPlugin`` fall back to the existing
786 three-way ``merge()`` path.
787
788 Implementation checklist for plugin authors
789 -------------------------------------------
790 1. Override ``schema()`` to return a :class:`~muse.core.schema.DomainSchema`
791 with ``merge_mode="crdt"`` and :class:`~muse.core.schema.CRDTDimensionSpec`
792 for each CRDT dimension.
793 2. Implement ``crdt_schema()`` to declare which CRDT primitive maps to each
794 dimension.
795 3. Implement ``join(a, b)`` using the CRDT primitives in
796 :mod:`muse.core.crdts`.
797 4. Implement ``to_crdt_state(snapshot)`` to lift a plain snapshot into
798 CRDT state.
799 5. Implement ``from_crdt_state(crdt)`` to materialise a CRDT state back to
800 a plain snapshot for ``muse show`` and CLI display.
801 """
802
803 def crdt_schema(self) -> list[CRDTDimensionSpec]:
804 """Declare the CRDT type used for each dimension.
805
806 Returns a list of :class:`~muse.core.schema.CRDTDimensionSpec` — one
807 per dimension that uses CRDT semantics. Dimensions not listed here
808 fall back to three-way merge.
809
810 Returns:
811 List of CRDT dimension declarations.
812 """
813 ...
814
815 def join(
816 self,
817 a: CRDTSnapshotManifest,
818 b: CRDTSnapshotManifest,
819 ) -> CRDTSnapshotManifest:
820 """Merge two CRDT snapshots by computing their lattice join.
821
822 This operation is:
823
824 - Commutative: ``join(a, b) == join(b, a)``
825 - Associative: ``join(join(a, b), c) == join(a, join(b, c))``
826 - Idempotent: ``join(a, a) == a``
827
828 These three properties guarantee convergence regardless of message
829 order or delivery count.
830
831 The implementation should use the CRDT primitives in
832 :mod:`muse.core.crdts` (one primitive per declared CRDT dimension),
833 compute the per-dimension joins, then rebuild the ``files`` manifest
834 and ``vclock`` from the results.
835
836 Args:
837 a: First CRDT snapshot manifest.
838 b: Second CRDT snapshot manifest.
839
840 Returns:
841 A new :class:`CRDTSnapshotManifest` that is the join of *a* and *b*.
842 """
843 ...
844
845 def to_crdt_state(self, snapshot: StateSnapshot) -> CRDTSnapshotManifest:
846 """Lift a plain snapshot into CRDT state representation.
847
848 Called when importing a snapshot that was created before this plugin
849 opted into CRDT mode. The implementation should initialise fresh CRDT
850 primitives from the snapshot content, with an empty vector clock.
851
852 Args:
853 snapshot: A plain :class:`StateSnapshot` to lift.
854
855 Returns:
856 A :class:`CRDTSnapshotManifest` with the same content and empty
857 CRDT metadata (zero vector clock, empty ``crdt_state``).
858 """
859 ...
860
861 def from_crdt_state(self, crdt: CRDTSnapshotManifest) -> StateSnapshot:
862 """Materialise a CRDT state back to a plain snapshot.
863
864 Used by ``muse show``, ``muse status``, and CLI commands that need a
865 standard :class:`StateSnapshot` view of a CRDT-mode snapshot.
866
867 Args:
868 crdt: A :class:`CRDTSnapshotManifest` to materialise.
869
870 Returns:
871 A plain :class:`StateSnapshot` with the visible (non-tombstoned)
872 content.
873 """
874 ...