cgcardona / muse public
divergence.py python
267 lines 7.9 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse divergence — show how two branches have diverged musically.
2
3 Computes a per-dimension musical divergence report between two CLI branches.
4
5 Usage
6 -----
7 ::
8
9 muse divergence <branch-a> <branch-b> [OPTIONS]
10
11 Options
12 -------
13 - ``--since <commit>`` Common ancestor commit ID (auto-detected if omitted).
14 - ``--dimensions <name>`` Dimension(s) to analyse — may be repeated
15 (default: all five dimensions).
16 - ``--json`` Machine-readable JSON output.
17
18 Output example (text mode)
19 --------------------------
20 ::
21
22 Musical Divergence: feature/guitar-version vs feature/piano-version
23 Common ancestor: 7e3a1f2c
24
25 Melodic divergence: HIGH — High melodic divergence — branches took different creative paths.
26 feature/guitar-version: 2 melodic file(s) changed
27 feature/piano-version: 0 melodic file(s) changed
28
29 Harmonic divergence: MED — Moderate harmonic divergence — different directions.
30 feature/guitar-version: 1 harmonic file(s) changed
31 feature/piano-version: 2 harmonic file(s) changed
32
33 Overall divergence score: 0.60
34
35 Output example (JSON mode)
36 --------------------------
37 ::
38
39 {
40 "branch_a": "feature/guitar-version",
41 "branch_b": "feature/piano-version",
42 "common_ancestor": "7e3a1f2c...",
43 "overall_score": 0.60,
44 "dimensions": [
45 {
46 "dimension": "melodic",
47 "level": "high",
48 "score": 1.0,
49 "description": "High melodic divergence — branches took different creative paths.",
50 "branch_a_summary": "2 melodic file(s) changed",
51 "branch_b_summary": "0 melodic file(s) changed"
52 }
53 ]
54 }
55 """
56
57 from __future__ import annotations
58
59 import asyncio
60 import json
61 import logging
62 import pathlib
63 from typing import Optional, TypedDict
64
65 import typer
66 from sqlalchemy.ext.asyncio import AsyncSession
67
68 from maestro.muse_cli._repo import require_repo
69 from maestro.muse_cli.db import open_session
70 from maestro.muse_cli.errors import ExitCode
71 from maestro.services.muse_divergence import (
72 DivergenceLevel,
73 MuseDivergenceResult,
74 compute_divergence,
75 )
76
77 logger = logging.getLogger(__name__)
78
79 app = typer.Typer()
80
81
82 class _DimJsonEntry(TypedDict):
83 """JSON-serialisable representation of a single dimension divergence entry."""
84
85 dimension: str
86 level: str
87 score: float
88 description: str
89 branch_a_summary: str
90 branch_b_summary: str
91
92
93 class _DivergenceJson(TypedDict):
94 """JSON-serialisable representation of a full divergence result."""
95
96 branch_a: str
97 branch_b: str
98 common_ancestor: str | None
99 overall_score: float
100 dimensions: list[_DimJsonEntry]
101
102 _LEVEL_LABELS: dict[DivergenceLevel, str] = {
103 DivergenceLevel.NONE: "NONE",
104 DivergenceLevel.LOW: "LOW ",
105 DivergenceLevel.MED: "MED ",
106 DivergenceLevel.HIGH: "HIGH",
107 }
108
109
110 # ---------------------------------------------------------------------------
111 # Renderers
112 # ---------------------------------------------------------------------------
113
114
115 def render_text(result: MuseDivergenceResult) -> None:
116 """Write a human-readable divergence report via :func:`typer.echo`.
117
118 Args:
119 result: The divergence result to render.
120 """
121 ancestor_short = result.common_ancestor[:8] if result.common_ancestor else "none"
122 typer.echo(f"Musical Divergence: {result.branch_a} vs {result.branch_b}")
123 typer.echo(f"Common ancestor: {ancestor_short}")
124 typer.echo("")
125 for dim in result.dimensions:
126 label = _LEVEL_LABELS[dim.level]
127 typer.echo(
128 f"{dim.dimension.capitalize()} divergence:\t{label} — {dim.description}"
129 )
130 typer.echo(f" {result.branch_a}: {dim.branch_a_summary}")
131 typer.echo(f" {result.branch_b}: {dim.branch_b_summary}")
132 typer.echo("")
133 typer.echo(f"Overall divergence score: {result.overall_score:.4f}")
134
135
136 def render_json(result: MuseDivergenceResult) -> None:
137 """Write a machine-readable JSON divergence report via :func:`typer.echo`.
138
139 Args:
140 result: The divergence result to render.
141 """
142 data: _DivergenceJson = {
143 "branch_a": result.branch_a,
144 "branch_b": result.branch_b,
145 "common_ancestor": result.common_ancestor,
146 "overall_score": result.overall_score,
147 "dimensions": [
148 {
149 "dimension": d.dimension,
150 "level": d.level.value,
151 "score": d.score,
152 "description": d.description,
153 "branch_a_summary": d.branch_a_summary,
154 "branch_b_summary": d.branch_b_summary,
155 }
156 for d in result.dimensions
157 ],
158 }
159 typer.echo(json.dumps(data, indent=2))
160
161
162 # ---------------------------------------------------------------------------
163 # Testable async core
164 # ---------------------------------------------------------------------------
165
166
167 async def _divergence_async(
168 *,
169 branch_a: str,
170 branch_b: str,
171 root: pathlib.Path,
172 session: AsyncSession,
173 since: str | None,
174 dimensions: list[str],
175 output_json: bool,
176 ) -> None:
177 """Core divergence logic — injectable for unit tests.
178
179 Reads ``repo_id`` from ``.muse/repo.json``, calls :func:`compute_divergence`,
180 and writes output via the appropriate renderer.
181
182 Args:
183 branch_a: First branch name.
184 branch_b: Second branch name.
185 root: Repository root (directory containing ``.muse/``).
186 session: Open async DB session.
187 since: Common ancestor commit ID override (``None`` → auto-detect).
188 dimensions: Dimensions to analyse (empty → all).
189 output_json: If ``True``, render JSON; otherwise render text.
190 """
191 muse_dir = root / ".muse"
192 repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text())
193 repo_id = repo_data["repo_id"]
194
195 try:
196 result = await compute_divergence(
197 session,
198 repo_id=repo_id,
199 branch_a=branch_a,
200 branch_b=branch_b,
201 since=since,
202 dimensions=dimensions if dimensions else None,
203 )
204 except ValueError as exc:
205 typer.echo(f"❌ {exc}")
206 raise typer.Exit(code=ExitCode.USER_ERROR)
207
208 if output_json:
209 render_json(result)
210 else:
211 render_text(result)
212
213
214 # ---------------------------------------------------------------------------
215 # Typer command
216 # ---------------------------------------------------------------------------
217
218
219 @app.callback(invoke_without_command=True)
220 def divergence(
221 ctx: typer.Context,
222 branch_a: str = typer.Argument(..., help="First branch."),
223 branch_b: str = typer.Argument(..., help="Second branch."),
224 since: Optional[str] = typer.Option(
225 None,
226 "--since",
227 help="Common ancestor commit ID (auto-detected if omitted).",
228 ),
229 dimensions: list[str] = typer.Option(
230 [],
231 "--dimensions",
232 help="Musical dimension to analyse — may be repeated. Default: all.",
233 ),
234 output_json: bool = typer.Option(
235 False,
236 "--json/--no-json",
237 help="Output machine-readable JSON.",
238 ),
239 ) -> None:
240 """Show how two branches have diverged musically.
241
242 Finds the common ancestor of BRANCH_A and BRANCH_B, then reports
243 per-dimension musical divergence scores (melodic, harmonic, rhythmic,
244 structural, dynamic) and an overall divergence score.
245 """
246 root = require_repo()
247
248 async def _run() -> None:
249 async with open_session() as session:
250 await _divergence_async(
251 branch_a=branch_a,
252 branch_b=branch_b,
253 root=root,
254 session=session,
255 since=since,
256 dimensions=dimensions,
257 output_json=output_json,
258 )
259
260 try:
261 asyncio.run(_run())
262 except typer.Exit:
263 raise
264 except Exception as exc:
265 typer.echo(f"❌ muse divergence failed: {exc}")
266 logger.error("❌ muse divergence error: %s", exc, exc_info=True)
267 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)