gabriel / musehub public
musehub_divergence.py python
539 lines 17.7 KB
Raw
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 10 days ago
1 """MuseHub Divergence Engine — commit divergence between two remote branches.
2
3 Computes per-dimension divergence scores by comparing the commit history on
4 two branches since their common ancestor (merge base), using commit message
5 keyword classification to determine which areas of the codebase each commit
6 touches.
7
8 Dimensions analysed
9 -------------------
10 - ``interface`` — api, route, endpoint, handler, request, response, schema, wire
11 - ``data`` — model, migration, db, database, table, query, schema, orm
12 - ``logic`` — service, worker, job, queue, task, background, algorithm, core
13 - ``tests`` — test, spec, fixture, assert, mock, coverage, pytest
14 - ``infrastructure`` — config, setting, env, deploy, docker, ci, pipeline, release
15
16 Score formula (per dimension)
17 ------------------------------
18 Given the sets of commit messages classified to dimension D on branch A
19 (``a_dim``) and branch B (``b_dim``) since the merge base:
20
21 score = |symmetric_difference(a_dim, b_dim)| / |union(a_dim, b_dim)|
22
23 Score 0.0 = both branches changed exactly the same things in this dimension.
24 Score 1.0 = no overlap — completely diverged.
25
26 Boundary rules
27 --------------
28 - Must NOT import StateStore, executor, MCP tools, or handlers.
29 - May import ORM models from musehub.db domain-specific modules.
30 """
31
32 import logging
33 import re
34 from dataclasses import dataclass
35 from enum import StrEnum
36
37 from sqlalchemy.ext.asyncio import AsyncSession
38 from sqlalchemy.future import select
39
40 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef
41 from musehub.models.musehub import ProposalDiffDimensionScore, ProposalDiffResponse
42 from musehub.types.json_types import StrDict
43
44 logger = logging.getLogger(__name__)
45
46 # ---------------------------------------------------------------------------
47 # Constants
48 # ---------------------------------------------------------------------------
49
50 ALL_DIMENSIONS: tuple[str, ...] = (
51 "melodic",
52 "harmonic",
53 "rhythmic",
54 "structural",
55 "dynamic",
56 )
57
58 type _DimensionPatternMap = dict[str, tuple[str, ...]]
59
60 #: Keyword patterns used to classify commit messages into musical dimensions.
61 _DIMENSION_PATTERNS: _DimensionPatternMap = {
62 "melodic": ("melody", "lead", "solo", "pitch", "note", "motif", "riff", "lick", "tune", "phrase", "melo"),
63 "harmonic": ("chord", "harmony", "scale", "mode", "interval", "progression", "voicing", "key"),
64 "rhythmic": ("tempo", "bpm", "drum", "groove", "beat", "rhythm", "meter", "pulse", "swing", "sync"),
65 "structural": ("bridge", "section", "verse", "chorus", "intro", "outro", "refrain", "hook", "coda", "form", "arrange", "structure"),
66 "dynamic": ("reverb", "mix", "master", "level", "dynamic", "velocity", "volume", "expression", "accent", "eq", "filter", "compress", "effect"),
67 }
68
69 #: Compiled regex matching song-section names in commit messages.
70 _SECTION_KEYWORDS: tuple[str, ...] = (
71 "Verse", "Chorus", "Bridge", "Intro", "Outro",
72 "Refrain", "Hook", "Coda", "Pre-chorus", "Interlude",
73 )
74 _SECTION_RE: re.Pattern[str] = re.compile(
75 rf"\b({'|'.join(re.escape(k) for k in _SECTION_KEYWORDS)})\b",
76 re.IGNORECASE,
77 )
78
79
80 def extract_affected_sections(messages: tuple[str, ...]) -> list[str]:
81 """Return the section names mentioned across *messages*, in keyword-definition order.
82
83 Args:
84 messages: Commit message strings to scan.
85
86 Returns:
87 Deduplicated list of section names ordered by :data:`_SECTION_KEYWORDS`.
88 """
89 found: set[str] = set()
90 for msg in messages:
91 for m in _SECTION_RE.finditer(msg):
92 found.add(m.group(1).lower())
93 return [kw for kw in _SECTION_KEYWORDS if kw.lower() in found]
94
95
96 # ---------------------------------------------------------------------------
97 # Result types
98 # ---------------------------------------------------------------------------
99
100
101 class MuseHubDivergenceLevel(StrEnum):
102 """Qualitative label for a per-dimension or overall divergence score.
103
104 Thresholds mirror the CLI divergence engine for consistency.
105
106 - ``NONE`` — score < 0.15
107 - ``LOW`` — 0.15 ≤ score < 0.40
108 - ``MED`` — 0.40 ≤ score < 0.70
109 - ``HIGH`` — score ≥ 0.70
110 """
111
112 NONE = "NONE"
113 LOW = "LOW"
114 MED = "MED"
115 HIGH = "HIGH"
116
117
118 @dataclass(frozen=True, slots=True)
119 class MuseHubDimensionDivergence:
120 """Divergence score and description for a single musical dimension.
121
122 Attributes:
123 dimension: Dimension name (e.g. ``"melodic"``).
124 level: Qualitative divergence level label.
125 score: Normalised divergence score in [0.0, 1.0].
126 description: Human-readable divergence summary.
127 branch_a_commits: Number of commits in this dimension on branch A.
128 branch_b_commits: Number of commits in this dimension on branch B.
129 """
130
131 dimension: str
132 level: MuseHubDivergenceLevel
133 score: float
134 description: str
135 branch_a_commits: int
136 branch_b_commits: int
137
138
139 @dataclass(frozen=True, slots=True)
140 class MuseHubDivergenceResult:
141 """Full musical divergence report between two MuseHub branches.
142
143 Attributes:
144 repo_id: Repository ID.
145 branch_a: Name of the first branch.
146 branch_b: Name of the second branch.
147 common_ancestor: Commit ID of the merge base, or ``None`` if disjoint.
148 dimensions: Per-dimension divergence results (always 5 entries).
149 overall_score: Mean of all per-dimension scores in [0.0, 1.0].
150 all_messages: All commit messages from both branches since the merge
151 base. Used by :func:`build_proposal_diff_response` to derive
152 ``affected_sections`` from actual commit text.
153 """
154
155 repo_id: str
156 branch_a: str
157 branch_b: str
158 common_ancestor: str | None
159 dimensions: tuple[MuseHubDimensionDivergence, ...]
160 overall_score: float
161 all_messages: tuple[str, ...] = ()
162
163
164 # ---------------------------------------------------------------------------
165 # Pure helpers
166 # ---------------------------------------------------------------------------
167
168
169 def classify_message(message: str) -> set[str]:
170 """Return the set of musical dimensions this commit message touches.
171
172 Matching is case-insensitive and keyword-based. A single message may map
173 to multiple dimensions (e.g. "add jazzy chord melody" → melodic + harmonic).
174
175 Args:
176 message: Commit message text.
177
178 Returns:
179 Set of dimension names that the message matches. Empty if unclassified.
180 """
181 lower = message.lower()
182 return {
183 dim
184 for dim, patterns in _DIMENSION_PATTERNS.items()
185 if any(pat in lower for pat in patterns)
186 }
187
188
189 def score_to_level(score: float) -> MuseHubDivergenceLevel:
190 """Map a numeric divergence score to a qualitative level label.
191
192 Args:
193 score: Normalised score in [0.0, 1.0].
194
195 Returns:
196 The appropriate :class:`MuseHubDivergenceLevel` enum member.
197 """
198 if score < 0.15:
199 return MuseHubDivergenceLevel.NONE
200 if score < 0.40:
201 return MuseHubDivergenceLevel.LOW
202 if score < 0.70:
203 return MuseHubDivergenceLevel.MED
204 return MuseHubDivergenceLevel.HIGH
205
206
207 def compute_hub_dimension_divergence(
208 dimension: str,
209 a_commit_ids: set[str],
210 b_commit_ids: set[str],
211 a_messages: StrDict,
212 b_messages: StrDict,
213 ) -> MuseHubDimensionDivergence:
214 """Compute divergence for a single musical dimension across two commit sets.
215
216 Score = ``|symmetric_diff| / |union|`` over commit IDs classified into
217 *dimension*:
218
219 - 0.0 → both branches touched exactly the same commits in this dimension.
220 - 1.0 → no overlap — completely diverged.
221
222 Args:
223 dimension: Dimension name (one of :data:`ALL_DIMENSIONS`).
224 a_commit_ids: Commit IDs for branch A since the merge base.
225 b_commit_ids: Commit IDs for branch B since the merge base.
226 a_messages: Mapping of commit_id → message for branch A.
227 b_messages: Mapping of commit_id → message for branch B.
228
229 Returns:
230 A :class:`MuseHubDimensionDivergence` with score, level, and summary.
231 """
232
233 def _filter_by_dim(ids: set[str], messages: StrDict) -> set[str]:
234 return {cid for cid in ids if dimension in classify_message(messages.get(cid, ""))}
235
236 a_dim = _filter_by_dim(a_commit_ids, a_messages)
237 b_dim = _filter_by_dim(b_commit_ids, b_messages)
238
239 union = a_dim | b_dim
240 sym_diff = a_dim.symmetric_difference(b_dim)
241 total = len(union)
242
243 if total == 0:
244 score = 0.0
245 desc = f"No {dimension} changes on either branch."
246 else:
247 score = len(sym_diff) / total
248 if score < 0.15:
249 desc = f"Both branches made similar {dimension} changes."
250 elif score < 0.40:
251 desc = f"Minor {dimension} divergence — mostly aligned."
252 elif score < 0.70:
253 desc = f"Moderate {dimension} divergence — different directions."
254 else:
255 desc = f"High {dimension} divergence — branches took different creative paths."
256
257 level = score_to_level(score)
258 return MuseHubDimensionDivergence(
259 dimension=dimension,
260 level=level,
261 score=round(score, 4),
262 description=desc,
263 branch_a_commits=len(a_dim),
264 branch_b_commits=len(b_dim),
265 )
266
267
268 # ---------------------------------------------------------------------------
269 # Async DB helpers
270 # ---------------------------------------------------------------------------
271
272
273 async def get_branch_commits(
274 session: AsyncSession,
275 repo_id: str,
276 branch: str,
277 ) -> list[MusehubCommit]:
278 """Return all commits on *branch* for *repo_id*, newest first.
279
280 Args:
281 session: Open async DB session.
282 repo_id: Repository identifier.
283 branch: Branch name.
284
285 Returns:
286 List of :class:`MusehubCommit` objects ordered by timestamp descending.
287 """
288 result = await session.execute(
289 select(MusehubCommit)
290 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
291 .where(
292 MusehubCommitRef.repo_id == repo_id,
293 MusehubCommit.branch == branch,
294 )
295 .order_by(MusehubCommit.timestamp.desc())
296 )
297 return list(result.scalars().all())
298
299
300 def find_common_ancestor(
301 a_commits: list[MusehubCommit],
302 b_commits: list[MusehubCommit],
303 ) -> str | None:
304 """Find the most recent common ancestor commit ID between two branch histories.
305
306 Collects the full ancestry of branch A via walk_dag, then returns the most
307 recent commit from branch B that appears in that ancestry set.
308
309 Args:
310 a_commits: All commits on branch A (newest first).
311 b_commits: All commits on branch B (newest first).
312
313 Returns:
314 The commit ID of the most recent common ancestor, or ``None`` if the
315 two histories are completely disjoint.
316 """
317 a_ids = {c.commit_id for c in a_commits}
318 b_ids = {c.commit_id for c in b_commits}
319 common = a_ids & b_ids
320 if not common:
321 return None
322 all_a = {c.commit_id: c for c in a_commits}
323 all_b = {c.commit_id: c for c in b_commits}
324 all_commits = {**all_a, **all_b}
325 from muse.core.graph import walk_dag
326 a_ancestry: set[str] = set(walk_dag(
327 list(a_ids),
328 lambda cid: all_commits[cid].parent_ids if cid in all_commits else [],
329 ))
330 for commit in sorted(b_commits, key=lambda c: c.timestamp, reverse=True):
331 if commit.commit_id in a_ancestry:
332 return commit.commit_id
333 return None
334
335
336 def get_commits_since(
337 all_commits: list[MusehubCommit],
338 base_commit_id: str | None,
339 ) -> list[MusehubCommit]:
340 """Filter commits to only those introduced after *base_commit_id*.
341
342 When *base_commit_id* is ``None``, all commits are returned (disjoint
343 histories — no common ancestor).
344
345 Args:
346 all_commits: All commits for a branch (newest first).
347 base_commit_id: Common ancestor commit ID to exclude, or ``None``.
348
349 Returns:
350 Commits introduced after the base, in newest-first order.
351 """
352 if base_commit_id is None:
353 return all_commits
354 return [c for c in all_commits if c.commit_id != base_commit_id]
355
356
357 # ---------------------------------------------------------------------------
358 # Public API
359 # ---------------------------------------------------------------------------
360
361
362 async def compute_hub_divergence(
363 session: AsyncSession,
364 *,
365 repo_id: str,
366 branch_a: str,
367 branch_b: str,
368 ) -> MuseHubDivergenceResult:
369 """Compute musical divergence between two MuseHub branches.
370
371 Finds the common ancestor, collects each branch's commits since that point,
372 classifies commit messages into musical dimensions, and computes a
373 per-dimension Jaccard divergence score.
374
375 Args:
376 session: Open async DB session.
377 repo_id: Repository ID.
378 branch_a: Name of the first branch.
379 branch_b: Name of the second branch.
380
381 Returns:
382 A :class:`MuseHubDivergenceResult` with per-dimension scores and the
383 resolved common ancestor commit ID.
384
385 Raises:
386 ValueError: If *branch_a* or *branch_b* has no commits in *repo_id*.
387 """
388 a_all = await get_branch_commits(session, repo_id, branch_a)
389 if not a_all:
390 raise ValueError(f"Branch '{branch_a}' has no commits in repo '{repo_id}'.")
391 b_all = await get_branch_commits(session, repo_id, branch_b)
392 if not b_all:
393 raise ValueError(f"Branch '{branch_b}' has no commits in repo '{repo_id}'.")
394
395 common_ancestor = find_common_ancestor(a_all, b_all)
396
397 logger.info(
398 "✅ musehub divergence: %r vs %r, base=%s",
399 branch_a,
400 branch_b,
401 common_ancestor[:8] if common_ancestor else "none",
402 )
403
404 a_since = get_commits_since(a_all, common_ancestor)
405 b_since = get_commits_since(b_all, common_ancestor)
406
407 a_ids = {c.commit_id for c in a_since}
408 b_ids = {c.commit_id for c in b_since}
409 a_msgs = {c.commit_id: c.message for c in a_since}
410 b_msgs = {c.commit_id: c.message for c in b_since}
411
412 dimensions = tuple(
413 compute_hub_dimension_divergence(dim, a_ids, b_ids, a_msgs, b_msgs)
414 for dim in ALL_DIMENSIONS
415 )
416
417 overall = (
418 round(sum(d.score for d in dimensions) / len(dimensions), 4)
419 if dimensions
420 else 0.0
421 )
422
423 all_messages = tuple(c.message for c in a_since) + tuple(c.message for c in b_since)
424
425 return MuseHubDivergenceResult(
426 repo_id=repo_id,
427 branch_a=branch_a,
428 branch_b=branch_b,
429 common_ancestor=common_ancestor,
430 dimensions=dimensions,
431 overall_score=overall,
432 all_messages=all_messages,
433 )
434
435
436 # ---------------------------------------------------------------------------
437 # ProposalDiffResponse builder helpers (shared by proposals and ui routes)
438 # ---------------------------------------------------------------------------
439
440
441 def _delta_label(score: float) -> str:
442 """Convert a divergence score to a human-readable delta badge label."""
443 pct = round(score * 100, 1)
444 if pct == 0.0:
445 return "unchanged"
446 return f"+{pct}"
447
448
449 def build_proposal_diff_response(
450 proposal_id: str,
451 from_branch: str,
452 to_branch: str,
453 result: MuseHubDivergenceResult,
454 ) -> ProposalDiffResponse:
455 """Assemble a :class:`ProposalDiffResponse` from a divergence computation result."""
456 dimensions = [
457 ProposalDiffDimensionScore(
458 dimension=d.dimension,
459 score=d.score,
460 level=d.level.value,
461 delta_label=_delta_label(d.score),
462 description=d.description,
463 from_branch_commits=d.branch_b_commits,
464 to_branch_commits=d.branch_a_commits,
465 )
466 for d in result.dimensions
467 ]
468
469 return ProposalDiffResponse(
470 proposal_id=proposal_id,
471 repo_id=result.repo_id,
472 from_branch=from_branch,
473 to_branch=to_branch,
474 dimensions=dimensions,
475 overall_score=result.overall_score,
476 common_ancestor=result.common_ancestor,
477 affected_sections=extract_affected_sections(result.all_messages),
478 )
479
480
481 def build_zero_diff_response(
482 proposal_id: str,
483 repo_id: str,
484 from_branch: str,
485 to_branch: str,
486 include_dimensions: bool = True,
487 ) -> ProposalDiffResponse:
488 """Return a zero-score :class:`ProposalDiffResponse` placeholder.
489
490 Used when :func:`compute_hub_divergence` raises :exc:`ValueError` because
491 one or both branches have no commits yet, or when the repo is a code-domain
492 repo where musical dimension analysis does not apply.
493
494 Args:
495 proposal_id: Proposal ID.
496 repo_id: Repository ID.
497 from_branch: Source branch name.
498 to_branch: Target branch name.
499 include_dimensions: When ``False`` (code-domain repos), return an empty
500 dimensions list and ``overall_score=None``. The proposal detail template
501 hides the Musical Analysis panel when dimensions is empty.
502
503 Returns:
504 A :class:`ProposalDiffResponse` with all dimensions at 0.0 (or empty for
505 code-domain repos).
506 """
507 if not include_dimensions:
508 return ProposalDiffResponse(
509 proposal_id=proposal_id,
510 repo_id=repo_id,
511 from_branch=from_branch,
512 to_branch=to_branch,
513 dimensions=[],
514 overall_score=None,
515 common_ancestor=None,
516 affected_sections=[],
517 )
518 dimensions = [
519 ProposalDiffDimensionScore(
520 dimension=dim,
521 score=0.0,
522 level="NONE",
523 delta_label="unchanged",
524 description="No commits on one or both branches yet.",
525 from_branch_commits=0,
526 to_branch_commits=0,
527 )
528 for dim in ALL_DIMENSIONS
529 ]
530 return ProposalDiffResponse(
531 proposal_id=proposal_id,
532 repo_id=repo_id,
533 from_branch=from_branch,
534 to_branch=to_branch,
535 dimensions=dimensions,
536 overall_score=0.0,
537 common_ancestor=None,
538 affected_sections=[],
539 )
File History 13 commits
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 10 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 13 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 16 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 18 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 31 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 35 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 36 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 37 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 37 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 37 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 37 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 49 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 51 days ago