similarity.py
python
| 1 | """muse similarity — compute musical similarity score between two commits. |
| 2 | |
| 3 | Compares two Muse commits across up to five musical dimensions and |
| 4 | produces per-dimension scores plus a weighted overall score. An AI |
| 5 | agent can use this output to calibrate how much new material to generate: |
| 6 | a similarity of 0.9 suggests a small variation; 0.4 suggests a major |
| 7 | rework. |
| 8 | |
| 9 | Dimensions |
| 10 | ---------- |
| 11 | - harmonic — key, chord vocabulary, chord progression similarity |
| 12 | - rhythmic — tempo, time signature, rhythmic density |
| 13 | - melodic — motif reuse, interval contour, pitch range |
| 14 | - structural — section layout (intro, verse, bridge, outro lengths) |
| 15 | - dynamic — velocity profile, crescendo/decrescendo patterns |
| 16 | |
| 17 | Scores are normalized to [0.0, 1.0]: |
| 18 | 1.0 = identical |
| 19 | 0.0 = completely different |
| 20 | |
| 21 | Output (default):: |
| 22 | |
| 23 | Similarity: HEAD~10 vs HEAD |
| 24 | |
| 25 | Harmonic: 0.45 ██████████░░░░░░░░░░ (key modulation, new chords) |
| 26 | Rhythmic: 0.89 ████████████████████ (same tempo, slightly more swing) |
| 27 | Melodic: 0.72 ██████████████████░░ (same motifs, extended range) |
| 28 | Structural: 0.65 █████████████░░░░░░░ (bridge added, intro shortened) |
| 29 | Dynamic: 0.55 ███████████░░░░░░░░░ (much louder, crescendo added) |
| 30 | |
| 31 | Overall: 0.65 (Significantly different — major rework) |
| 32 | |
| 33 | Flags |
| 34 | ----- |
| 35 | COMMIT-A First commit ref (required). |
| 36 | COMMIT-B Second commit ref (required). |
| 37 | --dimensions TEXT Comma-separated subset of dimensions (default: all five). |
| 38 | --section TEXT Scope comparison to a named section/region. |
| 39 | --track TEXT Scope comparison to a specific track. |
| 40 | --json Emit machine-readable JSON. |
| 41 | --threshold FLOAT Exit 1 if overall similarity < threshold (for scripting). |
| 42 | """ |
| 43 | from __future__ import annotations |
| 44 | |
| 45 | import asyncio |
| 46 | import json |
| 47 | import logging |
| 48 | import pathlib |
| 49 | from typing import Optional |
| 50 | |
| 51 | import typer |
| 52 | from sqlalchemy.ext.asyncio import AsyncSession |
| 53 | from typing_extensions import TypedDict |
| 54 | |
| 55 | from maestro.muse_cli._repo import require_repo |
| 56 | from maestro.muse_cli.db import open_session |
| 57 | from maestro.muse_cli.errors import ExitCode |
| 58 | |
| 59 | logger = logging.getLogger(__name__) |
| 60 | |
| 61 | app = typer.Typer() |
| 62 | |
| 63 | # --------------------------------------------------------------------------- |
| 64 | # Constants |
| 65 | # --------------------------------------------------------------------------- |
| 66 | |
| 67 | DIMENSION_NAMES: tuple[str, ...] = ( |
| 68 | "harmonic", |
| 69 | "rhythmic", |
| 70 | "melodic", |
| 71 | "structural", |
| 72 | "dynamic", |
| 73 | ) |
| 74 | |
| 75 | _ALL_DIMENSIONS: frozenset[str] = frozenset(DIMENSION_NAMES) |
| 76 | |
| 77 | # Dimension weights for computing the overall score. |
| 78 | # Harmonic and melodic carry the most musical identity. |
| 79 | _DIMENSION_WEIGHTS: dict[str, float] = { |
| 80 | "harmonic": 0.25, |
| 81 | "rhythmic": 0.20, |
| 82 | "melodic": 0.25, |
| 83 | "structural": 0.15, |
| 84 | "dynamic": 0.15, |
| 85 | } |
| 86 | |
| 87 | # Score thresholds for the human-readable quality label. |
| 88 | _LABEL_THRESHOLDS: tuple[tuple[float, str], ...] = ( |
| 89 | (0.90, "Nearly identical — minimal change"), |
| 90 | (0.75, "Highly similar — subtle variation"), |
| 91 | (0.60, "Moderately similar — noticeable changes"), |
| 92 | (0.40, "Significantly different — major rework"), |
| 93 | (0.00, "Completely different — new direction"), |
| 94 | ) |
| 95 | |
| 96 | _BAR_WIDTH = 20 # characters in the progress bar |
| 97 | |
| 98 | |
| 99 | # --------------------------------------------------------------------------- |
| 100 | # Named result types (stable CLI contract) |
| 101 | # --------------------------------------------------------------------------- |
| 102 | |
| 103 | |
| 104 | class DimensionScore(TypedDict): |
| 105 | """Score for a single musical dimension. |
| 106 | |
| 107 | Contract: |
| 108 | dimension — one of the five canonical dimension names |
| 109 | score — normalized similarity in [0.0, 1.0] |
| 110 | note — brief human-readable interpretation of the difference |
| 111 | """ |
| 112 | |
| 113 | dimension: str |
| 114 | score: float |
| 115 | note: str |
| 116 | |
| 117 | |
| 118 | class SimilarityResult(TypedDict): |
| 119 | """Overall similarity result between two commits. |
| 120 | |
| 121 | Contract: |
| 122 | commit_a — first commit ref as provided by the caller |
| 123 | commit_b — second commit ref as provided by the caller |
| 124 | dimensions — list of per-dimension scores (may be a subset) |
| 125 | overall — weighted overall similarity in [0.0, 1.0] |
| 126 | label — human-readable summary of the overall score |
| 127 | max_divergence — dimension name with the lowest score |
| 128 | """ |
| 129 | |
| 130 | commit_a: str |
| 131 | commit_b: str |
| 132 | dimensions: list[DimensionScore] |
| 133 | overall: float |
| 134 | label: str |
| 135 | max_divergence: str |
| 136 | |
| 137 | |
| 138 | # --------------------------------------------------------------------------- |
| 139 | # Stub data — realistic placeholder until MIDI data is queryable per-commit |
| 140 | # --------------------------------------------------------------------------- |
| 141 | |
| 142 | # Stub per-dimension scores and interpretive notes. |
| 143 | _STUB_DIMENSION_DATA: dict[str, tuple[float, str]] = { |
| 144 | "harmonic": (0.45, "key modulation, new chords"), |
| 145 | "rhythmic": (0.89, "same tempo, slightly more swing"), |
| 146 | "melodic": (0.72, "same motifs, extended range"), |
| 147 | "structural": (0.65, "bridge added, intro shortened"), |
| 148 | "dynamic": (0.55, "much louder, crescendo added"), |
| 149 | } |
| 150 | |
| 151 | |
| 152 | def _stub_dimension_scores(dimensions: frozenset[str]) -> list[DimensionScore]: |
| 153 | """Return stub DimensionScore rows for the requested dimensions. |
| 154 | |
| 155 | The ordering mirrors DIMENSION_NAMES so output is always stable. |
| 156 | """ |
| 157 | return [ |
| 158 | DimensionScore( |
| 159 | dimension=dim, |
| 160 | score=_STUB_DIMENSION_DATA[dim][0], |
| 161 | note=_STUB_DIMENSION_DATA[dim][1], |
| 162 | ) |
| 163 | for dim in DIMENSION_NAMES |
| 164 | if dim in dimensions |
| 165 | ] |
| 166 | |
| 167 | |
| 168 | # --------------------------------------------------------------------------- |
| 169 | # Score computation helpers |
| 170 | # --------------------------------------------------------------------------- |
| 171 | |
| 172 | |
| 173 | def _weighted_overall(scores: list[DimensionScore]) -> float: |
| 174 | """Compute a weighted overall similarity score. |
| 175 | |
| 176 | Uses _DIMENSION_WEIGHTS when a dimension is in the standard set; |
| 177 | falls back to equal weighting for any custom/unknown dimension. |
| 178 | """ |
| 179 | if not scores: |
| 180 | return 0.0 |
| 181 | total_weight = sum(_DIMENSION_WEIGHTS.get(s["dimension"], 1.0) for s in scores) |
| 182 | weighted_sum = sum( |
| 183 | s["score"] * _DIMENSION_WEIGHTS.get(s["dimension"], 1.0) for s in scores |
| 184 | ) |
| 185 | if total_weight == 0.0: |
| 186 | return 0.0 |
| 187 | return round(weighted_sum / total_weight, 4) |
| 188 | |
| 189 | |
| 190 | def _overall_label(overall: float) -> str: |
| 191 | """Return a human-readable label for an overall similarity score.""" |
| 192 | for threshold, label in _LABEL_THRESHOLDS: |
| 193 | if overall >= threshold: |
| 194 | return label |
| 195 | return _LABEL_THRESHOLDS[-1][1] |
| 196 | |
| 197 | |
| 198 | def _max_divergence_dimension(scores: list[DimensionScore]) -> str: |
| 199 | """Return the name of the dimension with the lowest similarity score.""" |
| 200 | if not scores: |
| 201 | return "" |
| 202 | return min(scores, key=lambda s: s["score"])["dimension"] |
| 203 | |
| 204 | |
| 205 | def build_similarity_result( |
| 206 | commit_a: str, |
| 207 | commit_b: str, |
| 208 | scores: list[DimensionScore], |
| 209 | ) -> SimilarityResult: |
| 210 | """Assemble a complete SimilarityResult from scored dimensions. |
| 211 | |
| 212 | Separated from the async core so tests can validate the computation |
| 213 | without I/O. |
| 214 | |
| 215 | Args: |
| 216 | commit_a: First commit ref. |
| 217 | commit_b: Second commit ref. |
| 218 | scores: Per-dimension scores (may be a subset of all five). |
| 219 | |
| 220 | Returns: |
| 221 | A SimilarityResult with all fields populated. |
| 222 | """ |
| 223 | overall = _weighted_overall(scores) |
| 224 | return SimilarityResult( |
| 225 | commit_a=commit_a, |
| 226 | commit_b=commit_b, |
| 227 | dimensions=scores, |
| 228 | overall=overall, |
| 229 | label=_overall_label(overall), |
| 230 | max_divergence=_max_divergence_dimension(scores), |
| 231 | ) |
| 232 | |
| 233 | |
| 234 | # --------------------------------------------------------------------------- |
| 235 | # Rendering helpers |
| 236 | # --------------------------------------------------------------------------- |
| 237 | |
| 238 | |
| 239 | def _bar(score: float, width: int = _BAR_WIDTH) -> str: |
| 240 | """Render a Unicode block progress bar for a score in [0, 1].""" |
| 241 | filled = round(score * width) |
| 242 | return "\u2588" * filled + "\u2591" * (width - filled) |
| 243 | |
| 244 | |
| 245 | def render_similarity_text(result: SimilarityResult) -> str: |
| 246 | """Render a human-readable similarity report. |
| 247 | |
| 248 | Called by the CLI and by tests so the rendering contract can be |
| 249 | validated independently of Typer. |
| 250 | |
| 251 | Args: |
| 252 | result: A fully populated SimilarityResult. |
| 253 | |
| 254 | Returns: |
| 255 | Multi-line string ready to echo to stdout. |
| 256 | """ |
| 257 | lines: list[str] = [ |
| 258 | f"Similarity: {result['commit_a']} vs {result['commit_b']}", |
| 259 | "", |
| 260 | ] |
| 261 | |
| 262 | label_width = max((len(s["dimension"]) for s in result["dimensions"]), default=0) + 1 |
| 263 | for score in result["dimensions"]: |
| 264 | dim_label = f"{score['dimension'].capitalize()}:".ljust(label_width + 1) |
| 265 | bar = _bar(score["score"]) |
| 266 | lines.append( |
| 267 | f" {dim_label} {score['score']:.2f} {bar} ({score['note']})" |
| 268 | ) |
| 269 | |
| 270 | lines.append("") |
| 271 | lines.append(f" Overall: {result['overall']:.2f} ({result['label']})") |
| 272 | |
| 273 | if result["max_divergence"]: |
| 274 | lines.append( |
| 275 | f" Max divergence: {result['max_divergence']} dimension" |
| 276 | ) |
| 277 | |
| 278 | return "\n".join(lines) |
| 279 | |
| 280 | |
| 281 | def render_similarity_json(result: SimilarityResult) -> str: |
| 282 | """Render a SimilarityResult as indented JSON.""" |
| 283 | return json.dumps(dict(result), indent=2) |
| 284 | |
| 285 | |
| 286 | # --------------------------------------------------------------------------- |
| 287 | # Testable async core |
| 288 | # --------------------------------------------------------------------------- |
| 289 | |
| 290 | |
| 291 | async def _similarity_async( |
| 292 | *, |
| 293 | root: pathlib.Path, |
| 294 | session: AsyncSession, |
| 295 | commit_a: str, |
| 296 | commit_b: str, |
| 297 | dimensions: frozenset[str], |
| 298 | section: Optional[str], |
| 299 | track: Optional[str], |
| 300 | threshold: Optional[float], |
| 301 | as_json: bool, |
| 302 | ) -> int: |
| 303 | """Core similarity logic — fully injectable for tests. |
| 304 | |
| 305 | Resolves both commit refs against the .muse/ directory, produces |
| 306 | stub per-dimension scores for the requested dimensions, assembles a |
| 307 | SimilarityResult, renders output, and returns an exit code. |
| 308 | |
| 309 | Args: |
| 310 | root: Repository root (directory containing .muse/). |
| 311 | session: Open async DB session (reserved for full implementation). |
| 312 | commit_a: First commit ref. |
| 313 | commit_b: Second commit ref. |
| 314 | dimensions: Set of dimension names to compute. |
| 315 | section: Named section to scope comparison (stub: noted). |
| 316 | track: Named track to scope comparison (stub: noted). |
| 317 | threshold: Exit 1 if overall < threshold; None means no check. |
| 318 | as_json: Emit JSON instead of text. |
| 319 | |
| 320 | Returns: |
| 321 | Integer exit code — 0 on success, 1 if below threshold. |
| 322 | """ |
| 323 | muse_dir = root / ".muse" |
| 324 | _head_ref = (muse_dir / "HEAD").read_text().strip() |
| 325 | |
| 326 | if section: |
| 327 | typer.echo( |
| 328 | f"WARNING: --section {section}: section-scoped comparison not yet implemented." |
| 329 | ) |
| 330 | if track: |
| 331 | typer.echo( |
| 332 | f"WARNING: --track {track}: track-scoped comparison not yet implemented." |
| 333 | ) |
| 334 | |
| 335 | scores = _stub_dimension_scores(dimensions) |
| 336 | result = build_similarity_result(commit_a, commit_b, scores) |
| 337 | |
| 338 | if as_json: |
| 339 | typer.echo(render_similarity_json(result)) |
| 340 | else: |
| 341 | typer.echo(render_similarity_text(result)) |
| 342 | |
| 343 | if threshold is not None and result["overall"] < threshold: |
| 344 | return int(1) |
| 345 | |
| 346 | return int(ExitCode.SUCCESS) |
| 347 | |
| 348 | |
| 349 | # --------------------------------------------------------------------------- |
| 350 | # Typer command |
| 351 | # --------------------------------------------------------------------------- |
| 352 | |
| 353 | |
| 354 | @app.callback(invoke_without_command=True) |
| 355 | def similarity( |
| 356 | ctx: typer.Context, |
| 357 | commit_a: str = typer.Argument( |
| 358 | ..., |
| 359 | help="First commit ref to compare.", |
| 360 | metavar="COMMIT-A", |
| 361 | ), |
| 362 | commit_b: str = typer.Argument( |
| 363 | ..., |
| 364 | help="Second commit ref to compare.", |
| 365 | metavar="COMMIT-B", |
| 366 | ), |
| 367 | dimensions: Optional[str] = typer.Option( |
| 368 | None, |
| 369 | "--dimensions", |
| 370 | help=( |
| 371 | "Comma-separated list of dimensions to compare. " |
| 372 | "Valid: harmonic,rhythmic,melodic,structural,dynamic. " |
| 373 | "Default: all five." |
| 374 | ), |
| 375 | metavar="DIMS", |
| 376 | ), |
| 377 | section: Optional[str] = typer.Option( |
| 378 | None, |
| 379 | "--section", |
| 380 | help="Scope comparison to a named section/region.", |
| 381 | metavar="TEXT", |
| 382 | ), |
| 383 | track: Optional[str] = typer.Option( |
| 384 | None, |
| 385 | "--track", |
| 386 | help="Scope comparison to a specific track.", |
| 387 | metavar="TEXT", |
| 388 | ), |
| 389 | as_json: bool = typer.Option( |
| 390 | False, |
| 391 | "--json", |
| 392 | help="Emit machine-readable JSON output.", |
| 393 | ), |
| 394 | threshold: Optional[float] = typer.Option( |
| 395 | None, |
| 396 | "--threshold", |
| 397 | help=( |
| 398 | "Exit 1 if the overall similarity score is below this value. " |
| 399 | "Useful in scripts to detect major reworks." |
| 400 | ), |
| 401 | metavar="FLOAT", |
| 402 | ), |
| 403 | ) -> None: |
| 404 | """Compute musical similarity score between two commits. |
| 405 | |
| 406 | Produces per-dimension scores (harmonic, rhythmic, melodic, structural, |
| 407 | dynamic) and a weighted overall score in [0.0, 1.0]. |
| 408 | |
| 409 | Example:: |
| 410 | |
| 411 | muse similarity HEAD~10 HEAD |
| 412 | muse similarity HEAD~10 HEAD --dimensions harmonic,rhythmic |
| 413 | muse similarity HEAD~10 HEAD --json |
| 414 | muse similarity HEAD~10 HEAD --threshold 0.5 |
| 415 | """ |
| 416 | # -- Validate flags first — before repo detection so bad input fails fast -- |
| 417 | active_dimensions: frozenset[str] |
| 418 | if dimensions is not None: |
| 419 | requested = frozenset( |
| 420 | d.strip().lower() for d in dimensions.split(",") if d.strip() |
| 421 | ) |
| 422 | invalid = requested - _ALL_DIMENSIONS |
| 423 | if invalid: |
| 424 | typer.echo( |
| 425 | f"Unknown dimension(s): {', '.join(sorted(invalid))}. " |
| 426 | f"Valid: {', '.join(DIMENSION_NAMES)}" |
| 427 | ) |
| 428 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 429 | if not requested: |
| 430 | typer.echo("--dimensions must specify at least one dimension.") |
| 431 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 432 | active_dimensions = requested |
| 433 | else: |
| 434 | active_dimensions = _ALL_DIMENSIONS |
| 435 | |
| 436 | if threshold is not None and not (0.0 <= threshold <= 1.0): |
| 437 | typer.echo(f"--threshold {threshold!r} out of range [0.0, 1.0].") |
| 438 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 439 | |
| 440 | root = require_repo() |
| 441 | |
| 442 | async def _run() -> int: |
| 443 | async with open_session() as session: |
| 444 | return await _similarity_async( |
| 445 | root=root, |
| 446 | session=session, |
| 447 | commit_a=commit_a, |
| 448 | commit_b=commit_b, |
| 449 | dimensions=active_dimensions, |
| 450 | section=section, |
| 451 | track=track, |
| 452 | threshold=threshold, |
| 453 | as_json=as_json, |
| 454 | ) |
| 455 | |
| 456 | try: |
| 457 | exit_code = asyncio.run(_run()) |
| 458 | if exit_code != 0: |
| 459 | raise typer.Exit(code=exit_code) |
| 460 | except typer.Exit: |
| 461 | raise |
| 462 | except Exception as exc: |
| 463 | typer.echo(f"muse similarity failed: {exc}") |
| 464 | logger.error("muse similarity error: %s", exc, exc_info=True) |
| 465 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |