domain.py python
1,062 lines 39.3 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days 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, NotRequired, Protocol, TypedDict, runtime_checkable
51
52 from muse.core._types import Manifest, Metadata
53
54 if TYPE_CHECKING:
55 from muse.core.merge_engine import VectorClock, CRDTState
56
57 # Local type aliases
58 type FieldMutationMap = dict[str, "FieldMutation"]
59 type StageIndex = dict[str, "StagedEntry"]
60 type DimensionReports = dict[str, Metadata]
61
62
63 class ConflictDict(TypedDict):
64 """Serialised form of a :class:`ConflictRecord`."""
65 path: str
66 conflict_type: str
67 ours_summary: str
68 theirs_summary: str
69 addresses: list[str]
70
71 # Public re-exports so callers can do ``from muse.domain import MutateOp`` etc.
72 __all__ = [
73 "SnapshotManifest",
74 "DomainAddress",
75 "InsertOp",
76 "DeleteOp",
77 "MoveOp",
78 "ReplaceOp",
79 "FieldMutation",
80 "MutateOp",
81 "EntityProvenance",
82 "LeafDomainOp",
83 "PatchOp",
84 "DirectoryRenameOp",
85 "DomainOp",
86 "SemVerBump",
87 "StructuredDelta",
88 "LiveState",
89 "StateSnapshot",
90 "StateDelta",
91 "ConflictRecord",
92 "MergeResult",
93 "DriftReport",
94 "MuseDomainPlugin",
95 "StructuredMergePlugin",
96 "CRDTSnapshotManifest",
97 "CRDTPlugin",
98 "RererePlugin",
99 "StagedEntry",
100 "StageStatus",
101 "StagePlugin",
102 ]
103
104 if TYPE_CHECKING:
105 from muse.core.schema import CRDTDimensionSpec, DomainSchema
106
107
108 # ---------------------------------------------------------------------------
109 # Snapshot types (unchanged from pre-Phase-1)
110 # ---------------------------------------------------------------------------
111
112
113 class SnapshotManifest(TypedDict):
114 """Content-addressed snapshot of domain state.
115
116 ``files`` maps workspace-relative POSIX paths to their SHA-256 content
117 digests. ``domain`` identifies which plugin produced this snapshot.
118 ``directories`` is a sorted list of workspace-relative POSIX directory
119 paths that are explicitly tracked in this snapshot. Empty directories
120 that should persist across commits must contain a ``.musekeep`` marker
121 file; directories that contain at least one tracked file are tracked
122 automatically.
123 """
124
125 files: Manifest
126 domain: str
127 directories: list[str]
128
129
130 # ---------------------------------------------------------------------------
131 # Typed delta algebra
132 # ---------------------------------------------------------------------------
133
134 #: A domain-specific address identifying a location within the state graph.
135 #: For file-level ops this is a workspace-relative POSIX path.
136 #: For sub-file ops this is a domain-specific coordinate (e.g. "note:42").
137 DomainAddress = str
138
139
140 class InsertOp(TypedDict):
141 """An element was inserted into a collection.
142
143 For ordered sequences ``position`` is the integer index at which the
144 element was inserted. For unordered sets ``position`` is ``None``.
145 ``content_id`` is the SHA-256 of the inserted content — either a blob
146 already in the object store (for file-level ops) or a deterministic hash
147 of the element's canonical serialisation (for sub-file ops).
148 """
149
150 op: Literal["insert"]
151 address: DomainAddress
152 position: int | None
153 content_id: str
154 content_summary: str
155
156
157 class DeleteOp(TypedDict):
158 """An element was removed from a collection.
159
160 ``position`` is the integer index that was removed for ordered sequences,
161 or ``None`` for unordered sets. ``content_id`` is the SHA-256 of the
162 deleted content so that the operation can be applied idempotently (already-
163 absent elements can be skipped). ``content_summary`` is the human-readable
164 description of what was removed, for ``muse show``.
165 """
166
167 op: Literal["delete"]
168 address: DomainAddress
169 position: int | None
170 content_id: str
171 content_summary: str
172
173
174 class MoveOp(TypedDict):
175 """An element was repositioned within an ordered sequence.
176
177 ``from_position`` is the source index (in the pre-move sequence) and
178 ``to_position`` is the destination index (in the post-move sequence).
179 Both are mandatory — moves are only meaningful in ordered collections.
180 ``content_id`` identifies the element being moved so that the operation
181 can be validated during replay.
182 """
183
184 op: Literal["move"]
185 address: DomainAddress
186 from_position: int
187 to_position: int
188 content_id: str
189
190
191 class ReplaceOp(TypedDict):
192 """An element's value changed (atomic, leaf-level replacement).
193
194 ``old_content_id`` and ``new_content_id`` are SHA-256 hashes of the
195 before- and after-content. They enable three-way merge engines to detect
196 concurrent conflicting modifications (both changed from the same
197 ``old_content_id`` to different ``new_content_id`` values).
198 ``old_summary`` and ``new_summary`` are human-readable strings for display,
199 analogous to ``content_summary`` on :class:`InsertOp`.
200 ``position`` is the index within the container (``None`` for unordered).
201 """
202
203 op: Literal["replace"]
204 address: DomainAddress
205 position: int | None
206 old_content_id: str
207 new_content_id: str
208 old_summary: str
209 new_summary: str
210
211
212 class FieldMutation(TypedDict):
213 """The string-serialised before/after of a single field in a :class:`MutateOp`.
214
215 Values are always strings so that typed primitives (int, float, bool) can
216 be compared uniformly without carrying domain-specific type information in
217 the generic delta algebra. Plugins format them according to their domain
218 conventions (e.g. ``"80"`` for a MIDI velocity, ``"C4"`` for a pitch name).
219 """
220
221 old: str
222 new: str
223
224
225 class MutateOp(TypedDict):
226 """A named entity's specific fields were updated.
227
228 Unlike :class:`ReplaceOp` — which replaces an entire element atomically —
229 ``MutateOp`` records *which* specific fields of a domain entity changed.
230 This enables mutation tracking for domains that maintain stable entity
231 identity separate from content equality.
232
233 Example: a MIDI note's velocity changed from 80 to 100. Under a pure
234 content-hash model that becomes ``DeleteOp + InsertOp`` (two different
235 content hashes). With ``MutateOp`` and a stable ``entity_id`` the diff
236 reports "velocity 80→100 on entity C4@bar4" — lineage is preserved.
237
238 ``entity_id``
239 Stable identifier for the mutated entity, assigned at first insertion
240 and reused across all subsequent mutations (regardless of content
241 changes).
242 ``fields``
243 Mapping from field name (e.g. ``"velocity"``, ``"start_tick"``) to a
244 :class:`FieldMutation` recording the serialised old and new values.
245 ``old_content_id`` / ``new_content_id``
246 SHA-256 of the full element state before and after the mutation,
247 enabling three-way merge conflict detection identical to
248 :class:`ReplaceOp`.
249 ``position``
250 Index within the containing ordered sequence (``None`` for unordered).
251 """
252
253 op: Literal["mutate"]
254 address: DomainAddress
255 entity_id: str
256 old_content_id: str
257 new_content_id: str
258 fields: FieldMutationMap
259 old_summary: str
260 new_summary: str
261 position: int | None
262
263
264 class EntityProvenance(TypedDict, total=False):
265 """Causal metadata attached to ops that create or modify tracked entities.
266
267 All fields are optional (``total=False``) because entity tracking is an
268 opt-in capability. Plugins that implement stable entity identity populate
269 these fields when constructing :class:`InsertOp`, :class:`MutateOp`, or
270 :class:`DeleteOp` entries. Consumers that do not understand entity
271 provenance can safely ignore them.
272
273 ``entity_id``
274 Stable domain-specific identifier for the entity (e.g. a UUID assigned
275 at the note's first insertion).
276 ``origin_op_id``
277 The ``op_id`` of the op that first created this entity.
278 ``last_modified_op_id``
279 The ``op_id`` of the most recent op that touched this entity.
280 ``created_at_commit``
281 Short-form commit ID where this entity was first introduced.
282 ``actor_id``
283 The agent or human identity that performed this op.
284 """
285
286 entity_id: str
287 origin_op_id: str
288 last_modified_op_id: str
289 created_at_commit: str
290 actor_id: str
291
292
293 #: The five non-recursive (leaf) operation types.
294 LeafDomainOp = InsertOp | DeleteOp | MoveOp | ReplaceOp | MutateOp
295
296
297 class DirectoryRenameOp(TypedDict):
298 """A directory was renamed or moved.
299
300 ``address`` is the new path (canonical address after the rename).
301 ``from_address`` is the old path. ``file_count`` is the number of
302 files under this directory that were relocated as part of the rename —
303 those file-level insert/delete ops are suppressed from the parent delta
304 and represented solely by this single typed event.
305 """
306
307 op: Literal["directory_rename"]
308 address: DomainAddress
309 from_address: DomainAddress
310 file_count: int
311
312
313 class PatchOp(TypedDict):
314 """A container element was internally modified.
315
316 ``address`` names the container (e.g. a file path). ``child_ops`` lists
317 the sub-element changes inside that container. These are always
318 leaf ops in the current implementation; true recursion via a nested
319 ``StructuredDelta`` is reserved for a future release.
320
321 ``child_domain`` identifies the sub-element domain (e.g. ``"midi_notes"``
322 for note-level ops inside a ``.mid`` file). ``child_summary`` is a
323 human-readable description of the child changes for ``muse show``.
324
325 When ``from_address`` is present the container was simultaneously renamed
326 (moved from that path) *and* modified. ``address`` is the new path;
327 ``from_address`` is the old path. The child_ops list contains the
328 semantic diff between the old and new symbol trees.
329 """
330
331 op: Literal["patch"]
332 address: DomainAddress
333 child_ops: list[DomainOp]
334 child_domain: str
335 child_summary: str
336 from_address: NotRequired[DomainAddress]
337
338
339 #: Union of all operation types — the atoms of a ``StructuredDelta``.
340 type DomainOp = LeafDomainOp | PatchOp | DirectoryRenameOp
341
342
343 SemVerBump = Literal["major", "minor", "patch", "none"]
344 """Semantic version impact of a delta.
345
346 ``major`` Breaking change: public symbol deleted, renamed, or signature changed.
347 ``minor`` Additive: new public symbol inserted.
348 ``patch`` Implementation-only change: body changed, signature stable.
349 ``none`` No semantic change (formatting, whitespace, metadata only).
350 """
351
352 class StructuredDelta(TypedDict, total=False):
353 """Rich, composable delta between two domain snapshots.
354
355 ``ops`` is an ordered list of operations that transforms ``base`` into
356 ``target`` when applied in sequence. The core engine stores this alongside
357 commit records so that ``muse show`` and ``muse diff`` can display it
358 without reloading full blobs.
359
360 ``summary`` is a precomputed human-readable string — for example
361 ``"3 notes added, 1 note removed"``. Plugins compute it because only they
362 understand their domain semantics.
363
364 ``sem_ver_bump`` (v2, optional) is the semantic version impact of this
365 delta, computed by :func:`infer_sem_ver_bump`. Absent for legacy records
366 or non-code domains that do not compute it.
367
368 ``breaking_changes`` (v2, optional) lists the symbol addresses whose
369 public interface was removed or incompatibly changed.
370 """
371
372 domain: str
373 ops: list[DomainOp]
374 summary: str
375 sem_ver_bump: SemVerBump
376 breaking_changes: list[str]
377
378
379 # ---------------------------------------------------------------------------
380 # Type aliases used in the protocol signatures
381 # ---------------------------------------------------------------------------
382
383 #: Live state is either an already-snapshotted manifest dict or a workdir path.
384 #: The MIDI plugin accepts both: a Path (for CLI commit/status) and a
385 #: SnapshotManifest dict (for in-memory merge and diff operations).
386 type LiveState = SnapshotManifest | pathlib.Path
387
388 #: A content-addressed, immutable snapshot of state at a point in time.
389 type StateSnapshot = SnapshotManifest
390
391 #: The minimal change between two snapshots — a list of typed domain operations.
392 type StateDelta = StructuredDelta
393
394
395 # ---------------------------------------------------------------------------
396 # Merge and drift result types
397 # ---------------------------------------------------------------------------
398
399
400 @dataclass
401 class ConflictRecord:
402 """Structured conflict record in a merge result (v2 taxonomy).
403
404 ``path`` The workspace-relative file path in conflict.
405 ``conflict_type`` One of: ``symbol_edit_overlap``, ``rename_edit``,
406 ``move_edit``, ``delete_use``, ``dependency_conflict``,
407 ``file_level`` (legacy, no symbol info).
408 ``ours_summary`` Short description of ours-side change.
409 ``theirs_summary`` Short description of theirs-side change.
410 ``addresses`` Symbol addresses involved (empty for file-level).
411 """
412
413 path: str
414 conflict_type: str = "file_level"
415 ours_summary: str = ""
416 theirs_summary: str = ""
417 addresses: list[str] = field(default_factory=list)
418
419 def to_dict(self) -> ConflictDict:
420 return {
421 "path": self.path,
422 "conflict_type": self.conflict_type,
423 "ours_summary": self.ours_summary,
424 "theirs_summary": self.theirs_summary,
425 "addresses": self.addresses,
426 }
427
428
429 @dataclass
430 class MergeResult:
431 """Outcome of a three-way merge between two divergent state lines.
432
433 ``merged`` is the reconciled snapshot. ``conflicts`` is a list of
434 workspace-relative file paths that could not be auto-merged and require
435 manual resolution. An empty ``conflicts`` list means the merge was clean.
436 The CLI is responsible for formatting user-facing messages from these paths.
437
438 ``applied_strategies`` maps each path where a ``.museattributes`` rule
439 overrode the default conflict behaviour to the strategy that was applied.
440
441 ``dimension_reports`` maps conflicting paths to their per-dimension
442 resolution detail.
443
444 ``op_log`` is the ordered list of ``DomainOp`` entries applied to produce
445 the merged snapshot. Empty for file-level merges; populated by plugins
446 that implement operation-level OT merge.
447
448 ``conflict_records`` (v2) provides structured conflict metadata with a
449 semantic taxonomy per conflicting path. Populated by plugins that
450 implement :class:`StructuredMergePlugin`. May be empty even when
451 ``conflicts`` is non-empty (legacy file-level conflict).
452 """
453
454 merged: StateSnapshot
455 conflicts: list[str] = field(default_factory=list)
456 applied_strategies: Metadata = field(default_factory=dict)
457 dimension_reports: DimensionReports = field(default_factory=dict)
458 op_log: list[DomainOp] = field(default_factory=list)
459 conflict_records: list[ConflictRecord] = field(default_factory=list)
460
461 @property
462 def is_clean(self) -> bool:
463 """``True`` when no unresolvable conflicts remain."""
464 return len(self.conflicts) == 0
465
466
467 @dataclass
468 class DriftReport:
469 """Gap between committed state and current live state.
470
471 ``has_drift`` is ``True`` when the live state differs from the committed
472 snapshot. ``summary`` is a human-readable description of what changed.
473 ``delta`` is the machine-readable structured delta for programmatic consumers.
474 """
475
476 has_drift: bool
477 summary: str = ""
478 delta: StateDelta = field(default_factory=lambda: StructuredDelta(
479 domain="", ops=[], summary="working tree clean",
480 ))
481
482
483 # ---------------------------------------------------------------------------
484 # The plugin protocol
485 # ---------------------------------------------------------------------------
486
487
488 @runtime_checkable
489 class MuseDomainPlugin(Protocol):
490 """The six interfaces a domain plugin must implement.
491
492 Muse provides everything else: the DAG, branching, checkout, lineage
493 walking, ASCII log graph, and merge base finder. Implement these six
494 methods and your domain gets the full Muse VCS for free.
495
496 Music is the reference implementation (``muse.plugins.midi``).
497 """
498
499 def snapshot(self, live_state: LiveState) -> StateSnapshot:
500 """Capture current live state as a serialisable, hashable snapshot.
501
502 The returned ``SnapshotManifest`` must be JSON-serialisable. Muse will
503 compute a SHA-256 content address from the canonical JSON form and
504 store the snapshot as a blob in ``.muse/objects/``.
505
506 **``.museignore`` contract** — when *live_state* is a
507 ``pathlib.Path`` (the ``state/`` directory), domain plugin
508 implementations **must** honour ``.museignore`` by calling
509 :func:`muse.core.ignore.load_ignore_config` on the repository root,
510 then :func:`muse.core.ignore.resolve_patterns` with the active domain
511 name, and finally filtering paths with :func:`muse.core.ignore.is_ignored`.
512 Domain-specific patterns (``[domain.<name>]`` sections) are applied
513 only when the active domain matches.
514 """
515 ...
516
517 def diff(
518 self,
519 base: StateSnapshot,
520 target: StateSnapshot,
521 *,
522 repo_root: pathlib.Path | None = None,
523 ) -> StateDelta:
524 """Compute the structured delta between two snapshots.
525
526 Returns a ``StructuredDelta`` where ``ops`` is a minimal list of
527 typed operations that transforms ``base`` into ``target``. Plugins
528 should:
529
530 1. Compute ops at the finest granularity they can interpret.
531 2. Assign meaningful ``content_summary`` strings to each op.
532 3. When ``repo_root`` is provided, load sub-file content from the
533 object store and produce ``PatchOp`` entries with note/element-level
534 ``child_ops`` instead of coarse ``ReplaceOp`` entries.
535 4. Compute a human-readable ``summary`` across all ops.
536
537 The core engine stores this delta alongside the commit record so that
538 ``muse show`` and ``muse diff`` can display it without reloading blobs.
539 """
540 ...
541
542 def merge(
543 self,
544 base: StateSnapshot,
545 left: StateSnapshot,
546 right: StateSnapshot,
547 *,
548 repo_root: pathlib.Path | None = None,
549 ) -> MergeResult:
550 """Three-way merge two divergent state lines against a common base.
551
552 ``base`` is the common ancestor (merge base). ``left`` and ``right``
553 are the two divergent snapshots. Returns a ``MergeResult`` with the
554 reconciled snapshot and any unresolvable conflicts.
555
556 **``.museattributes`` and multidimensional merge contract** — when
557 *repo_root* is provided, domain plugin implementations should:
558
559 1. Load ``.museattributes`` via
560 :func:`muse.core.attributes.load_attributes`.
561 2. For each conflicting path, call
562 :func:`muse.core.attributes.resolve_strategy` with the relevant
563 dimension name (or ``"*"`` for file-level resolution).
564 3. Apply the returned strategy:
565
566 - ``"ours"`` — take the *left* version; remove from conflict list.
567 - ``"theirs"`` — take the *right* version; remove from conflict list.
568 - ``"manual"`` — force into conflict list even if the engine would
569 auto-resolve.
570 - ``"auto"`` / ``"union"`` — defer to the engine's default logic.
571
572 4. For domain formats that support true multidimensional content (e.g.
573 MIDI: notes, pitch_bend, cc_volume, track_structure), attempt
574 sub-file dimension merge before falling back to a file-level conflict.
575 """
576 ...
577
578 def drift(
579 self,
580 committed: StateSnapshot,
581 live: LiveState,
582 ) -> DriftReport:
583 """Compare committed state against current live state.
584
585 Used by ``muse status`` to detect uncommitted changes. Returns a
586 ``DriftReport`` describing whether the live state has diverged from
587 the last committed snapshot and, if so, by how much.
588 """
589 ...
590
591 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
592 """Apply a delta to produce a new live state.
593
594 Used by ``muse checkout`` to reconstruct a historical state. Applies
595 ``delta`` on top of ``live_state`` and returns the resulting state.
596
597 For ``InsertOp`` and ``ReplaceOp``, the new content is identified by
598 ``content_id`` (a SHA-256 hash). When ``live_state`` is a
599 ``pathlib.Path``, the plugin reads the content from the object store.
600 When ``live_state`` is a ``SnapshotManifest``, only ``DeleteOp`` and
601 ``ReplaceOp`` at the file level can be applied in-memory.
602 """
603 ...
604
605 def schema(self) -> DomainSchema:
606 """Declare the structural schema of this domain's state.
607
608 The core engine calls this once at plugin registration time. Plugins
609 must return a stable, deterministic :class:`~muse.core.schema.DomainSchema`
610 describing:
611
612 - ``top_level`` — the primary collection structure (e.g. a set of
613 files, a map of chromosome names to sequences).
614 - ``dimensions`` — the semantic sub-dimensions of state (e.g. notes, pitch_bend, cc_volume, track_structure for MIDI).
615 - ``merge_mode`` — ``"three_way"`` (OT merge) or ``"crdt"`` (CRDT convergent join).
616
617 The schema drives :func:`~muse.core.diff_algorithms.diff_by_schema`
618 algorithm selection and the OT merge engine's conflict detection.
619
620 See :mod:`muse.core.schema` for all available element schema types.
621 """
622 ...
623
624
625 # ---------------------------------------------------------------------------
626 # Operational Transformation optional extension — structured (operation-level) merge
627 # ---------------------------------------------------------------------------
628
629
630 @runtime_checkable
631 class StructuredMergePlugin(MuseDomainPlugin, Protocol):
632 """Optional extension for plugins that support operation-level merging.
633
634 Plugins that implement this sub-protocol gain sub-file auto-merge: two
635 agents inserting notes at non-overlapping bars never produce a conflict,
636 because the merge engine reasons over ``DomainOp`` trees rather than file
637 paths.
638
639 The merge engine detects support at runtime via::
640
641 isinstance(plugin, StructuredMergePlugin)
642
643 Plugins that do not implement ``merge_ops`` fall back to the existing
644 file-level ``merge()`` path automatically — no changes required.
645
646 The :class:`~muse.plugins.midi.plugin.MidiPlugin` is the reference
647 implementation for OT-based merge.
648 """
649
650 def merge_ops(
651 self,
652 base: StateSnapshot,
653 ours_snap: StateSnapshot,
654 theirs_snap: StateSnapshot,
655 ours_ops: list[DomainOp],
656 theirs_ops: list[DomainOp],
657 *,
658 repo_root: pathlib.Path | None = None,
659 ) -> MergeResult:
660 """Merge two op lists against a common base using domain knowledge.
661
662 The core merge engine calls this when both branches have produced
663 ``StructuredDelta`` from ``diff()``. The plugin:
664
665 1. Calls :func:`muse.core.op_transform.merge_op_lists` to detect
666 conflicting ``DomainOp`` pairs.
667 2. For clean pairs, builds the merged ``SnapshotManifest`` by applying
668 the adjusted merged ops to *base*. The plugin uses *ours_snap* and
669 *theirs_snap* to look up the final content IDs for files touched only
670 by one side (necessary for ``PatchOp`` entries, which do not carry a
671 ``new_content_id`` directly).
672 3. For conflicting pairs, consults ``.museattributes`` (when
673 *repo_root* is provided) and either auto-resolves via the declared
674 strategy or adds the address to ``MergeResult.conflicts``.
675
676 Implementations must be domain-aware: a ``.museattributes`` rule of
677 ``merge=ours`` should take this plugin's understanding of "ours" (the
678 left branch content), not a raw file-level copy.
679
680 Args:
681 base: Common ancestor snapshot.
682 ours_snap: Final snapshot of our branch.
683 theirs_snap: Final snapshot of their branch.
684 ours_ops: Operations from our branch delta (base → ours).
685 theirs_ops: Operations from their branch delta (base → theirs).
686 repo_root: Repository root for ``.museattributes`` lookup.
687
688 Returns:
689 A :class:`MergeResult` with the reconciled snapshot and any
690 remaining unresolvable conflicts.
691 """
692 ...
693
694
695 # ---------------------------------------------------------------------------
696 # CRDT convergent merge — snapshot manifest and CRDTPlugin protocol
697 # ---------------------------------------------------------------------------
698
699
700 class CRDTSnapshotManifest(TypedDict):
701 """Extended snapshot manifest for CRDT-mode plugins.
702
703 Carries all the fields of a standard snapshot manifest plus CRDT-specific
704 metadata. The ``files`` mapping has the same semantics as
705 :class:`SnapshotManifest` — path → content hash. The additional fields
706 persist CRDT state between commits.
707
708 ``vclock`` records the causal state of the snapshot as a vector clock
709 ``{agent_id: event_count}``. It is used to detect concurrent writes and
710 to resolve LWW tiebreaks when two agents write at the same logical time.
711
712 ``crdt_state`` maps per-file-path CRDT state blobs to their SHA-256 hashes
713 in the object store. CRDT metadata (tombstones, RGA element IDs, OR-Set
714 tokens) lives here, separate from content hashes, so the content-addressed
715 store remains valid.
716
717 ``schema_version`` is the Muse package version (read from ``muse._version``).
718 """
719
720 files: Manifest
721 domain: str
722 vclock: VectorClock
723 crdt_state: CRDTState
724 schema_version: str
725
726
727 @runtime_checkable
728 class CRDTPlugin(MuseDomainPlugin, Protocol):
729 """Optional extension for plugins that want convergent CRDT merge semantics.
730
731 Plugins implementing this protocol replace the three-way ``merge()`` with
732 a mathematical ``join()`` on a lattice. ``join`` always succeeds:
733
734 - **No conflict state ever exists.**
735 - Any two replicas that have received the same set of writes converge to
736 the same state, regardless of delivery order.
737 - Millions of agents can write concurrently without coordination.
738
739 The three lattice laws guaranteed by ``join``:
740
741 1. **Commutativity**: ``join(a, b) == join(b, a)``
742 2. **Associativity**: ``join(join(a, b), c) == join(a, join(b, c))``
743 3. **Idempotency**: ``join(a, a) == a``
744
745 The core engine detects support at runtime via::
746
747 isinstance(plugin, CRDTPlugin)
748
749 and routes to ``join`` when ``DomainSchema.merge_mode == "crdt"``.
750 Plugins that do not implement ``CRDTPlugin`` fall back to the existing
751 three-way ``merge()`` path.
752
753 Implementation checklist for plugin authors
754 -------------------------------------------
755 1. Override ``schema()`` to return a :class:`~muse.core.schema.DomainSchema`
756 with ``merge_mode="crdt"`` and :class:`~muse.core.schema.CRDTDimensionSpec`
757 for each CRDT dimension.
758 2. Implement ``crdt_schema()`` to declare which CRDT primitive maps to each
759 dimension.
760 3. Implement ``join(a, b)`` using the CRDT primitives in
761 :mod:`muse.core.crdts`.
762 4. Implement ``to_crdt_state(snapshot)`` to lift a plain snapshot into
763 CRDT state.
764 5. Implement ``from_crdt_state(crdt)`` to materialise a CRDT state back to
765 a plain snapshot for ``muse show`` and CLI display.
766 """
767
768 def crdt_schema(self) -> list[CRDTDimensionSpec]:
769 """Declare the CRDT type used for each dimension.
770
771 Returns a list of :class:`~muse.core.schema.CRDTDimensionSpec` — one
772 per dimension that uses CRDT semantics. Dimensions not listed here
773 fall back to three-way merge.
774
775 Returns:
776 List of CRDT dimension declarations.
777 """
778 ...
779
780 def join(
781 self,
782 a: CRDTSnapshotManifest,
783 b: CRDTSnapshotManifest,
784 ) -> CRDTSnapshotManifest:
785 """Merge two CRDT snapshots by computing their lattice join.
786
787 This operation is:
788
789 - Commutative: ``join(a, b) == join(b, a)``
790 - Associative: ``join(join(a, b), c) == join(a, join(b, c))``
791 - Idempotent: ``join(a, a) == a``
792
793 These three properties guarantee convergence regardless of message
794 order or delivery count.
795
796 The implementation should use the CRDT primitives in
797 :mod:`muse.core.crdts` (one primitive per declared CRDT dimension),
798 compute the per-dimension joins, then rebuild the ``files`` manifest
799 and ``vclock`` from the results.
800
801 Args:
802 a: First CRDT snapshot manifest.
803 b: Second CRDT snapshot manifest.
804
805 Returns:
806 A new :class:`CRDTSnapshotManifest` that is the join of *a* and *b*.
807 """
808 ...
809
810 def to_crdt_state(self, snapshot: StateSnapshot) -> CRDTSnapshotManifest:
811 """Lift a plain snapshot into CRDT state representation.
812
813 Called when importing a snapshot that was created before this plugin
814 opted into CRDT mode. The implementation should initialise fresh CRDT
815 primitives from the snapshot content, with an empty vector clock.
816
817 Args:
818 snapshot: A plain :class:`StateSnapshot` to lift.
819
820 Returns:
821 A :class:`CRDTSnapshotManifest` with the same content and empty
822 CRDT metadata (zero vector clock, empty ``crdt_state``).
823 """
824 ...
825
826 def from_crdt_state(self, crdt: CRDTSnapshotManifest) -> StateSnapshot:
827 """Materialise a CRDT state back to a plain snapshot.
828
829 Used by ``muse show``, ``muse status``, and CLI commands that need a
830 standard :class:`StateSnapshot` view of a CRDT-mode snapshot.
831
832 Args:
833 crdt: A :class:`CRDTSnapshotManifest` to materialise.
834
835 Returns:
836 A plain :class:`StateSnapshot` with the visible (non-tombstoned)
837 content.
838 """
839 ...
840
841
842 # ---------------------------------------------------------------------------
843 # Rerere optional extension — domain-aware conflict fingerprinting
844 # ---------------------------------------------------------------------------
845
846
847 @runtime_checkable
848 class RererePlugin(MuseDomainPlugin, Protocol):
849 """Optional extension for plugins that provide domain-aware rerere fingerprinting.
850
851 The default rerere fingerprint is::
852
853 SHA-256( min(ours_id, theirs_id) + ":" + max(ours_id, theirs_id) )
854
855 This is content-addressed and commutative, but it can fail to recognise
856 "the same conflict" when the two conflicting blobs differ by surrounding
857 context (e.g. tempo metadata in MIDI, or import ordering in code).
858
859 Plugins implementing this sub-protocol can return a richer fingerprint that
860 captures only the *semantically meaningful* parts of the conflict — allowing
861 rerere to recognise and replay resolutions across superficially different
862 but semantically identical conflicts.
863
864 The core engine detects support at runtime via::
865
866 isinstance(plugin, RererePlugin)
867
868 Plugins that do not implement this fall back to the default content
869 fingerprint automatically — no changes required to the core rerere engine.
870
871 Example: a MIDI plugin might compute::
872
873 SHA-256( sorted_note_events(ours) + ":" + sorted_note_events(theirs) )
874
875 so that a re-timed MIDI file that otherwise has the same musical conflict
876 matches an existing rerere record.
877 """
878
879 def conflict_fingerprint(
880 self,
881 path: str,
882 ours_id: str,
883 theirs_id: str,
884 repo_root: pathlib.Path,
885 ) -> str:
886 """Return a stable fingerprint identifying this conflict's semantic shape.
887
888 The returned string must be exactly 64 lowercase hex characters (the
889 same format as a SHA-256 digest). If the implementation cannot produce
890 a valid fingerprint (e.g. the blob is not in the local store), it should
891 raise an exception — the caller will fall back to the default fingerprint.
892
893 The result must be:
894
895 - **Deterministic**: the same inputs always produce the same fingerprint.
896 - **Commutative**: ``fingerprint(p, a, b, root) == fingerprint(p, b, a, root)``
897 — the order of ours/theirs must not matter.
898 - **Collision-resistant**: different conflicts should produce different
899 fingerprints with high probability.
900
901 Args:
902 path: Workspace-relative POSIX path of the conflicting file.
903 ours_id: SHA-256 object ID of the "ours" blob.
904 theirs_id: SHA-256 object ID of the "theirs" blob.
905 repo_root: Repository root for loading blob content from the store.
906
907 Returns:
908 64-char lowercase hex fingerprint.
909 """
910 ...
911
912
913 # ---------------------------------------------------------------------------
914 # Staging optional extension — code-domain selective commit index
915 # ---------------------------------------------------------------------------
916
917
918 class StagedEntry(TypedDict):
919 """One file's staging record inside the code-domain stage index.
920
921 ``object_id`` is the SHA-256 of the *staged* version of the file (which
922 may differ from the current working-tree version if the file was modified
923 after staging). ``mode`` records why this file is staged:
924
925 - ``"A"`` — added (new file not present in the previous commit)
926 - ``"M"`` — modified (file exists in the previous commit)
927 - ``"D"`` — deleted (file will be removed from the next commit)
928
929 ``staged_at`` is an ISO-8601 timestamp set when the entry was created.
930 """
931
932 object_id: str
933 mode: Literal["A", "M", "D"]
934 staged_at: str
935
936
937 class StageStatus(TypedDict):
938 """Three-bucket view of working-tree state when a stage is active.
939
940 Returned by :meth:`StagePlugin.stage_status` and consumed by
941 ``muse status`` to render the familiar Git-style staged/unstaged split.
942
943 ``staged``
944 Files whose next-commit version is pinned in the stage index.
945 Maps path → ``{"mode": "A"|"M"|"D"}``.
946
947 ``unstaged``
948 Tracked files with working-tree changes that have *not* been staged.
949 Maps path → human label (``"modified"``, ``"deleted"``).
950
951 ``untracked``
952 Files present on disk but not tracked by the previous commit and not
953 in the stage.
954 """
955
956 staged: StageIndex
957 unstaged: Manifest
958 untracked: list[str]
959
960
961 @runtime_checkable
962 class StagePlugin(MuseDomainPlugin, Protocol):
963 """Optional extension for plugins that support selective commit staging.
964
965 Implementing this sub-protocol adds a Git-like index layer to a domain:
966 ``muse code add`` stages specific files, and ``muse commit`` commits only
967 what is staged, leaving unstaged changes invisible to the commit.
968
969 The core engine detects support at runtime via::
970
971 isinstance(plugin, StagePlugin)
972
973 Plugins that do not implement ``StagePlugin`` commit the full working
974 tree on every ``muse commit`` (the original Muse behaviour).
975
976 The stage index persists at ``.muse/code/stage.json`` (the path is
977 determined by :meth:`stage_index_path`). Its format is defined by
978 :class:`~muse.plugins.code.stage.StageIndex`.
979 """
980
981 def stage_index_path(self, root: pathlib.Path) -> pathlib.Path:
982 """Return the absolute path of the stage index file.
983
984 Conventionally ``.muse/<domain>/stage.json``. Exposing this on the
985 protocol lets ``muse status`` and ``muse commit`` locate the index
986 without hard-coding a domain-specific path.
987
988 Args:
989 root: Repository root directory (contains ``.muse/``).
990
991 Returns:
992 Absolute :class:`pathlib.Path` of the stage index file.
993 """
994 ...
995
996 def read_stage(self, root: pathlib.Path) -> StageIndex:
997 """Read the stage index, returning an empty dict when no stage exists.
998
999 Args:
1000 root: Repository root directory.
1001
1002 Returns:
1003 Mapping of workspace-relative POSIX path → :class:`StagedEntry`.
1004 """
1005 ...
1006
1007 def write_stage(
1008 self, root: pathlib.Path, entries: StageIndex
1009 ) -> None:
1010 """Persist *entries* as the stage index.
1011
1012 Passing an empty dict clears the stage (removes the index file).
1013
1014 Args:
1015 root: Repository root directory.
1016 entries: New stage contents.
1017 """
1018 ...
1019
1020 def clear_stage(self, root: pathlib.Path) -> None:
1021 """Remove the stage index, resetting to full-snapshot mode.
1022
1023 Args:
1024 root: Repository root directory.
1025 """
1026 ...
1027
1028 def stage_status(self, root: pathlib.Path) -> StageStatus:
1029 """Return a three-bucket view of the working tree against the stage.
1030
1031 Compares the current working tree against both the stage index and the
1032 most recent commit to produce three disjoint sets:
1033
1034 1. **staged** — files whose committed version will be the staged blob.
1035 2. **unstaged** — tracked files with working-tree changes not yet staged.
1036 3. **untracked** — files on disk that are not tracked and not staged.
1037
1038 Args:
1039 root: Repository root directory.
1040
1041 Returns:
1042 A :class:`StageStatus` TypedDict with the three buckets.
1043 """
1044 ...
1045
1046 def workdir_snapshot(self, root: pathlib.Path) -> SnapshotManifest:
1047 """Capture the raw working tree, bypassing any active stage.
1048
1049 Unlike :meth:`~MuseDomainPlugin.snapshot`, this method always reflects
1050 the actual on-disk state — staged entries are not substituted. This
1051 is used by ``muse diff --working`` to show what has changed in the
1052 working tree relative to HEAD, including files that have not yet been
1053 staged.
1054
1055 Args:
1056 root: Repository root directory.
1057
1058 Returns:
1059 A :class:`SnapshotManifest` mapping workspace-relative POSIX paths
1060 to their content hashes.
1061 """
1062 ...
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago