plugin.py python
1,258 lines 48.5 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """MIDI domain plugin — reference implementation of :class:`MuseDomainPlugin`.
2
3 This plugin implements the six Muse domain interfaces for MIDI state:
4 notes, velocities, controller events (CC), pitch bends, and aftertouch.
5
6 It is the domain that proved the abstraction. Every other domain — scientific
7 simulation, genomics, 3D spatial design — is a new plugin that implements
8 the same six interfaces.
9
10 Live State
11 ----------
12 For the MIDI domain, ``LiveState`` is either:
13
14 1. A ``pathlib.Path`` pointing to the repository root (the working tree) — the
15 MIDI files live on disk and are managed by ``muse commit / checkout``.
16 2. A dict snapshot previously captured by :meth:`snapshot` — used when
17 constructing merges and diffs in memory.
18
19 Both forms are supported. The plugin detects which form it received by
20 checking for ``pathlib.Path`` vs ``dict``.
21
22 Snapshot Format
23 ---------------
24 A music snapshot is a JSON-serialisable dict:
25
26 .. code-block:: json
27
28 {
29 "files": {
30 "tracks/drums.mid": "<sha256>",
31 "tracks/bass.mid": "<sha256>"
32 },
33 "domain": "midi"
34 }
35
36 The ``files`` key maps POSIX paths (relative to the repository root) to their
37 SHA-256 content digests.
38
39 Delta Format
40 ----------------------
41 ``diff()`` returns a ``StructuredDelta`` with typed ``DomainOp`` entries:
42
43 - ``InsertOp`` — a file was added (``content_id`` = its SHA-256 hash).
44 - ``DeleteOp`` — a file was removed.
45 - ``ReplaceOp`` — a non-MIDI file's content changed.
46 - ``PatchOp`` — a ``.mid`` file changed; ``child_ops`` contains note-level
47 ``InsertOp`` / ``DeleteOp`` entries from the Myers LCS diff.
48
49 When ``repo_root`` is available, MIDI files are loaded from the object store
50 and diffed at note level. Without it, modified ``.mid`` files fall back to
51 ``ReplaceOp``.
52 """
53
54 from __future__ import annotations
55
56 import hashlib
57 import json
58 import logging
59 import os
60 import pathlib
61 import stat as _stat
62
63 from muse._version import __version__
64 from muse.core.schema import (
65 DimensionSpec,
66 DomainSchema,
67 SequenceSchema,
68 SetSchema,
69 TensorSchema,
70 TreeSchema,
71 )
72 from muse.domain import (
73 DeleteOp,
74 DomainOp,
75 DriftReport,
76 InsertOp,
77 LiveState,
78 MergeResult,
79 MuseDomainPlugin,
80 PatchOp,
81 ReplaceOp,
82 SnapshotManifest,
83 StateDelta,
84 StateSnapshot,
85 StructuredDelta,
86 StructuredMergePlugin,
87 )
88 from muse.core.stat_cache import load_cache
89 from muse.core.store import Manifest
90 from muse.plugins.midi.midi_diff import NoteKey
91
92 logger = logging.getLogger(__name__)
93
94 type MidiFileMap = dict[str, str] # file_path → content hash (midi manifest)
95 type AppliedStrategies = dict[str, str] # file_path → strategy name
96 type DimensionReport = dict[str, str] # dimension → report string
97 type DimensionReports = dict[str, dict[str, str]] # file → dimension report
98 type PatchOpMap = dict[str, PatchOp] # address → patch op
99 type NoteIdMap = dict[str, NoteKey] # content_id → note key
100
101 _DOMAIN_TAG = "midi"
102
103
104 class MidiPlugin:
105 """MIDI domain plugin for the Muse VCS.
106
107 Implements :class:`~muse.domain.MuseDomainPlugin` (six core interfaces)
108 and :class:`~muse.domain.StructuredMergePlugin` (operation-level
109 merge) for MIDI state stored as files in the working tree.
110
111 This is the reference implementation. Every other domain plugin implements
112 the same six core interfaces; the :class:`~muse.domain.StructuredMergePlugin`
113 extension is optional but strongly recommended for domains that produce
114 note-level (sub-file) diffs.
115 """
116
117 # ------------------------------------------------------------------
118 # 1. snapshot — capture live state as a content-addressed dict
119 # ------------------------------------------------------------------
120
121 def snapshot(self, live_state: LiveState) -> StateSnapshot:
122 """Capture the current working tree as a snapshot dict.
123
124 Args:
125 live_state: A ``pathlib.Path`` pointing to the repository root (working tree)
126 or an existing snapshot dict (returned as-is).
127
128 Returns:
129 A JSON-serialisable ``{"files": {path: sha256}, "domain": "midi"}``
130 dict. The ``files`` mapping is the canonical snapshot manifest used
131 by the core VCS engine for commit / checkout / diff.
132
133 Ignore rules
134 ------------
135 When *live_state* is a ``pathlib.Path``, the plugin reads
136 ``.museignore`` from the repository root
137 and excludes any matching paths from the snapshot. Dotfiles are always
138 excluded regardless of ``.museignore``.
139 """
140 if isinstance(live_state, pathlib.Path):
141 from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns
142 workdir = live_state
143 patterns = resolve_patterns(load_ignore_config(workdir), _DOMAIN_TAG)
144 cache = load_cache(workdir)
145 files: MidiFileMap = {}
146 root_str = str(workdir)
147 prefix_len = len(root_str) + 1
148
149 for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False):
150 dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
151 for fname in sorted(filenames):
152 if fname.startswith("."):
153 continue
154 abs_str = os.path.join(dirpath, fname)
155 try:
156 st = os.lstat(abs_str)
157 except OSError:
158 continue
159 if not _stat.S_ISREG(st.st_mode):
160 continue
161 rel = abs_str[prefix_len:]
162 if os.sep != "/":
163 rel = rel.replace(os.sep, "/")
164 if is_ignored(rel, patterns):
165 continue
166 files[rel] = cache.get_cached(rel, abs_str, st.st_mtime, st.st_size, st.st_ino)
167
168 cache.prune(set(files))
169 cache.save()
170 return SnapshotManifest(files=files, domain=_DOMAIN_TAG, directories=[])
171
172 return live_state
173
174 # ------------------------------------------------------------------
175 # 2. diff — compute the structured delta between two snapshots
176 # ------------------------------------------------------------------
177
178 def diff(
179 self,
180 base: StateSnapshot,
181 target: StateSnapshot,
182 *,
183 repo_root: pathlib.Path | None = None,
184 ) -> StateDelta:
185 """Compute a ``StructuredDelta`` between two music snapshots.
186
187 File additions and removals produce ``InsertOp`` and ``DeleteOp``
188 entries respectively. For modified files:
189
190 - ``.mid`` files: when ``repo_root`` is provided, load the MIDI bytes
191 from the object store and produce a ``PatchOp`` with note-level
192 ``child_ops`` from the Myers LCS diff. Falls back to ``ReplaceOp``
193 when the object store is unavailable or parsing fails.
194 - All other files: ``ReplaceOp`` with file-level content IDs.
195
196 Args:
197 base: The ancestor snapshot.
198 target: The later snapshot.
199 repo_root: Repository root directory. When provided, MIDI files are
200 loaded from ``.muse/objects/`` for note-level diffing.
201
202 Returns:
203 A ``StructuredDelta`` whose ``ops`` list transforms *base* into
204 *target* and whose ``summary`` is human-readable.
205 """
206 base_files = base["files"]
207 target_files = target["files"]
208
209 base_paths = set(base_files)
210 target_paths = set(target_files)
211
212 ops: list[DomainOp] = []
213
214 # Added files — try symbol extraction; fall back to plain InsertOp.
215 for path in sorted(target_paths - base_paths):
216 patch = _new_file_patch(
217 path=path,
218 content_id=target_files[path],
219 repo_root=repo_root,
220 )
221 if patch is not None:
222 ops.append(patch)
223 else:
224 ops.append(
225 InsertOp(
226 op="insert",
227 address=path,
228 position=None,
229 content_id=target_files[path],
230 content_summary=f"new file: {path}",
231 )
232 )
233
234 # Removed files → DeleteOp
235 for path in sorted(base_paths - target_paths):
236 ops.append(
237 DeleteOp(
238 op="delete",
239 address=path,
240 position=None,
241 content_id=base_files[path],
242 content_summary=f"deleted: {path}",
243 )
244 )
245
246 # Modified files
247 for path in sorted(
248 p for p in base_paths & target_paths if base_files[p] != target_files[p]
249 ):
250 op = _diff_modified_file(
251 path=path,
252 old_hash=base_files[path],
253 new_hash=target_files[path],
254 repo_root=repo_root,
255 )
256 ops.append(op)
257
258 summary = _summarise_ops(ops)
259 return StructuredDelta(domain=_DOMAIN_TAG, ops=ops, summary=summary)
260
261 # ------------------------------------------------------------------
262 # 3. merge — three-way reconciliation
263 # ------------------------------------------------------------------
264
265 def merge(
266 self,
267 base: StateSnapshot,
268 left: StateSnapshot,
269 right: StateSnapshot,
270 *,
271 repo_root: pathlib.Path | None = None,
272 ) -> MergeResult:
273 """Three-way merge two divergent music state lines against a common base.
274
275 A file is auto-merged when only one side changed it. When both sides
276 changed the same file, the merge proceeds in two stages:
277
278 1. **File-level strategy** — if ``.museattributes`` contains an
279 ``ours`` or ``theirs`` rule matching the path (dimension ``"*"``),
280 the rule is applied and the file is removed from the conflict list.
281
282 2. **Dimension-level merge** — for ``.mid`` files that survive the
283 file-level check, the MIDI event stream is split into orthogonal
284 dimension slices (notes/melodic/rhythmic, harmonic, dynamic, structural).
285 Each dimension is merged independently. Dimension-specific
286 ``ours``/``theirs`` rules in ``.museattributes`` are honoured.
287 Only dimensions where *both* sides changed AND no resolvable rule
288 exists cause a true file-level conflict.
289
290 3. **Manual override** — ``manual`` strategy in ``.museattributes``
291 forces a path into the conflict list even when the engine would
292 normally auto-resolve it.
293
294 Args:
295 base: Snapshot at the common ancestor commit.
296 left: Snapshot for the *ours* (current) branch. The distinction
297 between ``left`` and ``right`` only affects the ``applied_strategies``
298 key in the result; the merge is symmetric for clean paths.
299 right: Snapshot for the *theirs* (incoming) branch.
300 repo_root: Path to the repository root so ``.museattributes`` and the
301 object store can be located. ``None`` disables attribute
302 loading and MIDI reconstruction (all conflicts become hard).
303
304 Returns:
305 A :class:`~muse.domain.MergeResult` whose ``snapshot`` holds the
306 merged manifest (conflict paths absent), ``conflicts`` lists the
307 unresolvable paths, and ``applied_strategies`` records which
308 ``.museattributes`` rules were used.
309 """
310 import hashlib as _hashlib
311
312 from muse.core.attributes import load_attributes, resolve_strategy
313 from muse.core.object_store import read_object, write_object
314 from muse.plugins.midi.midi_merge import merge_midi_dimensions
315
316 base_files = base["files"]
317 left_files = left["files"]
318 right_files = right["files"]
319
320 attrs = load_attributes(repo_root, domain=_DOMAIN_TAG) if repo_root is not None else []
321
322 left_changed: set[str] = _changed_paths(base_files, left_files)
323 right_changed: set[str] = _changed_paths(base_files, right_files)
324 all_conflict_paths: set[str] = left_changed & right_changed
325
326 merged: MidiFileMap = dict(base_files)
327
328 # Apply clean single-side changes first.
329 for path in left_changed - all_conflict_paths:
330 if path in left_files:
331 merged[path] = left_files[path]
332 else:
333 merged.pop(path, None)
334
335 for path in right_changed - all_conflict_paths:
336 if path in right_files:
337 merged[path] = right_files[path]
338 else:
339 merged.pop(path, None)
340
341 # Consensus deletions (both sides removed the same file) — not a conflict.
342 consensus_deleted = {
343 p for p in all_conflict_paths
344 if p not in left_files and p not in right_files
345 }
346 for path in consensus_deleted:
347 merged.pop(path, None)
348
349 real_conflicts: set[str] = all_conflict_paths - consensus_deleted
350
351 applied_strategies: AppliedStrategies = {}
352 dimension_reports: DimensionReports = {}
353 final_conflicts: list[str] = []
354
355 for path in sorted(real_conflicts):
356 file_strategy = resolve_strategy(attrs, path, "*")
357
358 if file_strategy == "ours":
359 if path in left_files:
360 merged[path] = left_files[path]
361 else:
362 merged.pop(path, None)
363 applied_strategies[path] = "ours"
364 continue
365
366 if file_strategy == "theirs":
367 if path in right_files:
368 merged[path] = right_files[path]
369 else:
370 merged.pop(path, None)
371 applied_strategies[path] = "theirs"
372 continue
373
374 if (
375 repo_root is not None
376 and path.lower().endswith(".mid")
377 and path in left_files
378 and path in right_files
379 and path in base_files
380 ):
381 base_obj = read_object(repo_root, base_files[path])
382 left_obj = read_object(repo_root, left_files[path])
383 right_obj = read_object(repo_root, right_files[path])
384
385 if base_obj is not None and left_obj is not None and right_obj is not None:
386 try:
387 dim_result = merge_midi_dimensions(
388 base_obj, left_obj, right_obj,
389 attrs,
390 path,
391 )
392 except ValueError:
393 dim_result = None
394
395 if dim_result is not None:
396 merged_bytes, dim_report = dim_result
397 new_hash = _hashlib.sha256(merged_bytes).hexdigest()
398 write_object(repo_root, new_hash, merged_bytes)
399 merged[path] = new_hash
400 applied_strategies[path] = "dimension-merge"
401 dimension_reports[path] = dim_report
402 continue
403
404 final_conflicts.append(path)
405
406 for path in sorted((left_changed | right_changed) - real_conflicts):
407 if path in consensus_deleted:
408 continue
409 if resolve_strategy(attrs, path, "*") == "manual":
410 final_conflicts.append(path)
411 applied_strategies[path] = "manual"
412 if path in base_files:
413 merged[path] = base_files[path]
414 else:
415 merged.pop(path, None)
416
417 return MergeResult(
418 merged=SnapshotManifest(files=merged, domain=_DOMAIN_TAG, directories=[]),
419 conflicts=sorted(final_conflicts),
420 applied_strategies=applied_strategies,
421 dimension_reports=dimension_reports,
422 )
423
424 # ------------------------------------------------------------------
425 # 4. drift — compare committed state vs live state
426 # ------------------------------------------------------------------
427
428 def drift(
429 self,
430 committed: StateSnapshot,
431 live: LiveState,
432 ) -> DriftReport:
433 """Detect uncommitted changes in the working tree relative to *committed*.
434
435 Args:
436 committed: The last committed snapshot.
437 live: Either a ``pathlib.Path`` (repository root) or a snapshot
438 dict representing current live state.
439
440 Returns:
441 A :class:`~muse.domain.DriftReport` describing whether and how the
442 live state differs from the committed snapshot.
443 """
444 live_snapshot = self.snapshot(live)
445 delta = self.diff(committed, live_snapshot)
446
447 inserts = sum(1 for op in delta["ops"] if op["op"] == "insert")
448 deletes = sum(1 for op in delta["ops"] if op["op"] == "delete")
449 modified = sum(1 for op in delta["ops"] if op["op"] in ("replace", "patch"))
450 has_drift = bool(inserts or deletes or modified)
451
452 parts: list[str] = []
453 if inserts:
454 parts.append(f"{inserts} added")
455 if deletes:
456 parts.append(f"{deletes} removed")
457 if modified:
458 parts.append(f"{modified} modified")
459
460 summary = ", ".join(parts) if parts else "working tree clean"
461 return DriftReport(has_drift=has_drift, summary=summary, delta=delta)
462
463 # ------------------------------------------------------------------
464 # 5. apply — execute a delta against live state (checkout)
465 # ------------------------------------------------------------------
466
467 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
468 """Apply a structured delta to produce a new live state.
469
470 When ``live_state`` is a ``pathlib.Path`` the physical files have
471 already been updated by the caller (``muse checkout`` restores objects
472 from the store before calling this). Rescanning the directory is the
473 cheapest correct way to reflect the new state.
474
475 When ``live_state`` is a snapshot dict, only ``DeleteOp`` and
476 ``ReplaceOp`` at the file level can be applied in-memory. ``InsertOp``
477 at the file level requires the new content to be on disk; callers that
478 need those should pass the workdir ``pathlib.Path`` instead.
479 ``PatchOp`` entries are skipped in-memory since reconstructing patched
480 file content requires both the original bytes and the object store.
481
482 Args:
483 delta: A ``StructuredDelta`` produced by :meth:`diff`.
484 live_state: The workdir path (preferred) or a snapshot dict.
485
486 Returns:
487 The updated live state as a ``SnapshotManifest``.
488 """
489 if isinstance(live_state, pathlib.Path):
490 return self.snapshot(live_state)
491
492 current_files = dict(live_state["files"])
493
494 for op in delta["ops"]:
495 if op["op"] == "delete":
496 current_files.pop(op["address"], None)
497 elif op["op"] == "replace":
498 current_files[op["address"]] = op["new_content_id"]
499 elif op["op"] == "insert":
500 current_files[op["address"]] = op["content_id"]
501 # PatchOp and MoveOp: skip in-memory — caller must use workdir path.
502
503 return SnapshotManifest(files=current_files, domain=_DOMAIN_TAG, directories=[])
504
505 # ------------------------------------------------------------------
506 # 6. schema — declare structural schema for the algorithm library
507 # ------------------------------------------------------------------
508
509 def schema(self) -> DomainSchema:
510 """Return the full structural schema for the MIDI domain.
511
512 Declares 21 semantic dimensions — one per independent MIDI event class
513 — that the core diff algorithm library and OT merge engine use to drive
514 per-dimension operations. This is a significant expansion from the
515 original 5 dimensions; the finer granularity means two agents can edit
516 completely different aspects of the same MIDI file (e.g. sustain pedal
517 and channel volume) without ever creating a merge conflict.
518
519 Top level is a ``SetSchema``: the music workspace is an unordered
520 collection of audio/MIDI files, each identified by its SHA-256 content
521 hash.
522
523 Independent dimensions (conflicts do not block merging others):
524 - **notes** (melodic/rhythmic) — note_on / note_off events
525 - **pitch_bend** — pitchwheel controller
526 - **channel_pressure** — monophonic aftertouch
527 - **poly_pressure** — per-note polyphonic aftertouch
528 - **cc_modulation** — CC 1 modulation wheel
529 - **cc_volume** — CC 7 channel volume
530 - **cc_pan** — CC 10 stereo pan
531 - **cc_expression** — CC 11 expression controller
532 - **cc_sustain** — CC 64 damper / sustain pedal
533 - **cc_portamento** — CC 65 portamento on/off
534 - **cc_sostenuto** — CC 66 sostenuto pedal
535 - **cc_soft_pedal** — CC 67 soft pedal (una corda)
536 - **cc_reverb** — CC 91 reverb send level
537 - **cc_chorus** — CC 93 chorus send level
538 - **cc_other** — all other numbered CC controllers
539 - **program_change** — instrument / patch selection
540 - **key_signatures** — key signature meta events
541 - **markers** — section markers, cue points, text annotations
542
543 Non-independent dimensions (conflicts block all others):
544 - **tempo_map** — set_tempo meta events; tempo changes shift the
545 musical meaning of every subsequent tick position, so a bilateral
546 tempo conflict requires human resolution before other dimensions
547 can be finalised.
548 - **time_signatures** — time_signature meta events; bar structure
549 changes have the same semantic blocking effect as tempo changes.
550 - **track_structure** — track name, instrument name, sysex, and
551 unknown meta events affecting routing and session layout.
552 """
553 seq_schema = SequenceSchema(
554 kind="sequence",
555 element_type="note_event",
556 identity="by_position",
557 diff_algorithm="lcs",
558 alphabet=None,
559 )
560 cc_schema = TensorSchema(
561 kind="tensor",
562 dtype="float32",
563 rank=1,
564 epsilon=0.5,
565 diff_mode="sparse",
566 )
567 tree_schema = TreeSchema(
568 kind="tree",
569 node_type="track_node",
570 diff_algorithm="zhang_shasha",
571 )
572 meta_schema = SequenceSchema(
573 kind="sequence",
574 element_type="meta_event",
575 identity="by_position",
576 diff_algorithm="lcs",
577 alphabet=None,
578 )
579 return DomainSchema(
580 domain=_DOMAIN_TAG,
581 description=(
582 "MIDI and audio file versioning with note-level diff and "
583 "21-dimension independent merge"
584 ),
585 top_level=SetSchema(
586 kind="set",
587 element_type="audio_file",
588 identity="by_content",
589 ),
590 dimensions=[
591 # --- Expressive note content ---
592 DimensionSpec(
593 name="notes",
594 description="Note pitches, durations, and timing (melodic + rhythmic)",
595 schema=seq_schema,
596 independent_merge=True,
597 ),
598 DimensionSpec(
599 name="pitch_bend",
600 description="Pitchwheel controller — expressive pitch deviation",
601 schema=cc_schema,
602 independent_merge=True,
603 ),
604 DimensionSpec(
605 name="channel_pressure",
606 description="Monophonic aftertouch — channel-wide pressure",
607 schema=cc_schema,
608 independent_merge=True,
609 ),
610 DimensionSpec(
611 name="poly_pressure",
612 description="Polyphonic aftertouch — per-note pressure",
613 schema=cc_schema,
614 independent_merge=True,
615 ),
616 # --- Named CC controllers ---
617 DimensionSpec(
618 name="cc_modulation",
619 description="CC 1 — modulation wheel",
620 schema=cc_schema,
621 independent_merge=True,
622 ),
623 DimensionSpec(
624 name="cc_volume",
625 description="CC 7 — channel volume",
626 schema=cc_schema,
627 independent_merge=True,
628 ),
629 DimensionSpec(
630 name="cc_pan",
631 description="CC 10 — stereo pan position",
632 schema=cc_schema,
633 independent_merge=True,
634 ),
635 DimensionSpec(
636 name="cc_expression",
637 description="CC 11 — expression controller",
638 schema=cc_schema,
639 independent_merge=True,
640 ),
641 DimensionSpec(
642 name="cc_sustain",
643 description="CC 64 — damper / sustain pedal",
644 schema=cc_schema,
645 independent_merge=True,
646 ),
647 DimensionSpec(
648 name="cc_portamento",
649 description="CC 65 — portamento on/off",
650 schema=cc_schema,
651 independent_merge=True,
652 ),
653 DimensionSpec(
654 name="cc_sostenuto",
655 description="CC 66 — sostenuto pedal",
656 schema=cc_schema,
657 independent_merge=True,
658 ),
659 DimensionSpec(
660 name="cc_soft_pedal",
661 description="CC 67 — soft pedal (una corda)",
662 schema=cc_schema,
663 independent_merge=True,
664 ),
665 DimensionSpec(
666 name="cc_reverb",
667 description="CC 91 — reverb send level",
668 schema=cc_schema,
669 independent_merge=True,
670 ),
671 DimensionSpec(
672 name="cc_chorus",
673 description="CC 93 — chorus send level",
674 schema=cc_schema,
675 independent_merge=True,
676 ),
677 DimensionSpec(
678 name="cc_other",
679 description="All other numbered CC controllers",
680 schema=cc_schema,
681 independent_merge=True,
682 ),
683 # --- Patch / program selection ---
684 DimensionSpec(
685 name="program_change",
686 description="Instrument / patch selection events",
687 schema=meta_schema,
688 independent_merge=True,
689 ),
690 # --- Non-independent timeline metadata ---
691 DimensionSpec(
692 name="tempo_map",
693 description=(
694 "Tempo (BPM) changes — non-independent: a conflict "
695 "blocks merging all other dimensions"
696 ),
697 schema=meta_schema,
698 independent_merge=False,
699 ),
700 DimensionSpec(
701 name="time_signatures",
702 description=(
703 "Time signature changes — non-independent: affects "
704 "bar structure for all other dimensions"
705 ),
706 schema=meta_schema,
707 independent_merge=False,
708 ),
709 # --- Tonal and annotation metadata ---
710 DimensionSpec(
711 name="key_signatures",
712 description="Key signature events",
713 schema=meta_schema,
714 independent_merge=True,
715 ),
716 DimensionSpec(
717 name="markers",
718 description="Section markers, cue points, text, lyrics, copyright",
719 schema=meta_schema,
720 independent_merge=True,
721 ),
722 # --- Track structure (non-independent) ---
723 DimensionSpec(
724 name="track_structure",
725 description=(
726 "Track name, instrument name, sysex, unknown meta — "
727 "non-independent: routing changes affect all tracks"
728 ),
729 schema=tree_schema,
730 independent_merge=False,
731 ),
732 ],
733 merge_mode="three_way",
734 schema_version=__version__,
735 )
736
737 # ------------------------------------------------------------------
738 # 7. merge_ops — operation-level OT merge (StructuredMergePlugin)
739 # ------------------------------------------------------------------
740
741 def merge_ops(
742 self,
743 base: StateSnapshot,
744 ours_snap: StateSnapshot,
745 theirs_snap: StateSnapshot,
746 ours_ops: list[DomainOp],
747 theirs_ops: list[DomainOp],
748 *,
749 repo_root: pathlib.Path | None = None,
750 ) -> MergeResult:
751 """Operation-level three-way merge using the OT engine.
752
753 Extends the file-level ``merge()`` method with sub-file granularity: two
754 changes to non-overlapping notes in the same MIDI file no longer produce
755 a conflict.
756
757 Algorithm
758 ---------
759 1. Run :func:`~muse.core.op_transform.merge_op_lists` on the flat op
760 lists to classify each (ours, theirs) pair as commuting or
761 conflicting.
762 2. Build the merged manifest from *base* by applying all clean merged
763 ops. ``InsertOp`` and ``ReplaceOp`` entries supply a ``content_id``
764 / ``new_content_id`` directly. For ``PatchOp`` entries (sub-file
765 note changes), the final file hash is looked up from *ours_snap* or
766 *theirs_snap*. When both sides produced a ``PatchOp`` for the same
767 MIDI file and the note-level ops commute, an attempt is made to
768 reconstruct the merged MIDI bytes; on failure the file falls back to
769 a conflict.
770 3. For conflicting pairs, consult ``.museattributes``. Strategies
771 ``"ours"`` and ``"theirs"`` are applied automatically; everything
772 else enters ``MergeResult.conflicts``.
773
774 Args:
775 base: Common ancestor snapshot.
776 ours_snap: Final snapshot of our branch.
777 theirs_snap: Final snapshot of their branch.
778 ours_ops: Operations from our branch delta (base → ours).
779 theirs_ops: Operations from their branch delta (base → theirs).
780 repo_root: Repository root for object store and attributes.
781
782 Returns:
783 A :class:`~muse.domain.MergeResult` with the reconciled snapshot
784 and any remaining unresolvable conflicts.
785 """
786 from muse.core.attributes import load_attributes, resolve_strategy
787 from muse.core.op_transform import merge_op_lists
788
789 attrs = load_attributes(repo_root, domain=_DOMAIN_TAG) if repo_root is not None else []
790
791 # OT classification: find commuting and conflicting op pairs.
792 ot_result = merge_op_lists([], ours_ops, theirs_ops)
793
794 # Build the merged manifest starting from base.
795 merged_files: MidiFileMap = dict(base["files"])
796 applied_strategies: AppliedStrategies = {}
797 final_conflicts: list[str] = []
798 op_log: list[DomainOp] = list(ot_result.merged_ops)
799
800 # Group PatchOps by address so we can detect same-file note merges.
801 ours_patches: PatchOpMap = {}
802 theirs_patches: PatchOpMap = {}
803 for op in ours_ops:
804 if op["op"] == "patch":
805 ours_patches[op["address"]] = op
806 for op in theirs_ops:
807 if op["op"] == "patch":
808 theirs_patches[op["address"]] = op
809
810 # Track which addresses are involved in a conflict.
811 conflicting_addresses: set[str] = {
812 our_op["address"] for our_op, _ in ot_result.conflict_ops
813 }
814
815 # --- Apply clean merged ops ---
816 for op in ot_result.merged_ops:
817 addr = op["address"]
818 if addr in conflicting_addresses:
819 continue # handled in conflict resolution below
820
821 if op["op"] == "insert":
822 merged_files[addr] = op["content_id"]
823
824 elif op["op"] == "delete":
825 merged_files.pop(addr, None)
826
827 elif op["op"] == "replace":
828 merged_files[addr] = op["new_content_id"]
829
830 elif op["op"] == "patch":
831 # PatchOp: determine which side(s) patched this file.
832 has_ours = addr in ours_patches
833 has_theirs = addr in theirs_patches
834
835 if has_ours and not has_theirs:
836 # Only our side changed this file — take our version.
837 if addr in ours_snap["files"]:
838 merged_files[addr] = ours_snap["files"][addr]
839 else:
840 merged_files.pop(addr, None)
841
842 elif has_theirs and not has_ours:
843 # Only their side changed this file — take their version.
844 if addr in theirs_snap["files"]:
845 merged_files[addr] = theirs_snap["files"][addr]
846 else:
847 merged_files.pop(addr, None)
848
849 else:
850 # Both sides patched the same file with commuting note ops.
851 # Attempt note-level MIDI reconstruction.
852 merged_content_id = _merge_patch_ops(
853 addr=addr,
854 ours_patch=ours_patches[addr],
855 theirs_patch=theirs_patches[addr],
856 base_files=dict(base["files"]),
857 ours_snap_files=dict(ours_snap["files"]),
858 theirs_snap_files=dict(theirs_snap["files"]),
859 repo_root=repo_root,
860 )
861 if merged_content_id is not None:
862 merged_files[addr] = merged_content_id
863 else:
864 # Reconstruction failed — treat as manual conflict.
865 final_conflicts.append(addr)
866
867 # --- Resolve conflicts ---
868 for our_op, their_op in ot_result.conflict_ops:
869 addr = our_op["address"]
870 strategy = resolve_strategy(attrs, addr, "*")
871
872 if strategy == "ours":
873 if addr in ours_snap["files"]:
874 merged_files[addr] = ours_snap["files"][addr]
875 else:
876 merged_files.pop(addr, None)
877 applied_strategies[addr] = "ours"
878
879 elif strategy == "theirs":
880 if addr in theirs_snap["files"]:
881 merged_files[addr] = theirs_snap["files"][addr]
882 else:
883 merged_files.pop(addr, None)
884 applied_strategies[addr] = "theirs"
885
886 else:
887 # Strategy "manual" or "auto" without a clear resolution.
888 final_conflicts.append(addr)
889
890 return MergeResult(
891 merged=SnapshotManifest(files=merged_files, domain=_DOMAIN_TAG, directories=[]),
892 conflicts=sorted(set(final_conflicts)),
893 applied_strategies=applied_strategies,
894 op_log=op_log,
895 )
896
897
898 # ---------------------------------------------------------------------------
899 # Module-level helpers
900 # ---------------------------------------------------------------------------
901
902
903 def _merge_patch_ops(
904 *,
905 addr: str,
906 ours_patch: PatchOp,
907 theirs_patch: PatchOp,
908 base_files: Manifest,
909 ours_snap_files: Manifest,
910 theirs_snap_files: Manifest,
911 repo_root: pathlib.Path | None,
912 ) -> str | None:
913 """Attempt note-level MIDI merge for two ``PatchOp``\\s on the same file.
914
915 Runs OT on the child_ops of each PatchOp. If the note-level ops all
916 commute, reconstructs the merged MIDI by:
917
918 1. Loading base, ours, and theirs MIDI bytes from the object store.
919 2. Extracting note sequences from all three versions.
920 3. Building ``content_id → NoteKey`` look-ups for the ours and theirs
921 sequences (so that InsertOp content IDs can be resolved to real notes).
922 4. Applying the merged note ops (deletions then insertions) to the base
923 note sequence.
924 5. Calling :func:`~muse.plugins.midi.midi_diff.reconstruct_midi` and
925 storing the resulting bytes.
926
927 Returns the SHA-256 hash of the reconstructed MIDI (ready to store in the
928 object store) on success, or ``None`` when:
929
930 - *repo_root* is ``None`` (cannot access object store).
931 - Base or branch bytes are not in the local object store.
932 - Note-level OT found conflicts.
933 - MIDI reconstruction raised any exception.
934
935 Args:
936 addr: Workspace-relative MIDI file path.
937 ours_patch: Our PatchOp for this file.
938 theirs_patch: Their PatchOp for this file.
939 base_files: Content-ID map for the common ancestor snapshot.
940 ours_snap_files: Content-ID map for our branch's final snapshot.
941 theirs_snap_files: Content-ID map for their branch's final snapshot.
942 repo_root: Repository root for object store access.
943
944 Returns:
945 Content-ID (SHA-256 hex) of the merged MIDI, or ``None`` on failure.
946 """
947 if repo_root is None or addr not in base_files:
948 return None
949
950 from muse.core.object_store import read_object, write_object
951 from muse.core.op_transform import merge_op_lists
952 from muse.plugins.midi.midi_diff import NoteKey, extract_notes, reconstruct_midi
953
954 # Run OT on note-level ops to classify conflicts.
955 note_result = merge_op_lists([], ours_patch["child_ops"], theirs_patch["child_ops"])
956 if not note_result.is_clean:
957 logger.debug(
958 "⚠️ Note-level conflict in %r: %d pair(s) — falling back to file conflict",
959 addr,
960 len(note_result.conflict_ops),
961 )
962 return None
963
964 try:
965 base_bytes = read_object(repo_root, base_files[addr])
966 if base_bytes is None:
967 return None
968
969 ours_hash = ours_snap_files.get(addr)
970 theirs_hash = theirs_snap_files.get(addr)
971 ours_bytes = read_object(repo_root, ours_hash) if ours_hash else None
972 theirs_bytes = read_object(repo_root, theirs_hash) if theirs_hash else None
973
974 base_notes, ticks_per_beat = extract_notes(base_bytes)
975
976 # Build content_id → NoteKey lookups from ours and theirs versions.
977 ours_by_id: NoteIdMap = {}
978 if ours_bytes is not None:
979 ours_notes, _ = extract_notes(ours_bytes)
980 ours_by_id = {_note_content_id(n): n for n in ours_notes}
981
982 theirs_by_id: NoteIdMap = {}
983 if theirs_bytes is not None:
984 theirs_notes, _ = extract_notes(theirs_bytes)
985 theirs_by_id = {_note_content_id(n): n for n in theirs_notes}
986
987 # Collect content IDs to delete.
988 delete_ids: set[str] = {
989 op["content_id"] for op in note_result.merged_ops if op["op"] == "delete"
990 }
991
992 # Apply deletions to base note list.
993 base_note_by_id = {_note_content_id(n): n for n in base_notes}
994 surviving: list[NoteKey] = [
995 n for n in base_notes if _note_content_id(n) not in delete_ids
996 ]
997
998 # Collect insertions: resolve content_id → NoteKey via ours then theirs.
999 inserted: list[NoteKey] = []
1000 for op in note_result.merged_ops:
1001 if op["op"] == "insert":
1002 cid = op["content_id"]
1003 note = ours_by_id.get(cid) or theirs_by_id.get(cid)
1004 if note is None:
1005 # Fallback: base itself shouldn't have it, but check anyway.
1006 note = base_note_by_id.get(cid)
1007 if note is None:
1008 logger.debug(
1009 "⚠️ Cannot resolve note content_id %s for %r — skipping",
1010 cid[:12],
1011 addr,
1012 )
1013 continue
1014 inserted.append(note)
1015
1016 merged_notes = surviving + inserted
1017 merged_bytes = reconstruct_midi(merged_notes, ticks_per_beat=ticks_per_beat)
1018
1019 merged_hash = hashlib.sha256(merged_bytes).hexdigest()
1020 write_object(repo_root, merged_hash, merged_bytes)
1021
1022 logger.info(
1023 "✅ Note-level MIDI merge for %r: %d ops clean, %d notes in result",
1024 addr,
1025 len(note_result.merged_ops),
1026 len(merged_notes),
1027 )
1028 return merged_hash
1029
1030 except Exception as exc: # noqa: BLE001 intentional broad catch
1031 logger.debug("⚠️ MIDI note-level reconstruction failed for %r: %s", addr, exc)
1032 return None
1033
1034
1035 def _note_content_id(note: NoteKey) -> str:
1036 """Return the SHA-256 content ID for a :class:`~muse.plugins.midi.midi_diff.NoteKey`.
1037
1038 Delegates to the same algorithm used in :mod:`muse.plugins.midi.midi_diff`
1039 so that content IDs computed here are identical to those stored in
1040 ``InsertOp`` / ``DeleteOp`` entries.
1041 """
1042 payload = (
1043 f"{note['pitch']}:{note['velocity']}:"
1044 f"{note['start_tick']}:{note['duration_ticks']}:{note['channel']}"
1045 )
1046 return hashlib.sha256(payload.encode()).hexdigest()
1047
1048
1049 def _new_file_patch(
1050 *,
1051 path: str,
1052 content_id: str,
1053 repo_root: pathlib.Path | None,
1054 ) -> PatchOp | None:
1055 """Return a ``PatchOp`` with symbol child ops for a newly-added file, or
1056 ``None`` when the file type is unsupported or content is unreadable.
1057
1058 Reads content from the object store first; falls back to the on-disk path
1059 under ``repo_root`` so uncommitted working-tree files are handled correctly.
1060
1061 Supported symbol extraction:
1062 - ``.md`` / ``.markdown`` — ATX headings (``#`` prefix)
1063 - ``.py`` — top-level ``def`` and ``class`` definitions
1064 """
1065 if repo_root is None:
1066 return None
1067
1068 lower = path.lower()
1069 is_md = lower.endswith(".md") or lower.endswith(".markdown")
1070 is_py = lower.endswith(".py")
1071 if not is_md and not is_py:
1072 return None
1073
1074 # Prefer object store; fall through to disk for uncommitted files.
1075 from muse.core.object_store import read_object
1076
1077 content: bytes | None = read_object(repo_root, content_id)
1078 if content is None:
1079 disk = repo_root / path
1080 if disk.is_file():
1081 content = disk.read_bytes()
1082 if content is None:
1083 return None
1084
1085 text = content.decode("utf-8", errors="replace")
1086 child_ops: list[DomainOp] = []
1087
1088 if is_md:
1089 for lineno, line in enumerate(text.splitlines(), 1):
1090 if line.startswith("#"):
1091 heading = line.lstrip("#").strip()
1092 if heading:
1093 child_ops.append(
1094 InsertOp(
1095 op="insert",
1096 address=heading,
1097 position=None,
1098 content_id="",
1099 content_summary=f"{heading} L{lineno}–{lineno}",
1100 )
1101 )
1102 elif is_py:
1103 for lineno, line in enumerate(text.splitlines(), 1):
1104 stripped = line.strip()
1105 if stripped.startswith("def ") or stripped.startswith("class "):
1106 tokens = stripped.split("(")[0].split()
1107 if len(tokens) >= 2:
1108 kind, name = tokens[0], tokens[1]
1109 child_ops.append(
1110 InsertOp(
1111 op="insert",
1112 address=name,
1113 position=None,
1114 content_id="",
1115 content_summary=f"{kind} {name} L{lineno}–{lineno}",
1116 )
1117 )
1118
1119 if not child_ops:
1120 return None
1121
1122 return PatchOp(
1123 op="patch",
1124 address=path,
1125 child_ops=child_ops,
1126 child_domain=_DOMAIN_TAG,
1127 child_summary=f"new file: {path} ({len(child_ops)} symbol(s))",
1128 )
1129
1130
1131 def _diff_modified_file(
1132 *,
1133 path: str,
1134 old_hash: str,
1135 new_hash: str,
1136 repo_root: pathlib.Path | None,
1137 ) -> DomainOp:
1138 """Produce the richest available operation for a modified file.
1139
1140 For ``.mid`` files where both content revisions are readable from the
1141 object store, performs a full note-level MIDI diff and returns a
1142 ``PatchOp`` carrying the individual ``InsertOp``/``DeleteOp`` child
1143 operations. Falls back to a ``ReplaceOp`` (opaque before/after hash
1144 pair) when the file is not a MIDI file, ``repo_root`` is ``None``, or
1145 either content revision cannot be retrieved from the store.
1146
1147 Args:
1148 path: Workspace-relative POSIX path of the modified file.
1149 old_hash: SHA-256 of the base content in the object store.
1150 new_hash: SHA-256 of the current content in the object store.
1151 repo_root: Repository root for object store access. ``None`` forces
1152 immediate fallback to ``ReplaceOp``.
1153
1154 Returns:
1155 A ``PatchOp`` with note-level child ops when deep diff succeeds,
1156 otherwise a ``ReplaceOp`` with the opaque before/after content hashes.
1157 """
1158 if path.lower().endswith(".mid") and repo_root is not None:
1159 from muse.core.object_store import read_object
1160 from muse.plugins.midi.midi_diff import diff_midi_notes
1161
1162 base_bytes = read_object(repo_root, old_hash)
1163 target_bytes = read_object(repo_root, new_hash)
1164
1165 if base_bytes is not None and target_bytes is not None:
1166 try:
1167 child_delta = diff_midi_notes(
1168 base_bytes, target_bytes, file_path=path
1169 )
1170 return PatchOp(
1171 op="patch",
1172 address=path,
1173 child_ops=child_delta["ops"],
1174 child_domain=child_delta["domain"],
1175 child_summary=child_delta["summary"],
1176 )
1177 except (ValueError, Exception) as exc:
1178 logger.debug("⚠️ MIDI deep diff failed for %r: %s", path, exc)
1179
1180 return ReplaceOp(
1181 op="replace",
1182 address=path,
1183 position=None,
1184 old_content_id=old_hash,
1185 new_content_id=new_hash,
1186 old_summary=f"{path} (previous)",
1187 new_summary=f"{path} (updated)",
1188 )
1189
1190
1191 def _summarise_ops(ops: list[DomainOp]) -> str:
1192 """Build a human-readable summary string from a list of domain ops."""
1193 inserts = 0
1194 deletes = 0
1195 replaces = 0
1196 patches = 0
1197
1198 for op in ops:
1199 kind = op["op"]
1200 if kind == "insert":
1201 inserts += 1
1202 elif kind == "delete":
1203 deletes += 1
1204 elif kind == "replace":
1205 replaces += 1
1206 elif kind == "patch":
1207 patches += 1
1208
1209 parts: list[str] = []
1210 if inserts:
1211 parts.append(f"{inserts} file{'s' if inserts != 1 else ''} added")
1212 if deletes:
1213 parts.append(f"{deletes} file{'s' if deletes != 1 else ''} removed")
1214 if replaces:
1215 parts.append(f"{replaces} file{'s' if replaces != 1 else ''} modified")
1216 if patches:
1217 parts.append(f"{patches} file{'s' if patches != 1 else ''} patched")
1218
1219 return ", ".join(parts) if parts else "no changes"
1220
1221
1222 def _hash_file(path: pathlib.Path) -> str:
1223 """Return the SHA-256 hex digest of a file's raw bytes."""
1224 h = hashlib.sha256()
1225 with path.open("rb") as fh:
1226 for chunk in iter(lambda: fh.read(65536), b""):
1227 h.update(chunk)
1228 return h.hexdigest()
1229
1230
1231 def _changed_paths(
1232 base: Manifest, other: Manifest
1233 ) -> set[str]:
1234 """Return paths that differ between *base* and *other*."""
1235 base_p = set(base)
1236 other_p = set(other)
1237 added = other_p - base_p
1238 deleted = base_p - other_p
1239 common = base_p & other_p
1240 modified = {p for p in common if base[p] != other[p]}
1241 return added | deleted | modified
1242
1243
1244 def content_hash(snapshot: StateSnapshot) -> str:
1245 """Return a stable SHA-256 digest of a snapshot for content-addressing."""
1246 canonical = json.dumps(snapshot, sort_keys=True, separators=(",", ":"))
1247 return hashlib.sha256(canonical.encode()).hexdigest()
1248
1249
1250 #: Module-level singleton — import and use directly.
1251 plugin = MidiPlugin()
1252
1253 assert isinstance(plugin, MuseDomainPlugin), (
1254 "MidiPlugin does not satisfy the MuseDomainPlugin protocol"
1255 )
1256 assert isinstance(plugin, StructuredMergePlugin), (
1257 "MidiPlugin does not satisfy the StructuredMergePlugin protocol"
1258 )
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago