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