_crdt_notes.py python
410 lines 14.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
1 """Voice-aware MIDI RGA — experimental CRDT for live MIDI note sequences.
2
3 This module is a **research prototype** for the live collaboration foundation
4 described in the Muse supercharge plan. It is NOT wired into the production
5 merge path. Its purpose is to:
6
7 1. Demonstrate that concurrent note insertions can be made commutative.
8 2. Provide a benchmark harness for comparing voice-aware RGA against LSEQ.
9 3. Serve as the implementation foundation for the eventual live collaboration
10 layer (Workstream 7 / 8 in the plan).
11
12 Design
13 ------
14 Standard RGA (Roh 2011) orders concurrent insertions at the same position
15 lexicographically by op_id. For music this produces unacceptable results:
16 two agents inserting bass and soprano notes at the same beat would interleave
17 their pitches arbitrarily, producing nonsense voice crossings.
18
19 **Music-RGA** uses a multi-key position ordering:
20
21 NotePosition = (measure, beat_sub, voice_lane, op_id)
22
23 Concurrent insertions at the same ``(measure, beat_sub)`` are ordered by
24 ``voice_lane`` first (bass=0 < tenor=1 < alto=2 < soprano=3), then by
25 ``op_id`` as a tie-break. This guarantees that bass notes always precede
26 treble notes in the materialised sequence regardless of insertion order.
27
28 Voice lane assignment
29 ---------------------
30 Voice lane is determined from the note's pitch at insert time using a
31 coarse tessiture model:
32
33 pitch < 48 → 0 (bass)
34 48 ≤ pitch < 60 → 1 (tenor)
35 60 ≤ pitch < 72 → 2 (alto)
36 pitch ≥ 72 → 3 (soprano)
37
38 Agents that perform explicit voice separation can override ``voice_lane``
39 when calling :meth:`MidiRGA.insert`.
40
41 CRDT properties
42 ---------------
43 The three lattice laws are demonstrated by :func:`_verify_crdt_laws` in the
44 test suite:
45
46 1. **Commutativity**: ``merge(a, b).to_sequence() == merge(b, a).to_sequence()``
47 2. **Associativity**: ``merge(merge(a, b), c) == merge(a, merge(b, c))``
48 3. **Idempotency**: ``merge(a, a).to_sequence() == a.to_sequence()``
49
50 Relationship to the commit DAG
51 -------------------------------
52 A live session accumulates :class:`RGANoteEntry` operations. At commit time,
53 :meth:`MidiRGA.to_domain_ops` translates the CRDT state into canonical Muse
54 :class:`~muse.domain.DomainOp` entries for storage in the commit record.
55 The CRDT state itself is ephemeral — not stored in the object store.
56
57 Public API
58 ----------
59 - :class:`NotePosition` — music-aware position key (NamedTuple).
60 - :class:`RGANoteEntry` — one element in the RGA (TypedDict).
61 - :class:`MidiRGA` — the voice-aware ordered note sequence CRDT.
62 """
63
64 from __future__ import annotations
65
66 import logging
67 import uuid as _uuid_mod
68 from typing import NamedTuple, TypedDict
69
70 from muse.domain import DeleteOp, DomainOp, InsertOp
71 from muse.plugins.midi.midi_diff import NoteKey, _note_content_id, _note_summary
72
73
74 type _EntryMap = dict[str, "RGANoteEntry"]
75 logger = logging.getLogger(__name__)
76
77 # ---------------------------------------------------------------------------
78 # Music-aware position key
79 # ---------------------------------------------------------------------------
80
81
82 class NotePosition(NamedTuple):
83 """Multi-key position for voice-aware ordering in the Music RGA.
84
85 Fields are ordered by comparison priority:
86
87 ``measure``
88 1-indexed bar number. Notes in earlier bars always precede notes
89 in later bars.
90 ``beat_sub``
91 Tick offset within the bar. Lower onset first within the same bar.
92 ``voice_lane``
93 Voice stream: 0=bass, 1=tenor, 2=alto, 3=soprano, 4+=auxiliary.
94 At the same ``(measure, beat_sub)``, lower voice lane wins — bass
95 notes are placed before treble notes, preventing voice crossings.
96 ``op_id``
97 UUID4 op identifier. Lexicographic tie-break for concurrent
98 insertions by different actors in the same voice at the same beat.
99 """
100
101 measure: int
102 beat_sub: int
103 voice_lane: int
104 op_id: str
105
106
107 def _pitch_to_voice_lane(pitch: int) -> int:
108 """Map a MIDI pitch to a coarse voice lane index.
109
110 Tessiture boundaries (MIDI pitch → voice):
111 - 0–47 → 0 (bass, sub-bass)
112 - 48–59 → 1 (tenor, baritone)
113 - 60–71 → 2 (alto, mezzo)
114 - 72–127 → 3 (soprano, treble)
115 """
116 if pitch < 48:
117 return 0
118 if pitch < 60:
119 return 1
120 if pitch < 72:
121 return 2
122 return 3
123
124
125 # ---------------------------------------------------------------------------
126 # RGA entry
127 # ---------------------------------------------------------------------------
128
129
130 class RGANoteEntry(TypedDict):
131 """One element in the :class:`MidiRGA` linked list.
132
133 ``op_id`` Unique insertion operation ID (UUID4).
134 ``actor_id`` The agent or human that performed this insertion.
135 ``note`` The MIDI note content.
136 ``position`` Music-aware position key for ordering.
137 ``parent_op_id`` The ``op_id`` of the element this was inserted after
138 (``None`` for head insertions).
139 ``tombstone`` ``True`` when this note has been deleted (standard RGA
140 tombstone semantics — the entry is retained so that its
141 position remains stable for other replicas).
142 """
143
144 op_id: str
145 actor_id: str
146 note: NoteKey
147 position: NotePosition
148 parent_op_id: str | None
149 tombstone: bool
150
151
152 # ---------------------------------------------------------------------------
153 # MidiRGA
154 # ---------------------------------------------------------------------------
155
156
157 class MidiRGA:
158 """Voice-aware Replicated Growable Array for live MIDI note sequences.
159
160 Implements the standard RGA CRDT (Roh et al., 2011) with a music-aware
161 position key (:class:`NotePosition`) that orders concurrent insertions by
162 voice lane before falling back to op_id, preventing voice crossings in
163 concurrent collaborative edits.
164
165 Usage::
166
167 seq = MidiRGA("agent-1")
168 e1 = seq.insert(bass_note)
169 e2 = seq.insert(soprano_note)
170 seq.delete(e1["op_id"])
171
172 # On another replica:
173 seq2 = MidiRGA("agent-2")
174 e3 = seq2.insert(tenor_note)
175
176 merged = MidiRGA.merge(seq, seq2)
177 notes = merged.to_sequence() # deterministic, voice-ordered
178
179 Args:
180 actor_id: Stable identifier for the agent or human using this replica.
181 """
182
183 def __init__(self, actor_id: str) -> None:
184 self._actor_id = actor_id
185 self._entries: _EntryMap = {} # op_id → entry
186
187 # ------------------------------------------------------------------
188 # Insertion
189 # ------------------------------------------------------------------
190
191 def insert(
192 self,
193 note: NoteKey,
194 *,
195 after: str | None = None,
196 voice_lane: int | None = None,
197 ticks_per_beat: int = 480,
198 time_sig_numerator: int = 4,
199 ) -> RGANoteEntry:
200 """Insert *note* into the sequence, optionally after entry *after*.
201
202 Args:
203 note: The MIDI note to insert.
204 after: ``op_id`` of the entry to insert after.
205 ``None`` inserts at the head.
206 voice_lane: Override the automatic tessiture assignment.
207 ticks_per_beat: Used to compute measure and beat_sub.
208 time_sig_numerator: Beats per bar (default 4 for 4/4 time).
209
210 Returns:
211 The created :class:`RGANoteEntry`.
212 """
213 op_id = str(_uuid_mod.uuid4())
214
215 ticks_per_bar = ticks_per_beat * time_sig_numerator
216 measure = note["start_tick"] // ticks_per_bar + 1
217 beat_sub = note["start_tick"] % ticks_per_bar
218 lane = voice_lane if voice_lane is not None else _pitch_to_voice_lane(note["pitch"])
219
220 position = NotePosition(
221 measure=measure,
222 beat_sub=beat_sub,
223 voice_lane=lane,
224 op_id=op_id,
225 )
226
227 entry: RGANoteEntry = RGANoteEntry(
228 op_id=op_id,
229 actor_id=self._actor_id,
230 note=note,
231 position=position,
232 parent_op_id=after,
233 tombstone=False,
234 )
235 self._entries[op_id] = entry
236 logger.debug(
237 "MidiRGA insert: actor=%r pitch=%d measure=%d voice=%d op=%s",
238 self._actor_id,
239 note["pitch"],
240 measure,
241 lane,
242 op_id[:8],
243 )
244 return entry
245
246 # ------------------------------------------------------------------
247 # Deletion
248 # ------------------------------------------------------------------
249
250 def delete(self, op_id: str) -> None:
251 """Mark the entry with *op_id* as tombstoned.
252
253 The entry remains in the internal map so that its position continues
254 to anchor other entries that were inserted after it.
255
256 Args:
257 op_id: The op_id of the entry to delete.
258
259 Raises:
260 KeyError: When *op_id* is not found in this replica.
261 """
262 if op_id not in self._entries:
263 raise KeyError(f"op_id {op_id!r} not found in MidiRGA")
264 entry = self._entries[op_id]
265 self._entries[op_id] = RGANoteEntry(
266 op_id=entry["op_id"],
267 actor_id=entry["actor_id"],
268 note=entry["note"],
269 position=entry["position"],
270 parent_op_id=entry["parent_op_id"],
271 tombstone=True,
272 )
273
274 # ------------------------------------------------------------------
275 # Materialisation
276 # ------------------------------------------------------------------
277
278 def to_sequence(self) -> list[NoteKey]:
279 """Materialise the live note sequence (excluding tombstones).
280
281 Entries are sorted by their :class:`NotePosition` key:
282 ``(measure, beat_sub, voice_lane, op_id)``. This guarantees a
283 deterministic, voice-coherent ordering regardless of insertion order
284 across replicas.
285
286 Returns:
287 Sorted list of live (non-tombstoned) :class:`NoteKey` objects.
288 """
289 live = [e for e in self._entries.values() if not e["tombstone"]]
290 live.sort(key=lambda e: e["position"])
291 return [e["note"] for e in live]
292
293 def entry_count(self) -> int:
294 """Return the total number of entries including tombstones."""
295 return len(self._entries)
296
297 def live_count(self) -> int:
298 """Return the number of non-tombstoned (visible) entries."""
299 return sum(1 for e in self._entries.values() if not e["tombstone"])
300
301 # ------------------------------------------------------------------
302 # CRDT merge — commutative, associative, idempotent
303 # ------------------------------------------------------------------
304
305 @staticmethod
306 def merge(a: "MidiRGA", b: "MidiRGA") -> "MidiRGA":
307 """Return a new MidiRGA that is the join of replicas *a* and *b*.
308
309 The join is:
310 - **Commutative**: ``merge(a, b).to_sequence() == merge(b, a).to_sequence()``
311 - **Associative**: ``merge(merge(a, b), c) == merge(a, merge(b, c))``
312 - **Idempotent**: ``merge(a, a).to_sequence() == a.to_sequence()``
313
314 For entries present in both replicas, deletion wins (tombstone=True
315 takes priority over tombstone=False). This is the standard OR-Set
316 / RGA semantics for concurrent delete-and-insert.
317
318 Args:
319 a: First replica.
320 b: Second replica.
321
322 Returns:
323 A new :class:`MidiRGA` containing the union of all entries from
324 both replicas with tombstone-wins conflict resolution.
325 """
326 merged = MidiRGA(actor_id=f"merge({a._actor_id},{b._actor_id})")
327
328 all_op_ids = set(a._entries) | set(b._entries)
329 for op_id in all_op_ids:
330 entry_a = a._entries.get(op_id)
331 entry_b = b._entries.get(op_id)
332
333 if entry_a is not None and entry_b is not None:
334 # Tombstone wins — if either replica deleted this entry, it
335 # is considered deleted in the merged result.
336 tombstone = entry_a["tombstone"] or entry_b["tombstone"]
337 merged._entries[op_id] = RGANoteEntry(
338 op_id=entry_a["op_id"],
339 actor_id=entry_a["actor_id"],
340 note=entry_a["note"],
341 position=entry_a["position"],
342 parent_op_id=entry_a["parent_op_id"],
343 tombstone=tombstone,
344 )
345 elif entry_a is not None:
346 merged._entries[op_id] = entry_a
347 else:
348 assert entry_b is not None
349 merged._entries[op_id] = entry_b
350
351 return merged
352
353 # ------------------------------------------------------------------
354 # Conversion to Muse DomainOps
355 # ------------------------------------------------------------------
356
357 def to_domain_ops(
358 self,
359 base_sequence: list[NoteKey],
360 ticks_per_beat: int = 480,
361 ) -> list[DomainOp]:
362 """Convert this CRDT state to Muse DomainOps relative to a base sequence.
363
364 Used at commit time to crystallise a live session's CRDT state into
365 the canonical Muse typed delta algebra for storage in the commit record.
366
367 The conversion computes:
368 - ``InsertOp`` for notes present in the live sequence but not in base.
369 - ``DeleteOp`` for notes present in base but not in the live sequence.
370
371 Args:
372 base_sequence: The committed note list at the start of the session.
373 ticks_per_beat: Used for human-readable summaries.
374
375 Returns:
376 List of :class:`~muse.domain.DomainOp` entries.
377 """
378 live = self.to_sequence()
379 base_content_ids = {_note_content_id(n) for n in base_sequence}
380 live_content_ids = {_note_content_id(n) for n in live}
381
382 ops: list[DomainOp] = []
383
384 for i, note in enumerate(live):
385 cid = _note_content_id(note)
386 if cid not in base_content_ids:
387 ops.append(
388 InsertOp(
389 op="insert",
390 address=f"note:{i}",
391 position=i,
392 content_id=cid,
393 content_summary=_note_summary(note, ticks_per_beat),
394 )
395 )
396
397 for i, note in enumerate(base_sequence):
398 cid = _note_content_id(note)
399 if cid not in live_content_ids:
400 ops.append(
401 DeleteOp(
402 op="delete",
403 address=f"note:{i}",
404 position=i,
405 content_id=cid,
406 content_summary=_note_summary(note, ticks_per_beat),
407 )
408 )
409
410 return ops
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 33 days ago