entity.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago
| 1 | """Stable entity identity for MIDI note objects in the Muse MIDI plugin. |
| 2 | |
| 3 | The key insight |
| 4 | --------------- |
| 5 | Content hash ≠ entity identity. |
| 6 | |
| 7 | The current note content ID — ``SHA-256(pitch:velocity:start_tick:duration_ticks:channel)`` |
| 8 | — is correct for *content equality* but wrong for *entity identity*. When a |
| 9 | musician or agent changes a note's velocity from 80 to 100, the old model |
| 10 | produces a ``DeleteOp + InsertOp`` (two unrelated content hashes). With stable |
| 11 | entity identity, the diff produces a ``MutateOp`` — "velocity 80→100 on note |
| 12 | C4@bar4" — and the note's lineage is preserved through the edit. |
| 13 | |
| 14 | A ``NoteEntity`` extends the five ``NoteKey`` fields with an optional |
| 15 | ``entity_id`` — a UUID4 assigned at first insertion that persists across all |
| 16 | subsequent mutations regardless of how the note's properties change. |
| 17 | |
| 18 | Entity assignment heuristic |
| 19 | ---------------------------- |
| 20 | The function :func:`assign_entity_ids` maps a new note list to the entity IDs |
| 21 | in a prior index using a three-tier match: |
| 22 | |
| 23 | 1. **Exact content match** — all five fields identical → same entity, no mutation. |
| 24 | 2. **Fuzzy match** — same pitch + channel, ``|Δtick| ≤ threshold``, and |
| 25 | ``|Δvelocity| ≤ threshold`` → same entity, emit ``MutateOp``. |
| 26 | 3. **No match** → new entity, new UUID4, emit ``InsertOp``. |
| 27 | |
| 28 | Notes in the prior index that matched nothing → emit ``DeleteOp``. |
| 29 | |
| 30 | Storage |
| 31 | ------- |
| 32 | Entity indexes live under ``.muse/entity_index/`` as derived artifacts: |
| 33 | |
| 34 | .muse/entity_index/<commit_id>/<track_safe_name>.json |
| 35 | |
| 36 | They are fully rebuildable from commit history and should be added to the |
| 37 | ``[domain.midi]`` section of ``.museignore`` in agent automation scripts |
| 38 | to avoid accidental commits. |
| 39 | |
| 40 | Public API |
| 41 | ---------- |
| 42 | - :class:`NoteEntity` — ``NoteKey`` fields + optional entity metadata. |
| 43 | - :class:`EntityIndexEntry` — one entity's record in the index. |
| 44 | - :class:`EntityIndex` — the full per-track, per-commit index. |
| 45 | - :func:`assign_entity_ids` — map a new note list onto prior entity IDs. |
| 46 | - :func:`diff_with_entity_ids` — entity-aware diff → ``list[DomainOp]``. |
| 47 | - :func:`build_entity_index` — build an :class:`EntityIndex` from entities. |
| 48 | - :func:`write_entity_index` / :func:`read_entity_index` — I/O. |
| 49 | """ |
| 50 | |
| 51 | from __future__ import annotations |
| 52 | |
| 53 | import hashlib |
| 54 | import json |
| 55 | import logging |
| 56 | import pathlib |
| 57 | import uuid as _uuid_mod |
| 58 | from typing import TypedDict |
| 59 | |
| 60 | from muse.domain import ( |
| 61 | DeleteOp, |
| 62 | DomainOp, |
| 63 | FieldMutation, |
| 64 | InsertOp, |
| 65 | MutateOp, |
| 66 | ) |
| 67 | from muse.plugins.midi.midi_diff import NoteKey, _note_content_id, _note_summary # noqa: PLC2701 |
| 68 | |
| 69 | type _ContentMap = dict[str, str] |
| 70 | type _NoteKeyBuckets = dict[str, list[NoteKey]] |
| 71 | type _FieldMutationMap = dict[str, FieldMutation] |
| 72 | type _EntityMap = dict[str, "EntityIndexEntry"] |
| 73 | type _NoteEntityMap = dict[str, "NoteEntity"] |
| 74 | |
| 75 | logger = logging.getLogger(__name__) |
| 76 | |
| 77 | _ENTITY_INDEX_DIR = ".muse/entity_index" |
| 78 | |
| 79 | |
| 80 | # --------------------------------------------------------------------------- |
| 81 | # Types |
| 82 | # --------------------------------------------------------------------------- |
| 83 | |
| 84 | |
| 85 | class _NoteEntityRequired(TypedDict): |
| 86 | """Required fields shared with NoteKey.""" |
| 87 | |
| 88 | pitch: int |
| 89 | velocity: int |
| 90 | start_tick: int |
| 91 | duration_ticks: int |
| 92 | channel: int |
| 93 | |
| 94 | |
| 95 | class NoteEntity(_NoteEntityRequired, total=False): |
| 96 | """A MIDI note with optional stable entity identity. |
| 97 | |
| 98 | When ``entity_id`` is absent the note is treated as content-only (legacy |
| 99 | behaviour). When present it is a UUID4 that persists across mutations to |
| 100 | velocity, timing, or duration — enabling lineage tracking through edits. |
| 101 | |
| 102 | ``voice_id`` |
| 103 | Optional voice lane identifier (e.g. ``"soprano"``, ``"alto"``). |
| 104 | Assigned by a voice-separation analysis pass; not required for basic |
| 105 | entity tracking. |
| 106 | ``origin_commit_id`` |
| 107 | Short-form commit ID where this entity was first created. |
| 108 | ``origin_op_id`` |
| 109 | Op UUID from the op log that created this entity. |
| 110 | """ |
| 111 | |
| 112 | entity_id: str |
| 113 | voice_id: str |
| 114 | origin_commit_id: str |
| 115 | origin_op_id: str |
| 116 | |
| 117 | |
| 118 | class EntityIndexEntry(TypedDict): |
| 119 | """One entity's record in the per-track entity index. |
| 120 | |
| 121 | ``content_id`` |
| 122 | SHA-256 content hash of the note's current fields (the five ``NoteKey`` |
| 123 | fields). Updated on every mutation. |
| 124 | ``origin_commit_id`` |
| 125 | Commit where this entity was first inserted. |
| 126 | ``voice_id`` |
| 127 | Voice stream assignment, or empty string if unassigned. |
| 128 | """ |
| 129 | |
| 130 | content_id: str |
| 131 | origin_commit_id: str |
| 132 | voice_id: str |
| 133 | |
| 134 | |
| 135 | class EntityIndex(TypedDict): |
| 136 | """Complete entity index for one track at one commit. |
| 137 | |
| 138 | ``entities`` maps ``entity_id`` → :class:`EntityIndexEntry`. |
| 139 | This is the lookup table used by :func:`assign_entity_ids` to re-identify |
| 140 | notes across commits. |
| 141 | """ |
| 142 | |
| 143 | track_path: str |
| 144 | commit_id: str |
| 145 | entities: _EntityMap |
| 146 | |
| 147 | |
| 148 | # --------------------------------------------------------------------------- |
| 149 | # Entity ID assignment |
| 150 | # --------------------------------------------------------------------------- |
| 151 | |
| 152 | #: Default threshold in MIDI ticks for fuzzy timing match (≈ 10 ms at 120 BPM |
| 153 | #: with 480 ticks/beat: 480 × 0.010 × 2 ≈ 10 ticks). |
| 154 | _DEFAULT_TICK_THRESHOLD = 10 |
| 155 | |
| 156 | #: Default velocity difference threshold for fuzzy entity matching. |
| 157 | _DEFAULT_VEL_THRESHOLD = 20 |
| 158 | |
| 159 | |
| 160 | def assign_entity_ids( |
| 161 | notes: list[NoteKey], |
| 162 | prior_index: EntityIndex | None, |
| 163 | commit_id: str, |
| 164 | op_id: str, |
| 165 | *, |
| 166 | mutation_threshold_ticks: int = _DEFAULT_TICK_THRESHOLD, |
| 167 | mutation_threshold_velocity: int = _DEFAULT_VEL_THRESHOLD, |
| 168 | ) -> list[NoteEntity]: |
| 169 | """Assign stable entity IDs to a list of notes. |
| 170 | |
| 171 | Maps each note in *notes* to an entity ID from *prior_index* using a |
| 172 | three-tier matching heuristic (exact → fuzzy → new). |
| 173 | |
| 174 | Args: |
| 175 | notes: New note list (from the current commit). |
| 176 | prior_index: Entity index from the parent commit. |
| 177 | ``None`` means this is the first commit |
| 178 | for this track (all notes get new IDs). |
| 179 | commit_id: Current commit ID (stored as provenance). |
| 180 | op_id: Op log entry ID that produced these notes. |
| 181 | mutation_threshold_ticks: Max |Δtick| for fuzzy timing match. |
| 182 | mutation_threshold_velocity: Max |Δvelocity| for fuzzy match. |
| 183 | |
| 184 | Returns: |
| 185 | List of :class:`NoteEntity` objects in the same order as *notes*, |
| 186 | each with a populated ``entity_id``. |
| 187 | """ |
| 188 | if prior_index is None: |
| 189 | return [_new_entity(n, commit_id, op_id) for n in notes] |
| 190 | |
| 191 | # Build lookup: content_id → entity_id for exact matches. |
| 192 | content_to_entity: _ContentMap = {} |
| 193 | # Build list for fuzzy matching: [(entity_id, note_key_fields, entry)] |
| 194 | fuzzy_candidates: list[tuple[str, NoteKey]] = [] |
| 195 | |
| 196 | for entity_id, entry in prior_index["entities"].items(): |
| 197 | cid = entry["content_id"] |
| 198 | # Reconstruct a NoteKey from the content hash is impossible directly, |
| 199 | # so we carry a parallel lookup keyed by content_id string. |
| 200 | content_to_entity[cid] = entity_id |
| 201 | # For fuzzy matching we need the actual field values. These aren't |
| 202 | # stored in the index entry (only the hash is), so fuzzy matching |
| 203 | # operates on the NEW notes' fields against the hash. We build the |
| 204 | # fuzzy set from the incoming notes rather than the prior index. |
| 205 | _ = fuzzy_candidates # populated below |
| 206 | |
| 207 | # Build a richer map from new notes' content IDs. |
| 208 | new_by_cid: _NoteKeyBuckets = {} |
| 209 | for n in notes: |
| 210 | cid = _note_content_id(n) |
| 211 | new_by_cid.setdefault(cid, []).append(n) |
| 212 | |
| 213 | # --- Tier 1: exact content match --- |
| 214 | # Assign entity IDs to notes whose content hash appears in the prior index. |
| 215 | assigned: dict[int, str] = {} # index → entity_id |
| 216 | used_entities: set[str] = set() |
| 217 | |
| 218 | for i, note in enumerate(notes): |
| 219 | cid = _note_content_id(note) |
| 220 | if cid in content_to_entity: |
| 221 | eid = content_to_entity[cid] |
| 222 | if eid not in used_entities: |
| 223 | assigned[i] = eid |
| 224 | used_entities.add(eid) |
| 225 | |
| 226 | # --- Tier 2: fuzzy match for unassigned notes --- |
| 227 | # Build prior note field table from the original notes that produced the |
| 228 | # prior index. Since we only have hashes, we use the *new* notes as a |
| 229 | # proxy: any note with (same pitch, same channel, close tick, close vel) |
| 230 | # is a mutation candidate. |
| 231 | # |
| 232 | # Approach: for each unassigned new note, find an unused prior entity |
| 233 | # whose content_id resolves to a note with matching pitch+channel and |
| 234 | # close tick+velocity. Since we can't reverse-SHA the prior hash, we |
| 235 | # instead accept the fuzzy match if the content hash of the hypothetical |
| 236 | # un-mutated note (same pitch, channel, but old vel/tick fields) matches. |
| 237 | # |
| 238 | # In practice, callers pass both old and new note lists when they have |
| 239 | # them; the simple heuristic here covers the common agent-edit case. |
| 240 | prior_entity_ids = list(prior_index["entities"].keys()) |
| 241 | |
| 242 | for i, note in enumerate(notes): |
| 243 | if i in assigned: |
| 244 | continue |
| 245 | # Try to match against any unused prior entity by field similarity. |
| 246 | # We approximate by assuming the prior entity had similar fields. |
| 247 | best_eid: str | None = None |
| 248 | best_score = float("inf") |
| 249 | |
| 250 | for eid in prior_entity_ids: |
| 251 | if eid in used_entities: |
| 252 | continue |
| 253 | entry = prior_index["entities"][eid] |
| 254 | prior_cid = entry["content_id"] |
| 255 | |
| 256 | # Attempt to reconstruct a plausible prior note for this entity. |
| 257 | # We don't have the raw fields — approximate by checking if a |
| 258 | # note with the same pitch + channel but slightly different |
| 259 | # timing/velocity would hash to this content_id. |
| 260 | for vel_delta in range(-mutation_threshold_velocity, mutation_threshold_velocity + 1, 2): |
| 261 | for tick_delta in range(-mutation_threshold_ticks, mutation_threshold_ticks + 1, 2): |
| 262 | candidate: NoteKey = NoteKey( |
| 263 | pitch=note["pitch"], |
| 264 | velocity=max(0, min(127, note["velocity"] + vel_delta)), |
| 265 | start_tick=max(0, note["start_tick"] + tick_delta), |
| 266 | duration_ticks=note["duration_ticks"], |
| 267 | channel=note["channel"], |
| 268 | ) |
| 269 | if _note_content_id(candidate) == prior_cid: |
| 270 | score = abs(vel_delta) + abs(tick_delta) |
| 271 | if score < best_score: |
| 272 | best_score = score |
| 273 | best_eid = eid |
| 274 | break |
| 275 | if best_eid is not None and best_score == 0: |
| 276 | break |
| 277 | |
| 278 | if best_eid is not None: |
| 279 | assigned[i] = best_eid |
| 280 | used_entities.add(best_eid) |
| 281 | |
| 282 | # --- Build output --- |
| 283 | result: list[NoteEntity] = [] |
| 284 | for i, note in enumerate(notes): |
| 285 | if i in assigned: |
| 286 | entity: NoteEntity = NoteEntity( |
| 287 | pitch=note["pitch"], |
| 288 | velocity=note["velocity"], |
| 289 | start_tick=note["start_tick"], |
| 290 | duration_ticks=note["duration_ticks"], |
| 291 | channel=note["channel"], |
| 292 | entity_id=assigned[i], |
| 293 | origin_commit_id=prior_index["entities"][assigned[i]]["origin_commit_id"], |
| 294 | origin_op_id=op_id, |
| 295 | voice_id=prior_index["entities"][assigned[i]].get("voice_id", ""), |
| 296 | ) |
| 297 | else: |
| 298 | entity = _new_entity(note, commit_id, op_id) |
| 299 | result.append(entity) |
| 300 | |
| 301 | return result |
| 302 | |
| 303 | |
| 304 | def _new_entity(note: NoteKey, commit_id: str, op_id: str) -> NoteEntity: |
| 305 | """Create a :class:`NoteEntity` with a fresh UUID4 entity_id.""" |
| 306 | return NoteEntity( |
| 307 | pitch=note["pitch"], |
| 308 | velocity=note["velocity"], |
| 309 | start_tick=note["start_tick"], |
| 310 | duration_ticks=note["duration_ticks"], |
| 311 | channel=note["channel"], |
| 312 | entity_id=str(_uuid_mod.uuid4()), |
| 313 | origin_commit_id=commit_id, |
| 314 | origin_op_id=op_id, |
| 315 | voice_id="", |
| 316 | ) |
| 317 | |
| 318 | |
| 319 | # --------------------------------------------------------------------------- |
| 320 | # Entity-aware diff |
| 321 | # --------------------------------------------------------------------------- |
| 322 | |
| 323 | |
| 324 | def diff_with_entity_ids( |
| 325 | base_entities: list[NoteEntity], |
| 326 | target_entities: list[NoteEntity], |
| 327 | ticks_per_beat: int, |
| 328 | ) -> list[DomainOp]: |
| 329 | """Produce an entity-aware diff between two note lists. |
| 330 | |
| 331 | Compared to the content-hash-only diff in :mod:`~muse.plugins.midi.midi_diff`, |
| 332 | this function detects *mutations* — cases where the same entity_id appears |
| 333 | in both lists with different field values — and emits ``MutateOp`` entries |
| 334 | instead of ``DeleteOp + InsertOp`` pairs. |
| 335 | |
| 336 | Algorithm: |
| 337 | 1. Build ``entity_id → NoteEntity`` maps for base and target. |
| 338 | 2. For entities present in both: compare fields; emit ``MutateOp`` if |
| 339 | anything changed, otherwise "keep" (no op). |
| 340 | 3. For entities only in base: emit ``DeleteOp``. |
| 341 | 4. For entities only in target: emit ``InsertOp``. |
| 342 | 5. For notes without an entity_id: fall back to content-hash comparison |
| 343 | (insert/delete only, no mutation tracking). |
| 344 | |
| 345 | Args: |
| 346 | base_entities: Notes from the ancestor commit, with entity IDs. |
| 347 | target_entities: Notes from the current commit, with entity IDs. |
| 348 | ticks_per_beat: Used for human-readable summaries. |
| 349 | |
| 350 | Returns: |
| 351 | Ordered list of :class:`~muse.domain.DomainOp` entries. |
| 352 | """ |
| 353 | ops: list[DomainOp] = [] |
| 354 | |
| 355 | # Separate tracked (have entity_id) from untracked notes. |
| 356 | base_tracked: _NoteEntityMap = {} |
| 357 | base_untracked: list[NoteEntity] = [] |
| 358 | for note in base_entities: |
| 359 | if "entity_id" in note and note.get("entity_id"): |
| 360 | base_tracked[note["entity_id"]] = note |
| 361 | else: |
| 362 | base_untracked.append(note) |
| 363 | |
| 364 | target_tracked: _NoteEntityMap = {} |
| 365 | target_untracked: list[NoteEntity] = [] |
| 366 | for note in target_entities: |
| 367 | if "entity_id" in note and note.get("entity_id"): |
| 368 | target_tracked[note["entity_id"]] = note |
| 369 | else: |
| 370 | target_untracked.append(note) |
| 371 | |
| 372 | # --- Tracked: mutate, keep, insert, delete --- |
| 373 | all_entity_ids = set(base_tracked) | set(target_tracked) |
| 374 | |
| 375 | for eid in sorted(all_entity_ids): |
| 376 | base_note = base_tracked.get(eid) |
| 377 | target_note = target_tracked.get(eid) |
| 378 | |
| 379 | if base_note is not None and target_note is not None: |
| 380 | old_cid = _note_content_id(_entity_to_key(base_note)) |
| 381 | new_cid = _note_content_id(_entity_to_key(target_note)) |
| 382 | if old_cid == new_cid: |
| 383 | continue # unchanged |
| 384 | |
| 385 | fields = _field_diff(base_note, target_note) |
| 386 | base_note_key = _entity_to_key(base_note) |
| 387 | target_note_key = _entity_to_key(target_note) |
| 388 | ops.append( |
| 389 | MutateOp( |
| 390 | op="mutate", |
| 391 | address=f"note:entity:{eid}", |
| 392 | entity_id=eid, |
| 393 | old_content_id=old_cid, |
| 394 | new_content_id=new_cid, |
| 395 | fields=fields, |
| 396 | old_summary=_note_summary(base_note_key, ticks_per_beat), |
| 397 | new_summary=_note_summary(target_note_key, ticks_per_beat), |
| 398 | position=None, |
| 399 | ) |
| 400 | ) |
| 401 | |
| 402 | elif base_note is not None: |
| 403 | cid = _note_content_id(_entity_to_key(base_note)) |
| 404 | ops.append( |
| 405 | DeleteOp( |
| 406 | op="delete", |
| 407 | address=f"note:entity:{eid}", |
| 408 | position=None, |
| 409 | content_id=cid, |
| 410 | content_summary=_note_summary(_entity_to_key(base_note), ticks_per_beat), |
| 411 | ) |
| 412 | ) |
| 413 | |
| 414 | else: |
| 415 | assert target_note is not None |
| 416 | cid = _note_content_id(_entity_to_key(target_note)) |
| 417 | ops.append( |
| 418 | InsertOp( |
| 419 | op="insert", |
| 420 | address=f"note:entity:{eid}", |
| 421 | position=None, |
| 422 | content_id=cid, |
| 423 | content_summary=_note_summary(_entity_to_key(target_note), ticks_per_beat), |
| 424 | ) |
| 425 | ) |
| 426 | |
| 427 | # --- Untracked: fall back to content-hash insert/delete --- |
| 428 | base_content_ids = {_note_content_id(_entity_to_key(n)) for n in base_untracked} |
| 429 | target_content_ids = {_note_content_id(_entity_to_key(n)) for n in target_untracked} |
| 430 | |
| 431 | for note in base_untracked: |
| 432 | cid = _note_content_id(_entity_to_key(note)) |
| 433 | if cid not in target_content_ids: |
| 434 | ops.append( |
| 435 | DeleteOp( |
| 436 | op="delete", |
| 437 | address="note:untracked", |
| 438 | position=None, |
| 439 | content_id=cid, |
| 440 | content_summary=_note_summary(_entity_to_key(note), ticks_per_beat), |
| 441 | ) |
| 442 | ) |
| 443 | |
| 444 | for note in target_untracked: |
| 445 | cid = _note_content_id(_entity_to_key(note)) |
| 446 | if cid not in base_content_ids: |
| 447 | ops.append( |
| 448 | InsertOp( |
| 449 | op="insert", |
| 450 | address="note:untracked", |
| 451 | position=None, |
| 452 | content_id=cid, |
| 453 | content_summary=_note_summary(_entity_to_key(note), ticks_per_beat), |
| 454 | ) |
| 455 | ) |
| 456 | |
| 457 | return ops |
| 458 | |
| 459 | |
| 460 | def _entity_to_key(entity: NoteEntity) -> NoteKey: |
| 461 | """Extract the five NoteKey fields from a NoteEntity.""" |
| 462 | return NoteKey( |
| 463 | pitch=entity["pitch"], |
| 464 | velocity=entity["velocity"], |
| 465 | start_tick=entity["start_tick"], |
| 466 | duration_ticks=entity["duration_ticks"], |
| 467 | channel=entity["channel"], |
| 468 | ) |
| 469 | |
| 470 | |
| 471 | def _field_diff(base: NoteEntity, target: NoteEntity) -> _FieldMutationMap: |
| 472 | """Return a FieldMutation map for all fields that changed.""" |
| 473 | mutations: _FieldMutationMap = {} |
| 474 | # Unpack into flat tuples to avoid variable-key TypedDict access. |
| 475 | base_vals: tuple[int, int, int, int, int] = ( |
| 476 | base["pitch"], base["velocity"], base["start_tick"], base["duration_ticks"], base["channel"] |
| 477 | ) |
| 478 | target_vals: tuple[int, int, int, int, int] = ( |
| 479 | target["pitch"], target["velocity"], target["start_tick"], target["duration_ticks"], target["channel"] |
| 480 | ) |
| 481 | names = ("pitch", "velocity", "start_tick", "duration_ticks", "channel") |
| 482 | for name, bv, tv in zip(names, base_vals, target_vals): |
| 483 | if bv != tv: |
| 484 | mutations[name] = FieldMutation(old=str(bv), new=str(tv)) |
| 485 | return mutations |
| 486 | |
| 487 | |
| 488 | # --------------------------------------------------------------------------- |
| 489 | # Entity index I/O |
| 490 | # --------------------------------------------------------------------------- |
| 491 | |
| 492 | |
| 493 | def build_entity_index( |
| 494 | entities: list[NoteEntity], |
| 495 | track_path: str, |
| 496 | commit_id: str, |
| 497 | ) -> EntityIndex: |
| 498 | """Build an :class:`EntityIndex` from a list of :class:`NoteEntity` objects. |
| 499 | |
| 500 | Notes without an ``entity_id`` are skipped (untracked notes do not appear |
| 501 | in the index). |
| 502 | |
| 503 | Args: |
| 504 | entities: Note entities from the current commit. |
| 505 | track_path: Workspace-relative MIDI file path. |
| 506 | commit_id: Current commit ID. |
| 507 | |
| 508 | Returns: |
| 509 | A populated :class:`EntityIndex`. |
| 510 | """ |
| 511 | entries: _EntityMap = {} |
| 512 | for note in entities: |
| 513 | eid = note.get("entity_id", "") |
| 514 | if not eid: |
| 515 | continue |
| 516 | entries[eid] = EntityIndexEntry( |
| 517 | content_id=_note_content_id(_entity_to_key(note)), |
| 518 | origin_commit_id=note.get("origin_commit_id", commit_id), |
| 519 | voice_id=note.get("voice_id", ""), |
| 520 | ) |
| 521 | return EntityIndex( |
| 522 | track_path=track_path, |
| 523 | commit_id=commit_id, |
| 524 | entities=entries, |
| 525 | ) |
| 526 | |
| 527 | |
| 528 | def _index_path(repo_root: pathlib.Path, commit_id: str, track_path: str) -> pathlib.Path: |
| 529 | safe_track = track_path.replace("/", "_").replace(".", "_") |
| 530 | sha = hashlib.sha256(track_path.encode()).hexdigest()[:8] |
| 531 | return ( |
| 532 | repo_root |
| 533 | / _ENTITY_INDEX_DIR |
| 534 | / commit_id[:16] |
| 535 | / f"{safe_track}_{sha}.json" |
| 536 | ) |
| 537 | |
| 538 | |
| 539 | def write_entity_index( |
| 540 | repo_root: pathlib.Path, |
| 541 | commit_id: str, |
| 542 | track_path: str, |
| 543 | index: EntityIndex, |
| 544 | ) -> None: |
| 545 | """Persist *index* to ``.muse/entity_index/<commit_id>/<track>.json``. |
| 546 | |
| 547 | Creates parent directories as needed. Safe to call multiple times — |
| 548 | an existing file is overwritten. |
| 549 | |
| 550 | Args: |
| 551 | repo_root: Repository root. |
| 552 | commit_id: Commit ID for the snapshot this index belongs to. |
| 553 | track_path: Workspace-relative MIDI file path. |
| 554 | index: The entity index to persist. |
| 555 | """ |
| 556 | path = _index_path(repo_root, commit_id, track_path) |
| 557 | path.parent.mkdir(parents=True, exist_ok=True) |
| 558 | path.write_text(json.dumps(index, indent=2) + "\n") |
| 559 | logger.debug( |
| 560 | "✅ Entity index written: %d entities for %r @ %s", |
| 561 | len(index["entities"]), |
| 562 | track_path, |
| 563 | commit_id[:8], |
| 564 | ) |
| 565 | |
| 566 | |
| 567 | def read_entity_index( |
| 568 | repo_root: pathlib.Path, |
| 569 | commit_id: str, |
| 570 | track_path: str, |
| 571 | ) -> EntityIndex | None: |
| 572 | """Load the entity index for *track_path* at *commit_id*. |
| 573 | |
| 574 | Args: |
| 575 | repo_root: Repository root. |
| 576 | commit_id: Commit ID. |
| 577 | track_path: Workspace-relative MIDI file path. |
| 578 | |
| 579 | Returns: |
| 580 | The :class:`EntityIndex`, or ``None`` when no index file exists. |
| 581 | """ |
| 582 | path = _index_path(repo_root, commit_id, track_path) |
| 583 | if not path.exists(): |
| 584 | return None |
| 585 | try: |
| 586 | raw: EntityIndex = json.loads(path.read_text()) |
| 587 | return raw |
| 588 | except (json.JSONDecodeError, KeyError) as exc: |
| 589 | logger.warning("⚠️ Corrupt entity index %s: %s", path, exc) |
| 590 | return None |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago