cgcardona / muse public
muse_log_graph.py python
279 lines 9.5 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Log Graph — serialize the commit DAG as Swift-ready JSON.
2
3 Pure read-only projection layer. Fetches all variations for a project
4 in a single query, builds the DAG in memory, and performs a stable
5 topological sort. O(N + E) time complexity.
6
7 Boundary rules:
8 - Must NOT import StateStore, executor, handlers, LLM code,
9 drift engine, merge engine, or checkout modules.
10 - May import muse_repository (for bulk queries).
11 """
12
13 from __future__ import annotations
14
15 import heapq
16 import logging
17 from collections import defaultdict
18 from dataclasses import dataclass
19 from sqlalchemy.ext.asyncio import AsyncSession
20
21 from pydantic import BaseModel, Field
22
23 from maestro.services.muse_repository import VariationSummary, get_variations_for_project
24
25 logger = logging.getLogger(__name__)
26
27
28 # ── Pydantic response models ──────────────────────────────────────────────────
29
30
31 class MuseLogNodeResponse(BaseModel):
32 """Wire representation of a single node in the Muse commit DAG.
33
34 Produced by ``MuseLogNode.to_response()`` and serialised as JSON by the
35 ``GET /muse/log`` endpoint. Field names are camelCase to match the Stori
36 DAW Swift convention.
37
38 Attributes:
39 id: UUID of this variation (commit).
40 parent: UUID of the first (or only) parent variation, or ``None`` for
41 the root commit.
42 parent2: UUID of the second parent for merge commits; ``None`` for
43 ordinary (linear) commits.
44 isHead: ``True`` if this node is the current HEAD pointer for the
45 project (i.e. the variation the DAW currently has loaded).
46 timestamp: POSIX timestamp (seconds since epoch, float) of when the
47 variation was committed. Used for topological sort tie-breaking
48 and for display in the DAW's history panel.
49 intent: Free-text intent string supplied when the variation was created
50 (e.g. ``"add bass groove"``), or ``None`` if none was provided.
51 regions: List of region IDs whose MIDI content was affected by this
52 variation. Empty for root / no-op commits.
53 """
54
55 id: str = Field(description="UUID of this variation (commit).")
56 parent: str | None = Field(
57 description="UUID of the first parent variation, or None for the root commit."
58 )
59 parent2: str | None = Field(
60 description=(
61 "UUID of the second parent for merge commits; "
62 "None for ordinary (linear) commits."
63 )
64 )
65 isHead: bool = Field(
66 description=(
67 "True if this node is the current HEAD pointer"
68 "the variation the DAW currently has loaded."
69 )
70 )
71 timestamp: float = Field(
72 description=(
73 "POSIX timestamp (seconds since epoch) of when the variation was committed. "
74 "Used for topological sort tie-breaking and history-panel display."
75 )
76 )
77 intent: str | None = Field(
78 description=(
79 "Free-text intent string supplied at commit time "
80 "(e.g. 'add bass groove'), or None if none was provided."
81 )
82 )
83 regions: list[str] = Field(
84 description=(
85 "List of region IDs whose MIDI content was affected by this variation. "
86 "Empty for root or no-op commits."
87 )
88 )
89
90
91 class MuseLogGraphResponse(BaseModel):
92 """Wire representation of the full Muse commit DAG for a project.
93
94 Produced by ``MuseLogGraph.to_response()`` and returned directly by
95 ``GET /muse/log``. The Swift frontend renders this as a visual commit
96 timeline, highlighting the HEAD node and drawing parent edges.
97
98 Attributes:
99 projectId: UUID of the project this DAG belongs to.
100 head: UUID of the current HEAD variation, or ``None`` if no HEAD has
101 been set yet (brand-new project with no commits).
102 nodes: Topologically sorted list of all variations (commits) for this
103 project. Parents always appear before their children; ties are
104 broken by timestamp then variation UUID.
105 """
106
107 projectId: str = Field(
108 description="UUID of the project this DAG belongs to."
109 )
110 head: str | None = Field(
111 description=(
112 "UUID of the current HEAD variation, or None if no HEAD has been set yet "
113 "(brand-new project with no commits)."
114 )
115 )
116 nodes: list[MuseLogNodeResponse] = Field(
117 description=(
118 "Topologically sorted list of all variations (commits) for this project. "
119 "Parents always appear before their children; ties broken by timestamp then UUID."
120 )
121 )
122
123
124 # ── Internal domain dataclasses ───────────────────────────────────────────────
125
126
127 @dataclass(frozen=True)
128 class MuseLogNode:
129 """A single node in the commit DAG."""
130
131 variation_id: str
132 parent: str | None
133 parent2: str | None
134 is_head: bool
135 timestamp: float
136 intent: str | None
137 affected_regions: tuple[str, ...]
138
139 def to_response(self) -> MuseLogNodeResponse:
140 """Convert this internal node to its Pydantic wire representation.
141
142 Translates snake_case internal field names to the camelCase names
143 expected by the Stori DAW frontend, and converts the immutable
144 ``affected_regions`` tuple to a plain list.
145
146 Returns:
147 ``MuseLogNodeResponse`` ready for JSON serialisation by FastAPI.
148 """
149 return MuseLogNodeResponse(
150 id=self.variation_id,
151 parent=self.parent,
152 parent2=self.parent2,
153 isHead=self.is_head,
154 timestamp=self.timestamp,
155 intent=self.intent,
156 regions=list(self.affected_regions),
157 )
158
159
160 @dataclass(frozen=True)
161 class MuseLogGraph:
162 """The full commit DAG for a project, topologically ordered."""
163
164 project_id: str
165 head: str | None
166 nodes: tuple[MuseLogNode, ...]
167
168 def to_response(self) -> MuseLogGraphResponse:
169 """Convert this internal graph to its Pydantic wire representation.
170
171 Calls ``MuseLogNode.to_response()`` on every node in topological order
172 and wraps them in a ``MuseLogGraphResponse``.
173
174 Returns:
175 ``MuseLogGraphResponse`` ready for JSON serialisation by FastAPI.
176 """
177 return MuseLogGraphResponse(
178 projectId=self.project_id,
179 head=self.head,
180 nodes=[n.to_response() for n in self.nodes],
181 )
182
183
184 async def build_muse_log_graph(
185 session: AsyncSession,
186 project_id: str,
187 ) -> MuseLogGraph:
188 """Build the full commit DAG for a project.
189
190 Performs a single bulk query, then computes the topological ordering
191 in memory. Parents always appear before children; ties are broken
192 by timestamp (earliest first), then by variation_id for determinism.
193 """
194 summaries = await get_variations_for_project(session, project_id)
195
196 if not summaries:
197 return MuseLogGraph(project_id=project_id, head=None, nodes=())
198
199 nodes = _build_nodes(summaries)
200 sorted_nodes = _topological_sort(nodes)
201 head_id = _find_head(summaries)
202
203 logger.info(
204 "✅ Log graph built: project=%s, %d nodes, head=%s",
205 project_id[:8], len(sorted_nodes), (head_id or "none")[:8],
206 )
207
208 return MuseLogGraph(
209 project_id=project_id,
210 head=head_id,
211 nodes=tuple(sorted_nodes),
212 )
213
214
215 def _build_nodes(summaries: list[VariationSummary]) -> list[MuseLogNode]:
216 """Convert VariationSummary rows into MuseLogNode instances."""
217 return [
218 MuseLogNode(
219 variation_id=s.variation_id,
220 parent=s.parent_variation_id,
221 parent2=s.parent2_variation_id,
222 is_head=s.is_head,
223 timestamp=s.created_at.timestamp(),
224 intent=s.intent if s.intent else None,
225 affected_regions=s.affected_regions,
226 )
227 for s in summaries
228 ]
229
230
231 def _find_head(summaries: list[VariationSummary]) -> str | None:
232 """Return the HEAD variation_id, or None if no HEAD is set."""
233 for s in summaries:
234 if s.is_head:
235 return s.variation_id
236 return None
237
238
239 def _topological_sort(nodes: list[MuseLogNode]) -> list[MuseLogNode]:
240 """Stable topological sort via Kahn's algorithm.
241
242 Ordering guarantees:
243 1. Parents always appear before children.
244 2. Tie-break by timestamp (earliest first).
245 3. Final tie-break by variation_id (lexicographic).
246 """
247 by_id: dict[str, MuseLogNode] = {n.variation_id: n for n in nodes}
248 known_ids = set(by_id.keys())
249
250 children: dict[str, list[str]] = defaultdict(list)
251 in_degree: dict[str, int] = {n.variation_id: 0 for n in nodes}
252
253 for node in nodes:
254 if node.parent and node.parent in known_ids:
255 children[node.parent].append(node.variation_id)
256 in_degree[node.variation_id] += 1
257 if node.parent2 and node.parent2 in known_ids:
258 children[node.parent2].append(node.variation_id)
259 in_degree[node.variation_id] += 1
260
261 heap: list[tuple[float, str]] = []
262 for vid, deg in in_degree.items():
263 if deg == 0:
264 n = by_id[vid]
265 heapq.heappush(heap, (n.timestamp, vid))
266
267 result: list[MuseLogNode] = []
268
269 while heap:
270 _, vid = heapq.heappop(heap)
271 result.append(by_id[vid])
272
273 for child_id in children[vid]:
274 in_degree[child_id] -= 1
275 if in_degree[child_id] == 0:
276 child = by_id[child_id]
277 heapq.heappush(heap, (child.timestamp, child_id))
278
279 return result