cgcardona / muse public
test_muse_divergence.py python
641 lines 23.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for muse divergence — musical divergence between two CLI branches.
2
3 Covers:
4 - Common ancestor auto-detection (merge-base computation).
5 - Per-dimension divergence scores and level labels.
6 - JSON output format.
7 - Multiple divergent commits across branches.
8 - Boundary seal (no forbidden imports in service or command).
9
10 Naming convention: ``test_<behaviour>_<scenario>``
11 """
12 from __future__ import annotations
13
14 import ast
15 import datetime
16 import hashlib
17 import json
18 import pathlib
19 from collections.abc import AsyncGenerator
20
21 import pytest
22 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
23
24 from maestro.db.database import Base
25 from maestro.db import muse_models # noqa: F401 — registers ORM models with Base
26 from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot
27 from maestro.services.muse_divergence import (
28 ALL_DIMENSIONS,
29 DivergenceLevel,
30 MuseDivergenceResult,
31 classify_path,
32 compute_dimension_divergence,
33 compute_divergence,
34 score_to_level,
35 )
36
37
38 # ---------------------------------------------------------------------------
39 # Fixtures
40 # ---------------------------------------------------------------------------
41
42
43 @pytest.fixture
44 async def async_session() -> AsyncGenerator[AsyncSession, None]:
45 """In-memory SQLite session with the full Maestro schema."""
46 engine = create_async_engine(
47 "sqlite+aiosqlite:///:memory:",
48 connect_args={"check_same_thread": False},
49 )
50 async with engine.begin() as conn:
51 await conn.run_sync(Base.metadata.create_all)
52 Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
53 async with Session() as session:
54 yield session
55 await engine.dispose()
56
57
58 # ---------------------------------------------------------------------------
59 # Helpers
60 # ---------------------------------------------------------------------------
61
62
63 _REPO_ID = "test-repo-001"
64 _COUNTER = 0
65
66
67 def _unique_hash(prefix: str) -> str:
68 """Deterministic 64-char hash for test IDs."""
69 global _COUNTER
70 _COUNTER += 1
71 raw = f"{prefix}-{_COUNTER}"
72 return hashlib.sha256(raw.encode()).hexdigest()
73
74
75 def _make_snapshot(manifest: dict[str, str]) -> MuseCliSnapshot:
76 """Create a :class:`MuseCliSnapshot` from a manifest dict."""
77 parts = sorted(f"{k}:{v}" for k, v in manifest.items())
78 snap_id = hashlib.sha256("|".join(parts).encode()).hexdigest()
79 return MuseCliSnapshot(snapshot_id=snap_id, manifest=manifest)
80
81
82 def _make_commit(
83 snapshot: MuseCliSnapshot,
84 branch: str,
85 parent: MuseCliCommit | None = None,
86 parent2: MuseCliCommit | None = None,
87 seq: int = 0,
88 ) -> MuseCliCommit:
89 """Create a :class:`MuseCliCommit` linked to *snapshot* on *branch*."""
90 cid = _unique_hash(f"commit-{branch}-{seq}")
91 return MuseCliCommit(
92 commit_id=cid,
93 repo_id=_REPO_ID,
94 branch=branch,
95 snapshot_id=snapshot.snapshot_id,
96 parent_commit_id=parent.commit_id if parent else None,
97 parent2_commit_id=parent2.commit_id if parent2 else None,
98 message=f"commit on {branch} seq={seq}",
99 author="test",
100 committed_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
101 + datetime.timedelta(seconds=seq),
102 )
103
104
105 async def _save(
106 session: AsyncSession,
107 snapshot: MuseCliSnapshot,
108 commit: MuseCliCommit,
109 ) -> None:
110 """Persist *snapshot* and *commit* to the session."""
111 existing_snap = await session.get(MuseCliSnapshot, snapshot.snapshot_id)
112 if existing_snap is None:
113 session.add(snapshot)
114 session.add(commit)
115 await session.flush()
116
117
118 # ---------------------------------------------------------------------------
119 # 1 — classify_path (pure, no DB)
120 # ---------------------------------------------------------------------------
121
122
123 class TestClassifyPath:
124
125 def test_melodic_keywords_matched(self) -> None:
126 assert "melodic" in classify_path("lead_guitar.mid")
127 assert "melodic" in classify_path("Melody_Line.midi")
128 assert "melodic" in classify_path("SOLO.MID")
129
130 def test_harmonic_keywords_matched(self) -> None:
131 assert "harmonic" in classify_path("chord_progression.txt")
132 assert "harmonic" in classify_path("harmony_track.mid")
133 assert "harmonic" in classify_path("key_of_d.json")
134
135 def test_rhythmic_keywords_matched(self) -> None:
136 assert "rhythmic" in classify_path("drum_pattern.mid")
137 assert "rhythmic" in classify_path("beat.mid")
138 assert "rhythmic" in classify_path("groove_01.wav")
139
140 def test_structural_keywords_matched(self) -> None:
141 assert "structural" in classify_path("chorus.mid")
142 assert "structural" in classify_path("verse_01.mid")
143 assert "structural" in classify_path("intro.mid")
144 assert "structural" in classify_path("bridge_section.mid")
145
146 def test_dynamic_keywords_matched(self) -> None:
147 assert "dynamic" in classify_path("mixdown.wav")
148 assert "dynamic" in classify_path("master_vol.mid")
149 assert "dynamic" in classify_path("level_control.json")
150
151 def test_unclassified_path_returns_empty_set(self) -> None:
152 assert classify_path("project.muse") == set()
153 assert classify_path("readme.txt") == set()
154
155 def test_multi_dimension_path(self) -> None:
156 result = classify_path("melody_key.mid")
157 assert "melodic" in result
158 assert "harmonic" in result
159
160
161 # ---------------------------------------------------------------------------
162 # 2 — score_to_level (pure)
163 # ---------------------------------------------------------------------------
164
165
166 class TestScoreToLevel:
167
168 def test_zero_is_none(self) -> None:
169 assert score_to_level(0.0) is DivergenceLevel.NONE
170
171 def test_boundary_0_15_is_low(self) -> None:
172 assert score_to_level(0.15) is DivergenceLevel.LOW
173
174 def test_boundary_0_40_is_med(self) -> None:
175 assert score_to_level(0.40) is DivergenceLevel.MED
176
177 def test_boundary_0_70_is_high(self) -> None:
178 assert score_to_level(0.70) is DivergenceLevel.HIGH
179
180 def test_full_score_is_high(self) -> None:
181 assert score_to_level(1.0) is DivergenceLevel.HIGH
182
183
184 # ---------------------------------------------------------------------------
185 # 3 — compute_dimension_divergence (pure)
186 # ---------------------------------------------------------------------------
187
188
189 class TestComputeDimensionDivergence:
190
191 def test_no_files_on_either_branch_gives_none_level(self) -> None:
192 result = compute_dimension_divergence("melodic", set(), set())
193 assert result.score == 0.0
194 assert result.level is DivergenceLevel.NONE
195 assert result.branch_a_summary == "0 melodic file(s) changed"
196
197 def test_same_melodic_file_on_both_branches_gives_zero_score(self) -> None:
198 a = {"melody.mid"}
199 b = {"melody.mid"}
200 result = compute_dimension_divergence("melodic", a, b)
201 assert result.score == 0.0
202 assert result.level is DivergenceLevel.NONE
203
204 def test_melodic_only_on_branch_a_gives_high_score(self) -> None:
205 a = {"lead_guitar.mid", "solo_01.mid"}
206 b: set[str] = set()
207 result = compute_dimension_divergence("melodic", a, b)
208 assert result.score == 1.0
209 assert result.level is DivergenceLevel.HIGH
210
211 def test_partial_overlap_gives_low_score(self) -> None:
212 a = {"melody.mid", "lead.mid", "solo.mid"}
213 b = {"melody.mid", "vocal.mid"}
214 result = compute_dimension_divergence("melodic", a, b)
215 # union = {melody, lead, solo, vocal} = 4; sym_diff = {lead, solo, vocal} = 3
216 assert 0.5 < result.score < 1.0
217
218 def test_non_matching_paths_ignored(self) -> None:
219 a = {"beat.mid", "drum.mid"}
220 b = {"beat.mid"}
221 result = compute_dimension_divergence("melodic", a, b)
222 # Neither 'beat' nor 'drum' matches melodic keywords
223 assert result.score == 0.0
224 assert result.branch_a_summary == "0 melodic file(s) changed"
225
226 def test_description_mentions_dimension_name(self) -> None:
227 result = compute_dimension_divergence("harmonic", {"chord.mid"}, set())
228 assert "harmonic" in result.description.lower()
229
230
231 # ---------------------------------------------------------------------------
232 # 4 — compute_divergence (async, DB)
233 # ---------------------------------------------------------------------------
234
235
236 class TestComputeDivergenceDetectsCommonAncestor:
237 """test_muse_divergence_detects_common_ancestor_automatically"""
238
239 @pytest.mark.anyio
240 async def test_detects_common_ancestor_automatically(
241 self, async_session: AsyncSession
242 ) -> None:
243 """
244 root (main) ─── branch-a (adds melody.mid)
245 └── branch-b (adds chord.mid)
246
247 Common ancestor = root commit.
248 """
249 base_snap = _make_snapshot({})
250 base_commit = _make_commit(base_snap, "main", seq=0)
251 await _save(async_session, base_snap, base_commit)
252
253 a_snap = _make_snapshot({"melody.mid": "hash-melody"})
254 a_commit = _make_commit(a_snap, "branch-a", parent=base_commit, seq=1)
255 await _save(async_session, a_snap, a_commit)
256
257 b_snap = _make_snapshot({"chord.mid": "hash-chord"})
258 b_commit = _make_commit(b_snap, "branch-b", parent=base_commit, seq=2)
259 await _save(async_session, b_snap, b_commit)
260
261 await async_session.commit()
262
263 result = await compute_divergence(
264 async_session,
265 repo_id=_REPO_ID,
266 branch_a="branch-a",
267 branch_b="branch-b",
268 )
269
270 assert isinstance(result, MuseDivergenceResult)
271 assert result.common_ancestor == base_commit.commit_id
272 assert result.branch_a == "branch-a"
273 assert result.branch_b == "branch-b"
274
275 @pytest.mark.anyio
276 async def test_since_override_skips_merge_base_computation(
277 self, async_session: AsyncSession
278 ) -> None:
279 """``--since`` overrides automatic LCA detection."""
280 base_snap = _make_snapshot({})
281 base_commit = _make_commit(base_snap, "main", seq=10)
282 await _save(async_session, base_snap, base_commit)
283
284 a_snap = _make_snapshot({"lead.mid": "h1"})
285 a_commit = _make_commit(a_snap, "dev-a", parent=base_commit, seq=11)
286 await _save(async_session, a_snap, a_commit)
287
288 b_snap = _make_snapshot({"harm.mid": "h2"})
289 b_commit = _make_commit(b_snap, "dev-b", parent=base_commit, seq=12)
290 await _save(async_session, b_snap, b_commit)
291
292 await async_session.commit()
293
294 result = await compute_divergence(
295 async_session,
296 repo_id=_REPO_ID,
297 branch_a="dev-a",
298 branch_b="dev-b",
299 since=base_commit.commit_id,
300 )
301
302 assert result.common_ancestor == base_commit.commit_id
303
304 @pytest.mark.anyio
305 async def test_missing_branch_raises_value_error(
306 self, async_session: AsyncSession
307 ) -> None:
308 """A branch with no commits raises ``ValueError``."""
309 snap = _make_snapshot({})
310 commit = _make_commit(snap, "existing-branch", seq=20)
311 await _save(async_session, snap, commit)
312 await async_session.commit()
313
314 with pytest.raises(ValueError, match="no commits"):
315 await compute_divergence(
316 async_session,
317 repo_id=_REPO_ID,
318 branch_a="existing-branch",
319 branch_b="nonexistent-branch",
320 )
321
322
323 class TestComputeDivergencePerDimensionScores:
324 """test_muse_divergence_per_dimension_scores"""
325
326 @pytest.mark.anyio
327 async def test_melodic_divergence_high_when_only_branch_a_has_melody(
328 self, async_session: AsyncSession
329 ) -> None:
330 """Branch A adds melody.mid; Branch B adds chord.mid.
331
332 Melodic: only A → HIGH. Harmonic: only B → HIGH.
333 Rhythmic: neither → NONE.
334 """
335 base_snap = _make_snapshot({})
336 base_commit = _make_commit(base_snap, "main", seq=30)
337 await _save(async_session, base_snap, base_commit)
338
339 a_snap = _make_snapshot({"melody.mid": "m1"})
340 a_commit = _make_commit(a_snap, "feat-melody", parent=base_commit, seq=31)
341 await _save(async_session, a_snap, a_commit)
342
343 b_snap = _make_snapshot({"chord_sheet.txt": "c1"})
344 b_commit = _make_commit(b_snap, "feat-harmony", parent=base_commit, seq=32)
345 await _save(async_session, b_snap, b_commit)
346
347 await async_session.commit()
348
349 result = await compute_divergence(
350 async_session,
351 repo_id=_REPO_ID,
352 branch_a="feat-melody",
353 branch_b="feat-harmony",
354 )
355
356 dim_by_name = {d.dimension: d for d in result.dimensions}
357
358 assert dim_by_name["melodic"].level is DivergenceLevel.HIGH
359 assert dim_by_name["melodic"].score == 1.0
360
361 assert dim_by_name["harmonic"].level is DivergenceLevel.HIGH
362 assert dim_by_name["harmonic"].score == 1.0
363
364 assert dim_by_name["rhythmic"].level is DivergenceLevel.NONE
365 assert dim_by_name["rhythmic"].score == 0.0
366
367 @pytest.mark.anyio
368 async def test_rhythmic_divergence_none_when_same_beat_file_on_both(
369 self, async_session: AsyncSession
370 ) -> None:
371 """Both branches add the same beat.mid — rhythmic divergence = 0."""
372 base_snap = _make_snapshot({})
373 base_commit = _make_commit(base_snap, "main", seq=40)
374 await _save(async_session, base_snap, base_commit)
375
376 a_snap = _make_snapshot({"beat.mid": "b1", "melody.mid": "m1"})
377 a_commit = _make_commit(a_snap, "rhy-a", parent=base_commit, seq=41)
378 await _save(async_session, a_snap, a_commit)
379
380 b_snap = _make_snapshot({"beat.mid": "b1", "chord.mid": "c1"})
381 b_commit = _make_commit(b_snap, "rhy-b", parent=base_commit, seq=42)
382 await _save(async_session, b_snap, b_commit)
383
384 await async_session.commit()
385
386 result = await compute_divergence(
387 async_session,
388 repo_id=_REPO_ID,
389 branch_a="rhy-a",
390 branch_b="rhy-b",
391 )
392
393 dim_by_name = {d.dimension: d for d in result.dimensions}
394 assert dim_by_name["rhythmic"].score == 0.0
395 assert dim_by_name["rhythmic"].level is DivergenceLevel.NONE
396
397 @pytest.mark.anyio
398 async def test_dimensions_filter_limits_output(
399 self, async_session: AsyncSession
400 ) -> None:
401 """``dimensions=['melodic', 'harmonic']`` returns only those two."""
402 base_snap = _make_snapshot({})
403 base_commit = _make_commit(base_snap, "main", seq=50)
404 await _save(async_session, base_snap, base_commit)
405
406 a_snap = _make_snapshot({"melody.mid": "m1"})
407 a_commit = _make_commit(a_snap, "filter-a", parent=base_commit, seq=51)
408 await _save(async_session, a_snap, a_commit)
409
410 b_snap = _make_snapshot({"chord.mid": "c1"})
411 b_commit = _make_commit(b_snap, "filter-b", parent=base_commit, seq=52)
412 await _save(async_session, b_snap, b_commit)
413
414 await async_session.commit()
415
416 result = await compute_divergence(
417 async_session,
418 repo_id=_REPO_ID,
419 branch_a="filter-a",
420 branch_b="filter-b",
421 dimensions=["melodic", "harmonic"],
422 )
423
424 assert len(result.dimensions) == 2
425 returned_names = {d.dimension for d in result.dimensions}
426 assert returned_names == {"melodic", "harmonic"}
427
428 @pytest.mark.anyio
429 async def test_overall_score_is_mean_of_dimension_scores(
430 self, async_session: AsyncSession
431 ) -> None:
432 """``overall_score`` equals the mean of individual dimension scores."""
433 base_snap = _make_snapshot({})
434 base_commit = _make_commit(base_snap, "main", seq=60)
435 await _save(async_session, base_snap, base_commit)
436
437 a_snap = _make_snapshot({"melody.mid": "m1"})
438 a_commit = _make_commit(a_snap, "overall-a", parent=base_commit, seq=61)
439 await _save(async_session, a_snap, a_commit)
440
441 b_snap = _make_snapshot({"melody.mid": "m1"})
442 b_commit = _make_commit(b_snap, "overall-b", parent=base_commit, seq=62)
443 await _save(async_session, b_snap, b_commit)
444
445 await async_session.commit()
446
447 result = await compute_divergence(
448 async_session,
449 repo_id=_REPO_ID,
450 branch_a="overall-a",
451 branch_b="overall-b",
452 )
453
454 computed_mean = round(
455 sum(d.score for d in result.dimensions) / len(result.dimensions), 4
456 )
457 assert result.overall_score == computed_mean
458
459
460 class TestComputeDivergenceJsonOutput:
461 """test_muse_divergence_json_output"""
462
463 @pytest.mark.anyio
464 async def test_result_serializes_to_valid_json(
465 self, async_session: AsyncSession
466 ) -> None:
467 """MuseDivergenceResult fields round-trip cleanly through json.dumps."""
468 base_snap = _make_snapshot({})
469 base_commit = _make_commit(base_snap, "main", seq=70)
470 await _save(async_session, base_snap, base_commit)
471
472 a_snap = _make_snapshot({"lead.mid": "h1"})
473 a_commit = _make_commit(a_snap, "json-a", parent=base_commit, seq=71)
474 await _save(async_session, a_snap, a_commit)
475
476 b_snap = _make_snapshot({"chord.mid": "h2"})
477 b_commit = _make_commit(b_snap, "json-b", parent=base_commit, seq=72)
478 await _save(async_session, b_snap, b_commit)
479
480 await async_session.commit()
481
482 result = await compute_divergence(
483 async_session,
484 repo_id=_REPO_ID,
485 branch_a="json-a",
486 branch_b="json-b",
487 )
488
489 data = {
490 "branch_a": result.branch_a,
491 "branch_b": result.branch_b,
492 "common_ancestor": result.common_ancestor,
493 "overall_score": result.overall_score,
494 "dimensions": [
495 {
496 "dimension": d.dimension,
497 "level": d.level.value,
498 "score": d.score,
499 "description": d.description,
500 "branch_a_summary": d.branch_a_summary,
501 "branch_b_summary": d.branch_b_summary,
502 }
503 for d in result.dimensions
504 ],
505 }
506 serialised = json.dumps(data)
507 parsed = json.loads(serialised)
508
509 assert parsed["branch_a"] == "json-a"
510 assert parsed["branch_b"] == "json-b"
511 assert parsed["common_ancestor"] == base_commit.commit_id
512 assert isinstance(parsed["overall_score"], float)
513 assert len(parsed["dimensions"]) == len(ALL_DIMENSIONS)
514 for dim in parsed["dimensions"]:
515 assert "dimension" in dim
516 assert "level" in dim
517 assert "score" in dim
518 assert "description" in dim
519
520
521 class TestComputeDivergenceMultipleDivergentCommits:
522 """test_muse_divergence_multiple_divergent_commits"""
523
524 @pytest.mark.anyio
525 async def test_multiple_commits_accumulate_changes_correctly(
526 self, async_session: AsyncSession
527 ) -> None:
528 """Branch A adds files across multiple commits; tip manifest reflects all.
529
530 root → [branch-multi-a: commit1 adds melody.mid, commit2 adds solo.mid]
531 → [branch-multi-b: commit1 adds chord.mid]
532
533 Melodic divergence on branch A should count both melody.mid and solo.mid.
534 """
535 base_snap = _make_snapshot({})
536 base_commit = _make_commit(base_snap, "main", seq=80)
537 await _save(async_session, base_snap, base_commit)
538
539 # Branch A: two sequential commits
540 a1_snap = _make_snapshot({"melody.mid": "m1"})
541 a1_commit = _make_commit(a1_snap, "branch-multi-a", parent=base_commit, seq=81)
542 await _save(async_session, a1_snap, a1_commit)
543
544 a2_snap = _make_snapshot({"melody.mid": "m1", "solo.mid": "s1"})
545 a2_commit = _make_commit(a2_snap, "branch-multi-a", parent=a1_commit, seq=82)
546 await _save(async_session, a2_snap, a2_commit)
547
548 # Branch B: single commit
549 b1_snap = _make_snapshot({"chord.mid": "c1"})
550 b1_commit = _make_commit(b1_snap, "branch-multi-b", parent=base_commit, seq=83)
551 await _save(async_session, b1_snap, b1_commit)
552
553 await async_session.commit()
554
555 result = await compute_divergence(
556 async_session,
557 repo_id=_REPO_ID,
558 branch_a="branch-multi-a",
559 branch_b="branch-multi-b",
560 )
561
562 dim_by_name = {d.dimension: d for d in result.dimensions}
563
564 # Both melody.mid and solo.mid are on branch A → 2 melodic files
565 assert "2 melodic" in dim_by_name["melodic"].branch_a_summary
566 assert dim_by_name["melodic"].level is DivergenceLevel.HIGH
567
568 @pytest.mark.anyio
569 async def test_disjoint_histories_common_ancestor_is_none(
570 self, async_session: AsyncSession
571 ) -> None:
572 """Branches with no common history produce ``common_ancestor=None``."""
573 snap_x = _make_snapshot({"melody.mid": "mx"})
574 commit_x = _make_commit(snap_x, "isolated-x", seq=90)
575 await _save(async_session, snap_x, commit_x)
576
577 snap_y = _make_snapshot({"chord.mid": "cy"})
578 commit_y = _make_commit(snap_y, "isolated-y", seq=91)
579 await _save(async_session, snap_y, commit_y)
580
581 await async_session.commit()
582
583 result = await compute_divergence(
584 async_session,
585 repo_id=_REPO_ID,
586 branch_a="isolated-x",
587 branch_b="isolated-y",
588 )
589
590 assert result.common_ancestor is None
591
592
593 # ---------------------------------------------------------------------------
594 # 5 — Boundary seal
595 # ---------------------------------------------------------------------------
596
597
598 class TestDivergenceBoundarySeal:
599 """Verify that service and command modules do not import forbidden modules."""
600
601 _SERVICES_DIR = (
602 pathlib.Path(__file__).resolve().parent.parent
603 / "maestro"
604 / "services"
605 )
606 _COMMANDS_DIR = (
607 pathlib.Path(__file__).resolve().parent.parent
608 / "maestro"
609 / "muse_cli"
610 / "commands"
611 )
612
613 def test_service_does_not_import_forbidden_modules(self) -> None:
614 filepath = self._SERVICES_DIR / "muse_divergence.py"
615 tree = ast.parse(filepath.read_text())
616 forbidden = {
617 "state_store", "executor", "maestro_handlers",
618 "maestro_editing", "mcp", "muse_merge_base",
619 }
620 for node in ast.walk(tree):
621 if isinstance(node, ast.ImportFrom) and node.module:
622 for fb in forbidden:
623 assert fb not in node.module, (
624 f"muse_divergence imports forbidden module: {node.module}"
625 )
626
627 def test_command_does_not_import_state_store(self) -> None:
628 filepath = self._COMMANDS_DIR / "divergence.py"
629 tree = ast.parse(filepath.read_text())
630 forbidden = {"state_store", "executor", "maestro_handlers", "mcp"}
631 for node in ast.walk(tree):
632 if isinstance(node, ast.ImportFrom) and node.module:
633 for fb in forbidden:
634 assert fb not in node.module, (
635 f"divergence command imports forbidden module: {node.module}"
636 )
637
638 def test_service_starts_with_future_annotations(self) -> None:
639 filepath = self._SERVICES_DIR / "muse_divergence.py"
640 source = filepath.read_text()
641 assert "from __future__ import annotations" in source