find_phrase.py python
164 lines 6.3 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """muse find-phrase — search for a melodic phrase across commit history.
2
3 Scans every commit that contains a MIDI track and computes a similarity score
4 between the query phrase (a short .mid file or a bar range of the track) and
5 each historical snapshot. Returns the commits where the phrase appears most
6 strongly.
7
8 Usage::
9
10 muse find-phrase tracks/melody.mid --query query/motif.mid
11 muse find-phrase tracks/melody.mid --query query/motif.mid --min-score 0.7
12 muse find-phrase tracks/melody.mid --query query/motif.mid --depth 100 --json
13
14 Output::
15
16 Phrase search: tracks/melody.mid (query: query/motif.mid)
17 Scanning 24 commits…
18
19 Score Commit Author Message
20 ──────────────────────────────────────────────────────────────────
21 0.934 cb4afaed agent-melody-composer feat: add intro melody
22 0.871 9f3a12e7 agent-harmoniser feat: harmonise verse
23 0.612 1b2c3d4e agent-arranger refactor: restructure bridge
24 """
25
26 from __future__ import annotations
27
28 import argparse
29 import json
30 import logging
31 import pathlib
32 import sys
33 from typing import TypedDict
34
35 from muse.core.errors import ExitCode
36 from muse.core.repo import read_repo_id, require_repo
37 from muse.core.store import read_current_branch, resolve_commit_ref
38 from muse.core.validation import clamp_int, sanitize_display
39 from muse.plugins.midi._analysis import phrase_similarity
40 from muse.plugins.midi._query import (
41 NoteInfo,
42 load_track,
43 load_track_from_workdir,
44 walk_commits_for_track,
45 )
46
47 logger = logging.getLogger(__name__)
48
49
50 class PhraseMatch(TypedDict):
51 """A commit that contains the searched phrase."""
52
53 score: float
54 commit_id: str
55 author: str
56 message: str
57
58
59
60 def _read_branch(root: pathlib.Path) -> str:
61 return read_current_branch(root)
62
63
64 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
65 """Register the find-phrase subcommand."""
66 parser = subparsers.add_parser("find-phrase", help="Search for a melodic phrase across MIDI commit history.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
67 parser.add_argument("track", metavar="TRACK", help="Workspace-relative path to the .mid file to search in.")
68 parser.add_argument("--query", "-q", metavar="QUERY_MIDI", required=True, help="Path to a short .mid file containing the phrase to search for.")
69 parser.add_argument("--commit", "-c", metavar="REF", default=None, dest="ref", help="Start the history walk from this commit (default: HEAD).")
70 parser.add_argument("--depth", "-d", metavar="N", type=int, default=50, help="Maximum commits to scan (default 50).")
71 parser.add_argument("--min-score", "-s", metavar="S", type=float, default=0.5, dest="min_score", help="Minimum similarity score to report (default 0.5).")
72 parser.add_argument("--json", action="store_true", dest="as_json", help="Emit results as JSON.")
73 parser.set_defaults(func=run)
74
75
76 def run(args: argparse.Namespace) -> None:
77 """Search for a melodic phrase across MIDI commit history.
78
79 ``muse find-phrase`` computes pitch-class histogram and interval-fingerprint
80 similarity between a query MIDI file and every historical snapshot of a
81 track. Use it to answer: "At which commit did this motif first appear?"
82 or "Which branches contain this theme?"
83
84 For agents: pipe the output (``--json``) into a decision loop to select the
85 commit with the highest match score as the merge base for a cherry-pick.
86 """
87 track: str = args.track
88 query: str = args.query
89 ref: str | None = args.ref
90 depth: int = clamp_int(args.depth, 1, 50, 'depth')
91 min_score: float = args.min_score
92 as_json: bool = args.as_json
93
94 if depth < 1 or depth > 10_000:
95 print(f"❌ --depth must be between 1 and 10,000 (got {depth}).", file=sys.stderr)
96 raise SystemExit(ExitCode.USER_ERROR)
97 if not 0.0 <= min_score <= 1.0:
98 print(f"❌ --min-score must be between 0.0 and 1.0 (got {min_score}).", file=sys.stderr)
99 raise SystemExit(ExitCode.USER_ERROR)
100 root = require_repo()
101
102 # Load query phrase
103 query_result = load_track_from_workdir(root, query)
104 if query_result is None:
105 print(f"❌ Query file '{query}' not found or not a valid MIDI file.", file=sys.stderr)
106 raise SystemExit(ExitCode.USER_ERROR)
107 query_notes, _qtpb = query_result
108
109 if not query_notes:
110 print(f" (query file '{query}' contains no notes — cannot search)")
111 return
112
113 repo_id = read_repo_id(root)
114 branch = _read_branch(root)
115 start_ref = ref or "HEAD"
116 start_commit = resolve_commit_ref(root, repo_id, branch, start_ref)
117 if start_commit is None:
118 print(f"❌ Commit '{start_ref}' not found.", file=sys.stderr)
119 raise SystemExit(ExitCode.USER_ERROR)
120
121 history = walk_commits_for_track(root, start_commit.commit_id, track, max_commits=depth)
122
123 if not as_json:
124 print(f"\nPhrase search: {track} (query: {query})")
125 print(f"Scanning {len(history)} commits…\n")
126
127 matches: list[PhraseMatch] = []
128 for commit, manifest in history:
129 if manifest is None or track not in manifest:
130 continue
131 result = load_track(root, commit.commit_id, track)
132 if result is None:
133 continue
134 candidate_notes: list[NoteInfo] = result[0]
135 if not candidate_notes:
136 continue
137 score = phrase_similarity(query_notes, candidate_notes)
138 if score >= min_score:
139 matches.append(PhraseMatch(
140 score=score,
141 commit_id=commit.commit_id[:8],
142 author=sanitize_display(commit.author or "unknown"),
143 message=sanitize_display((commit.message or "").splitlines()[0][:60]),
144 ))
145
146 matches.sort(key=lambda m: -m["score"])
147
148 if as_json:
149 print(json.dumps(
150 {"track": track, "query": query, "matches": list(matches)},
151 indent=2,
152 ))
153 return
154
155 if not matches:
156 print(f" (no commits with score ≥ {min_score} found)")
157 return
158
159 print(f" {'Score':>7} {'Commit':<10} {'Author':<28} Message")
160 print(" " + "─" * 74)
161 for m in matches:
162 print(
163 f" {m['score']:>7.3f} {m['commit_id']:<10} {m['author']:<28} {m['message']}"
164 )
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago