gabriel / muse public
domain.py python
871 lines 33.0 KB
9ee9c39c refactor: rename music→midi domain, strip all 5-dim backward compat Gabriel Cardona <gabriel@tellurstori.com> 5d 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 music 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 from __future__ import annotations
46
47 import pathlib
48 from dataclasses import dataclass, field
49 from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, runtime_checkable
50
51 # Public re-exports so callers can do ``from muse.domain import MutateOp`` etc.
52 __all__ = [
53 "SnapshotManifest",
54 "DomainAddress",
55 "InsertOp",
56 "DeleteOp",
57 "MoveOp",
58 "ReplaceOp",
59 "FieldMutation",
60 "MutateOp",
61 "EntityProvenance",
62 "LeafDomainOp",
63 "PatchOp",
64 "DomainOp",
65 "SemVerBump",
66 "StructuredDelta",
67 "infer_sem_ver_bump",
68 "LiveState",
69 "StateSnapshot",
70 "StateDelta",
71 "ConflictRecord",
72 "MergeResult",
73 "DriftReport",
74 "MuseDomainPlugin",
75 "StructuredMergePlugin",
76 "CRDTSnapshotManifest",
77 "CRDTPlugin",
78 ]
79
80 if TYPE_CHECKING:
81 from muse.core.schema import CRDTDimensionSpec, DomainSchema
82
83
84 # ---------------------------------------------------------------------------
85 # Snapshot types (unchanged from pre-Phase-1)
86 # ---------------------------------------------------------------------------
87
88
89 class SnapshotManifest(TypedDict):
90 """Content-addressed snapshot of domain state.
91
92 ``files`` maps workspace-relative POSIX paths to their SHA-256 content
93 digests. ``domain`` identifies which plugin produced this snapshot.
94 """
95
96 files: dict[str, str]
97 domain: str
98
99
100 # ---------------------------------------------------------------------------
101 # Typed delta algebra
102 # ---------------------------------------------------------------------------
103
104 #: A domain-specific address identifying a location within the state graph.
105 #: For file-level ops this is a workspace-relative POSIX path.
106 #: For sub-file ops this is a domain-specific coordinate (e.g. "note:42").
107 DomainAddress = str
108
109
110 class InsertOp(TypedDict):
111 """An element was inserted into a collection.
112
113 For ordered sequences ``position`` is the integer index at which the
114 element was inserted. For unordered sets ``position`` is ``None``.
115 ``content_id`` is the SHA-256 of the inserted content — either a blob
116 already in the object store (for file-level ops) or a deterministic hash
117 of the element's canonical serialisation (for sub-file ops).
118 """
119
120 op: Literal["insert"]
121 address: DomainAddress
122 position: int | None
123 content_id: str
124 content_summary: str
125
126
127 class DeleteOp(TypedDict):
128 """An element was removed from a collection.
129
130 ``position`` is the integer index that was removed for ordered sequences,
131 or ``None`` for unordered sets. ``content_id`` is the SHA-256 of the
132 deleted content so that the operation can be applied idempotently (already-
133 absent elements can be skipped). ``content_summary`` is the human-readable
134 description of what was removed, for ``muse show``.
135 """
136
137 op: Literal["delete"]
138 address: DomainAddress
139 position: int | None
140 content_id: str
141 content_summary: str
142
143
144 class MoveOp(TypedDict):
145 """An element was repositioned within an ordered sequence.
146
147 ``from_position`` is the source index (in the pre-move sequence) and
148 ``to_position`` is the destination index (in the post-move sequence).
149 Both are mandatory — moves are only meaningful in ordered collections.
150 ``content_id`` identifies the element being moved so that the operation
151 can be validated during replay.
152 """
153
154 op: Literal["move"]
155 address: DomainAddress
156 from_position: int
157 to_position: int
158 content_id: str
159
160
161 class ReplaceOp(TypedDict):
162 """An element's value changed (atomic, leaf-level replacement).
163
164 ``old_content_id`` and ``new_content_id`` are SHA-256 hashes of the
165 before- and after-content. They enable three-way merge engines to detect
166 concurrent conflicting modifications (both changed from the same
167 ``old_content_id`` to different ``new_content_id`` values).
168 ``old_summary`` and ``new_summary`` are human-readable strings for display,
169 analogous to ``content_summary`` on :class:`InsertOp`.
170 ``position`` is the index within the container (``None`` for unordered).
171 """
172
173 op: Literal["replace"]
174 address: DomainAddress
175 position: int | None
176 old_content_id: str
177 new_content_id: str
178 old_summary: str
179 new_summary: str
180
181
182 class FieldMutation(TypedDict):
183 """The string-serialised before/after of a single field in a :class:`MutateOp`.
184
185 Values are always strings so that typed primitives (int, float, bool) can
186 be compared uniformly without carrying domain-specific type information in
187 the generic delta algebra. Plugins format them according to their domain
188 conventions (e.g. ``"80"`` for a MIDI velocity, ``"C4"`` for a pitch name).
189 """
190
191 old: str
192 new: str
193
194
195 class MutateOp(TypedDict):
196 """A named entity's specific fields were updated.
197
198 Unlike :class:`ReplaceOp` — which replaces an entire element atomically —
199 ``MutateOp`` records *which* specific fields of a domain entity changed.
200 This enables mutation tracking for domains that maintain stable entity
201 identity separate from content equality.
202
203 Example: a MIDI note's velocity changed from 80 to 100. Under a pure
204 content-hash model that becomes ``DeleteOp + InsertOp`` (two different
205 content hashes). With ``MutateOp`` and a stable ``entity_id`` the diff
206 reports "velocity 80→100 on entity C4@bar4" — lineage is preserved.
207
208 ``entity_id``
209 Stable identifier for the mutated entity, assigned at first insertion
210 and reused across all subsequent mutations (regardless of content
211 changes).
212 ``fields``
213 Mapping from field name (e.g. ``"velocity"``, ``"start_tick"``) to a
214 :class:`FieldMutation` recording the serialised old and new values.
215 ``old_content_id`` / ``new_content_id``
216 SHA-256 of the full element state before and after the mutation,
217 enabling three-way merge conflict detection identical to
218 :class:`ReplaceOp`.
219 ``position``
220 Index within the containing ordered sequence (``None`` for unordered).
221 """
222
223 op: Literal["mutate"]
224 address: DomainAddress
225 entity_id: str
226 old_content_id: str
227 new_content_id: str
228 fields: dict[str, FieldMutation]
229 old_summary: str
230 new_summary: str
231 position: int | None
232
233
234 class EntityProvenance(TypedDict, total=False):
235 """Causal metadata attached to ops that create or modify tracked entities.
236
237 All fields are optional (``total=False``) because entity tracking is an
238 opt-in capability. Plugins that implement stable entity identity populate
239 these fields when constructing :class:`InsertOp`, :class:`MutateOp`, or
240 :class:`DeleteOp` entries. Consumers that do not understand entity
241 provenance can safely ignore them.
242
243 ``entity_id``
244 Stable domain-specific identifier for the entity (e.g. a UUID assigned
245 at the note's first insertion).
246 ``origin_op_id``
247 The ``op_id`` of the op that first created this entity.
248 ``last_modified_op_id``
249 The ``op_id`` of the most recent op that touched this entity.
250 ``created_at_commit``
251 Short-form commit ID where this entity was first introduced.
252 ``actor_id``
253 The agent or human identity that performed this op.
254 """
255
256 entity_id: str
257 origin_op_id: str
258 last_modified_op_id: str
259 created_at_commit: str
260 actor_id: str
261
262
263 #: The five non-recursive (leaf) operation types.
264 LeafDomainOp = InsertOp | DeleteOp | MoveOp | ReplaceOp | MutateOp
265
266
267 class PatchOp(TypedDict):
268 """A container element was internally modified.
269
270 ``address`` names the container (e.g. a file path). ``child_ops`` lists
271 the sub-element changes inside that container. These are always
272 leaf ops in the current implementation; true recursion via a nested
273 ``StructuredDelta`` is reserved for a future release.
274
275 ``child_domain`` identifies the sub-element domain (e.g. ``"midi_notes"``
276 for note-level ops inside a ``.mid`` file). ``child_summary`` is a
277 human-readable description of the child changes for ``muse show``.
278 """
279
280 op: Literal["patch"]
281 address: DomainAddress
282 child_ops: list[DomainOp]
283 child_domain: str
284 child_summary: str
285
286
287 #: Union of all operation types — the atoms of a ``StructuredDelta``.
288 type DomainOp = LeafDomainOp | PatchOp
289
290
291 SemVerBump = Literal["major", "minor", "patch", "none"]
292 """Semantic version impact of a delta.
293
294 ``major`` Breaking change: public symbol deleted, renamed, or signature changed.
295 ``minor`` Additive: new public symbol inserted.
296 ``patch`` Implementation-only change: body changed, signature stable.
297 ``none`` No semantic change (formatting, whitespace, metadata only).
298 """
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 music 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 ``muse-work/`` directory), domain plugin
543 implementations **must** honour ``.museignore`` by calling
544 :func:`muse.core.ignore.load_patterns` on the repository root and
545 filtering out paths matched by :func:`muse.core.ignore.is_ignored`.
546 """
547 ...
548
549 def diff(
550 self,
551 base: StateSnapshot,
552 target: StateSnapshot,
553 *,
554 repo_root: pathlib.Path | None = None,
555 ) -> StateDelta:
556 """Compute the structured delta between two snapshots.
557
558 Returns a ``StructuredDelta`` where ``ops`` is a minimal list of
559 typed operations that transforms ``base`` into ``target``. Plugins
560 should:
561
562 1. Compute ops at the finest granularity they can interpret.
563 2. Assign meaningful ``content_summary`` strings to each op.
564 3. When ``repo_root`` is provided, load sub-file content from the
565 object store and produce ``PatchOp`` entries with note/element-level
566 ``child_ops`` instead of coarse ``ReplaceOp`` entries.
567 4. Compute a human-readable ``summary`` across all ops.
568
569 The core engine stores this delta alongside the commit record so that
570 ``muse show`` and ``muse diff`` can display it without reloading blobs.
571 """
572 ...
573
574 def merge(
575 self,
576 base: StateSnapshot,
577 left: StateSnapshot,
578 right: StateSnapshot,
579 *,
580 repo_root: pathlib.Path | None = None,
581 ) -> MergeResult:
582 """Three-way merge two divergent state lines against a common base.
583
584 ``base`` is the common ancestor (merge base). ``left`` and ``right``
585 are the two divergent snapshots. Returns a ``MergeResult`` with the
586 reconciled snapshot and any unresolvable conflicts.
587
588 **``.museattributes`` and multidimensional merge contract** — when
589 *repo_root* is provided, domain plugin implementations should:
590
591 1. Load ``.museattributes`` via
592 :func:`muse.core.attributes.load_attributes`.
593 2. For each conflicting path, call
594 :func:`muse.core.attributes.resolve_strategy` with the relevant
595 dimension name (or ``"*"`` for file-level resolution).
596 3. Apply the returned strategy:
597
598 - ``"ours"`` — take the *left* version; remove from conflict list.
599 - ``"theirs"`` — take the *right* version; remove from conflict list.
600 - ``"manual"`` — force into conflict list even if the engine would
601 auto-resolve.
602 - ``"auto"`` / ``"union"`` — defer to the engine's default logic.
603
604 4. For domain formats that support true multidimensional content (e.g.
605 MIDI: notes, pitch_bend, cc_volume, track_structure), attempt
606 sub-file dimension merge before falling back to a file-level conflict.
607 """
608 ...
609
610 def drift(
611 self,
612 committed: StateSnapshot,
613 live: LiveState,
614 ) -> DriftReport:
615 """Compare committed state against current live state.
616
617 Used by ``muse status`` to detect uncommitted changes. Returns a
618 ``DriftReport`` describing whether the live state has diverged from
619 the last committed snapshot and, if so, by how much.
620 """
621 ...
622
623 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
624 """Apply a delta to produce a new live state.
625
626 Used by ``muse checkout`` to reconstruct a historical state. Applies
627 ``delta`` on top of ``live_state`` and returns the resulting state.
628
629 For ``InsertOp`` and ``ReplaceOp``, the new content is identified by
630 ``content_id`` (a SHA-256 hash). When ``live_state`` is a
631 ``pathlib.Path``, the plugin reads the content from the object store.
632 When ``live_state`` is a ``SnapshotManifest``, only ``DeleteOp`` and
633 ``ReplaceOp`` at the file level can be applied in-memory.
634 """
635 ...
636
637 def schema(self) -> DomainSchema:
638 """Declare the structural schema of this domain's state.
639
640 The core engine calls this once at plugin registration time. Plugins
641 must return a stable, deterministic :class:`~muse.core.schema.DomainSchema`
642 describing:
643
644 - ``top_level`` — the primary collection structure (e.g. a set of
645 files, a map of chromosome names to sequences).
646 - ``dimensions`` — the semantic sub-dimensions of state (e.g. notes, pitch_bend, cc_volume, track_structure for MIDI).
647 - ``merge_mode`` — ``"three_way"`` (OT merge) or ``"crdt"`` (CRDT convergent join).
648
649 The schema drives :func:`~muse.core.diff_algorithms.diff_by_schema`
650 algorithm selection and the OT merge engine's conflict detection.
651
652 See :mod:`muse.core.schema` for all available element schema types.
653 """
654 ...
655
656
657 # ---------------------------------------------------------------------------
658 # Operational Transformation optional extension — structured (operation-level) merge
659 # ---------------------------------------------------------------------------
660
661
662 @runtime_checkable
663 class StructuredMergePlugin(MuseDomainPlugin, Protocol):
664 """Optional extension for plugins that support operation-level merging.
665
666 Plugins that implement this sub-protocol gain sub-file auto-merge: two
667 agents inserting notes at non-overlapping bars never produce a conflict,
668 because the merge engine reasons over ``DomainOp`` trees rather than file
669 paths.
670
671 The merge engine detects support at runtime via::
672
673 isinstance(plugin, StructuredMergePlugin)
674
675 Plugins that do not implement ``merge_ops`` fall back to the existing
676 file-level ``merge()`` path automatically — no changes required.
677
678 The :class:`~muse.plugins.midi.plugin.MidiPlugin` is the reference
679 implementation for OT-based merge.
680 """
681
682 def merge_ops(
683 self,
684 base: StateSnapshot,
685 ours_snap: StateSnapshot,
686 theirs_snap: StateSnapshot,
687 ours_ops: list[DomainOp],
688 theirs_ops: list[DomainOp],
689 *,
690 repo_root: pathlib.Path | None = None,
691 ) -> MergeResult:
692 """Merge two op lists against a common base using domain knowledge.
693
694 The core merge engine calls this when both branches have produced
695 ``StructuredDelta`` from ``diff()``. The plugin:
696
697 1. Calls :func:`muse.core.op_transform.merge_op_lists` to detect
698 conflicting ``DomainOp`` pairs.
699 2. For clean pairs, builds the merged ``SnapshotManifest`` by applying
700 the adjusted merged ops to *base*. The plugin uses *ours_snap* and
701 *theirs_snap* to look up the final content IDs for files touched only
702 by one side (necessary for ``PatchOp`` entries, which do not carry a
703 ``new_content_id`` directly).
704 3. For conflicting pairs, consults ``.museattributes`` (when
705 *repo_root* is provided) and either auto-resolves via the declared
706 strategy or adds the address to ``MergeResult.conflicts``.
707
708 Implementations must be domain-aware: a ``.museattributes`` rule of
709 ``merge=ours`` should take this plugin's understanding of "ours" (the
710 left branch content), not a raw file-level copy.
711
712 Args:
713 base: Common ancestor snapshot.
714 ours_snap: Final snapshot of our branch.
715 theirs_snap: Final snapshot of their branch.
716 ours_ops: Operations from our branch delta (base → ours).
717 theirs_ops: Operations from their branch delta (base → theirs).
718 repo_root: Repository root for ``.museattributes`` lookup.
719
720 Returns:
721 A :class:`MergeResult` with the reconciled snapshot and any
722 remaining unresolvable conflicts.
723 """
724 ...
725
726
727 # ---------------------------------------------------------------------------
728 # CRDT convergent merge — snapshot manifest and CRDTPlugin protocol
729 # ---------------------------------------------------------------------------
730
731
732 class CRDTSnapshotManifest(TypedDict):
733 """Extended snapshot manifest for CRDT-mode plugins.
734
735 Carries all the fields of a standard snapshot manifest plus CRDT-specific
736 metadata. The ``files`` mapping has the same semantics as
737 :class:`SnapshotManifest` — path → content hash. The additional fields
738 persist CRDT state between commits.
739
740 ``vclock`` records the causal state of the snapshot as a vector clock
741 ``{agent_id: event_count}``. It is used to detect concurrent writes and
742 to resolve LWW tiebreaks when two agents write at the same logical time.
743
744 ``crdt_state`` maps per-file-path CRDT state blobs to their SHA-256 hashes
745 in the object store. CRDT metadata (tombstones, RGA element IDs, OR-Set
746 tokens) lives here, separate from content hashes, so the content-addressed
747 store remains valid.
748
749 ``schema_version`` is always ``1``.
750 """
751
752 files: dict[str, str]
753 domain: str
754 vclock: dict[str, int]
755 crdt_state: dict[str, str]
756 schema_version: Literal[1]
757
758
759 @runtime_checkable
760 class CRDTPlugin(MuseDomainPlugin, Protocol):
761 """Optional extension for plugins that want convergent CRDT merge semantics.
762
763 Plugins implementing this protocol replace the three-way ``merge()`` with
764 a mathematical ``join()`` on a lattice. ``join`` always succeeds:
765
766 - **No conflict state ever exists.**
767 - Any two replicas that have received the same set of writes converge to
768 the same state, regardless of delivery order.
769 - Millions of agents can write concurrently without coordination.
770
771 The three lattice laws guaranteed by ``join``:
772
773 1. **Commutativity**: ``join(a, b) == join(b, a)``
774 2. **Associativity**: ``join(join(a, b), c) == join(a, join(b, c))``
775 3. **Idempotency**: ``join(a, a) == a``
776
777 The core engine detects support at runtime via::
778
779 isinstance(plugin, CRDTPlugin)
780
781 and routes to ``join`` when ``DomainSchema.merge_mode == "crdt"``.
782 Plugins that do not implement ``CRDTPlugin`` fall back to the existing
783 three-way ``merge()`` path.
784
785 Implementation checklist for plugin authors
786 -------------------------------------------
787 1. Override ``schema()`` to return a :class:`~muse.core.schema.DomainSchema`
788 with ``merge_mode="crdt"`` and :class:`~muse.core.schema.CRDTDimensionSpec`
789 for each CRDT dimension.
790 2. Implement ``crdt_schema()`` to declare which CRDT primitive maps to each
791 dimension.
792 3. Implement ``join(a, b)`` using the CRDT primitives in
793 :mod:`muse.core.crdts`.
794 4. Implement ``to_crdt_state(snapshot)`` to lift a plain snapshot into
795 CRDT state.
796 5. Implement ``from_crdt_state(crdt)`` to materialise a CRDT state back to
797 a plain snapshot for ``muse show`` and CLI display.
798 """
799
800 def crdt_schema(self) -> list[CRDTDimensionSpec]:
801 """Declare the CRDT type used for each dimension.
802
803 Returns a list of :class:`~muse.core.schema.CRDTDimensionSpec` — one
804 per dimension that uses CRDT semantics. Dimensions not listed here
805 fall back to three-way merge.
806
807 Returns:
808 List of CRDT dimension declarations.
809 """
810 ...
811
812 def join(
813 self,
814 a: CRDTSnapshotManifest,
815 b: CRDTSnapshotManifest,
816 ) -> CRDTSnapshotManifest:
817 """Merge two CRDT snapshots by computing their lattice join.
818
819 This operation is:
820
821 - Commutative: ``join(a, b) == join(b, a)``
822 - Associative: ``join(join(a, b), c) == join(a, join(b, c))``
823 - Idempotent: ``join(a, a) == a``
824
825 These three properties guarantee convergence regardless of message
826 order or delivery count.
827
828 The implementation should use the CRDT primitives in
829 :mod:`muse.core.crdts` (one primitive per declared CRDT dimension),
830 compute the per-dimension joins, then rebuild the ``files`` manifest
831 and ``vclock`` from the results.
832
833 Args:
834 a: First CRDT snapshot manifest.
835 b: Second CRDT snapshot manifest.
836
837 Returns:
838 A new :class:`CRDTSnapshotManifest` that is the join of *a* and *b*.
839 """
840 ...
841
842 def to_crdt_state(self, snapshot: StateSnapshot) -> CRDTSnapshotManifest:
843 """Lift a plain snapshot into CRDT state representation.
844
845 Called when importing a snapshot that was created before this plugin
846 opted into CRDT mode. The implementation should initialise fresh CRDT
847 primitives from the snapshot content, with an empty vector clock.
848
849 Args:
850 snapshot: A plain :class:`StateSnapshot` to lift.
851
852 Returns:
853 A :class:`CRDTSnapshotManifest` with the same content and empty
854 CRDT metadata (zero vector clock, empty ``crdt_state``).
855 """
856 ...
857
858 def from_crdt_state(self, crdt: CRDTSnapshotManifest) -> StateSnapshot:
859 """Materialise a CRDT state back to a plain snapshot.
860
861 Used by ``muse show``, ``muse status``, and CLI commands that need a
862 standard :class:`StateSnapshot` view of a CRDT-mode snapshot.
863
864 Args:
865 crdt: A :class:`CRDTSnapshotManifest` to materialise.
866
867 Returns:
868 A plain :class:`StateSnapshot` with the visible (non-tombstoned)
869 content.
870 """
871 ...