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