gabriel / muse public
_midi_query.py python
547 lines 18.6 KB
Raw
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378 docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14) Sonnet 5 13 days ago
1 """Music-domain query DSL for the Muse VCS.
2
3 Allows agents and humans to query the commit history for musical content::
4
5 muse music-query "note.pitch_class == 'Eb' and bar == 12"
6 muse music-query "harmony.quality == 'dim' and bar == 8"
7 muse music-query "author == 'agent-x' and track == 'piano.mid'"
8 muse music-query "note.velocity > 80 and not bar == 4"
9 muse music-query "agent_id == 'counterpoint-bot'"
10
11 Grammar (EBNF)
12 --------------
13
14 query = or_expr
15 or_expr = and_expr ( 'or' and_expr )*
16 and_expr = not_expr ( 'and' not_expr )*
17 not_expr = 'not' not_expr | atom
18 atom = '(' query ')' | comparison
19 comparison = FIELD OP VALUE
20 FIELD = 'note.pitch' | 'note.pitch_class' | 'note.velocity'
21 | 'note.channel' | 'note.duration'
22 | 'bar' | 'track' | 'harmony.chord' | 'harmony.quality'
23 | 'author' | 'agent_id' | 'model_id' | 'toolchain_id'
24 OP = '==' | '!=' | '>' | '<' | '>=' | '<='
25 VALUE = QUOTED_STRING | NUMBER
26
27 Supported field paths
28 ---------------------
29
30 +---------------------+---------------------------------------------+
31 | Field | Resolves to |
32 +=====================+=============================================+
33 | note.pitch | any note's MIDI pitch (integer 0–127) |
34 +---------------------+---------------------------------------------+
35 | note.pitch_class | pitch class name ("C", "C#", …, "B") |
36 +---------------------+---------------------------------------------+
37 | note.velocity | MIDI velocity (0–127) |
38 +---------------------+---------------------------------------------+
39 | note.channel | MIDI channel (0–15) |
40 +---------------------+---------------------------------------------+
41 | note.duration | note duration in beats (float) |
42 +---------------------+---------------------------------------------+
43 | bar | 1-indexed bar number (assumes 4/4) |
44 +---------------------+---------------------------------------------+
45 | track | workspace-relative MIDI file path |
46 +---------------------+---------------------------------------------+
47 | harmony.chord | detected chord name ("Cmaj", "Fdim7", …) |
48 +---------------------+---------------------------------------------+
49 | harmony.quality | chord quality suffix ("maj", "min", "dim"…) |
50 +---------------------+---------------------------------------------+
51 | author | commit author string |
52 +---------------------+---------------------------------------------+
53 | agent_id | agent_id from commit provenance |
54 +---------------------+---------------------------------------------+
55 | model_id | model_id from commit provenance |
56 +---------------------+---------------------------------------------+
57 | toolchain_id | toolchain_id from commit provenance |
58 +---------------------+---------------------------------------------+
59 """
60
61 import logging
62 import pathlib
63 import re
64 from dataclasses import dataclass, field
65 from typing import Literal, TypedDict
66
67 from muse.core.types import short_id
68 from muse.core.object_store import read_object
69 from muse.core.commits import (
70 CommitRecord,
71 read_commit,
72 )
73 from muse.core.snapshots import get_commit_snapshot_manifest
74 from muse.plugins.midi._query import (
75 NoteInfo,
76 detect_chord,
77 notes_by_bar,
78 walk_commits_for_track,
79 )
80 from muse.plugins.midi.midi_diff import extract_notes
81
82 type _OpMap = dict[str, "_OpLiteral"]
83 logger = logging.getLogger(__name__)
84
85 # ---------------------------------------------------------------------------
86 # AST node dataclasses
87 # ---------------------------------------------------------------------------
88
89 @dataclass
90 class EqNode:
91 """Leaf comparison: ``field OP value``."""
92
93 field: str
94 op: Literal["==", "!=", ">", "<", ">=", "<="]
95 value: str | int | float
96
97 @dataclass
98 class AndNode:
99 """Logical AND of two sub-expressions."""
100
101 left: QueryNode
102 right: QueryNode
103
104 @dataclass
105 class OrNode:
106 """Logical OR of two sub-expressions."""
107
108 left: QueryNode
109 right: QueryNode
110
111 @dataclass
112 class NotNode:
113 """Logical NOT of a sub-expression."""
114
115 inner: QueryNode
116
117 QueryNode = EqNode | AndNode | OrNode | NotNode
118
119 # ---------------------------------------------------------------------------
120 # Query context and result types
121 # ---------------------------------------------------------------------------
122
123 @dataclass
124 class QueryContext:
125 """Data available to the evaluator for one bar in one track at one commit."""
126
127 commit: CommitRecord
128 track: str
129 bar: int
130 notes: list[NoteInfo]
131 chord: str
132 ticks_per_beat: int
133
134 class NoteDict(TypedDict):
135 """Serialisable representation of a note for query results."""
136
137 pitch: int
138 pitch_class: str
139 velocity: int
140 channel: int
141 beat: float
142 duration_beats: float
143
144 class QueryMatch(TypedDict):
145 """A single match returned by :func:`run_query`."""
146
147 commit_id: str
148 commit_short: str
149 commit_message: str
150 author: str
151 agent_id: str
152 committed_at: str
153 track: str
154 bar: int
155 notes: list[NoteDict]
156 chord: str
157 matched_on: str
158
159 # ---------------------------------------------------------------------------
160 # Tokenizer
161 # ---------------------------------------------------------------------------
162
163 _TOKEN_RE = re.compile(
164 r"""
165 (?P<LPAREN> \( ) |
166 (?P<RPAREN> \) ) |
167 (?P<OP> ==|!=|>=|<=|>|< ) |
168 (?P<KW> (?:and|or|not)(?![A-Za-z0-9_.]) ) |
169 (?P<STR> "(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*' ) |
170 (?P<NUM> -?\d+(?:\.\d+)? ) |
171 (?P<NAME> [A-Za-z_][A-Za-z0-9_.]* ) |
172 (?P<WS> \s+ )
173 """,
174 re.VERBOSE,
175 )
176
177 _OpLiteral = Literal["==", "!=", ">", "<", ">=", "<="]
178 _VALID_OPS: frozenset[str] = frozenset({"==", "!=", ">", "<", ">=", "<="})
179
180 @dataclass
181 class Token:
182 """A single lexed token."""
183
184 kind: str
185 value: str
186
187 def _tokenize(query: str) -> list[Token]:
188 """Convert *query* string to a flat list of :class:`Token` objects.
189
190 Whitespace tokens are discarded. Raises ``ValueError`` on unrecognised input.
191 """
192 tokens: list[Token] = []
193 for m in _TOKEN_RE.finditer(query):
194 kind = m.lastgroup
195 if kind is None or kind == "WS":
196 continue
197 tokens.append(Token(kind=kind, value=m.group()))
198 # Verify full coverage.
199 covered = sum(len(m.group()) for m in _TOKEN_RE.finditer(query) if m.lastgroup != "WS")
200 no_ws = re.sub(r"\s+", "", query)
201 if covered != len(no_ws):
202 raise ValueError(f"Unrecognised characters in query: {query!r}")
203 return tokens
204
205 # ---------------------------------------------------------------------------
206 # Recursive descent parser
207 # ---------------------------------------------------------------------------
208
209 class _Parser:
210 """Recursive descent parser for the music query DSL."""
211
212 def __init__(self, tokens: list[Token]) -> None:
213 self._tokens = tokens
214 self._pos = 0
215
216 def _peek(self) -> Token | None:
217 if self._pos < len(self._tokens):
218 return self._tokens[self._pos]
219 return None
220
221 def _consume(self, kind: str | None = None, value: str | None = None) -> Token:
222 tok = self._peek()
223 if tok is None:
224 raise ValueError("Unexpected end of query")
225 if kind is not None and tok.kind != kind:
226 raise ValueError(f"Expected {kind!r}, got {tok.kind!r} ({tok.value!r})")
227 if value is not None and tok.value != value:
228 raise ValueError(f"Expected {value!r}, got {tok.value!r}")
229 self._pos += 1
230 return tok
231
232 def parse(self) -> QueryNode:
233 node = self._or_expr()
234 if self._peek() is not None:
235 raise ValueError(f"Unexpected token: {self._peek()!r}")
236 return node
237
238 def _or_expr(self) -> QueryNode:
239 node = self._and_expr()
240 while (tok := self._peek()) is not None and tok.kind == "KW" and tok.value == "or":
241 self._consume()
242 right = self._and_expr()
243 node = OrNode(left=node, right=right)
244 return node
245
246 def _and_expr(self) -> QueryNode:
247 node = self._not_expr()
248 while (tok := self._peek()) is not None and tok.kind == "KW" and tok.value == "and":
249 self._consume()
250 right = self._not_expr()
251 node = AndNode(left=node, right=right)
252 return node
253
254 def _not_expr(self) -> QueryNode:
255 tok = self._peek()
256 if tok is not None and tok.kind == "KW" and tok.value == "not":
257 self._consume()
258 return NotNode(inner=self._not_expr())
259 return self._atom()
260
261 def _atom(self) -> QueryNode:
262 tok = self._peek()
263 if tok is None:
264 raise ValueError("Unexpected end of query in atom")
265 if tok.kind == "LPAREN":
266 self._consume("LPAREN")
267 node = self._or_expr()
268 self._consume("RPAREN")
269 return node
270 return self._comparison()
271
272 def _comparison(self) -> QueryNode:
273 field_tok = self._consume("NAME")
274 op_tok = self._consume("OP")
275 if op_tok.value not in _VALID_OPS:
276 raise ValueError(f"Invalid operator: {op_tok.value!r}")
277
278 val_tok = self._consume()
279 if val_tok.kind == "STR":
280 raw = val_tok.value[1:-1]
281 raw = raw.replace('\\"', '"').replace("\\'", "'")
282 value: str | int | float = raw
283 elif val_tok.kind == "NUM":
284 value = float(val_tok.value) if "." in val_tok.value else int(val_tok.value)
285 elif val_tok.kind == "NAME":
286 value = val_tok.value
287 else:
288 raise ValueError(f"Expected value, got {val_tok.kind!r}")
289
290 _op_map: _OpMap = {
291 "==": "==", "!=": "!=", ">": ">", "<": "<", ">=": ">=", "<=": "<=",
292 }
293 op_val = _op_map.get(op_tok.value)
294 if op_val is None:
295 raise ValueError(f"Invalid operator: {op_tok.value!r}")
296 return EqNode(
297 field=field_tok.value,
298 op=op_val,
299 value=value,
300 )
301
302 def parse_query(query_str: str) -> QueryNode:
303 """Parse a query string into an AST.
304
305 Args:
306 query_str: Music query expression.
307
308 Returns:
309 Root :data:`QueryNode` of the AST.
310
311 Raises:
312 ValueError: On parse error.
313 """
314 tokens = _tokenize(query_str)
315 return _Parser(tokens).parse()
316
317 # ---------------------------------------------------------------------------
318 # Evaluator
319 # ---------------------------------------------------------------------------
320
321 def _compare(actual: str | int | float, op: str, expected: str | int | float) -> bool:
322 """Apply a comparison operator to two values.
323
324 String comparisons use ``==`` / ``!=`` only (other operators raise).
325 Numeric comparisons support all six operators.
326 """
327 if isinstance(actual, str):
328 if op == "==":
329 return actual.lower() == str(expected).lower()
330 if op == "!=":
331 return actual.lower() != str(expected).lower()
332 raise ValueError(f"Operator {op!r} not supported for string values")
333
334 exp_num: float
335 if isinstance(expected, str):
336 try:
337 exp_num = float(expected)
338 except ValueError:
339 raise ValueError(f"Cannot compare numeric field to {expected!r}")
340 else:
341 exp_num = float(expected)
342
343 act_num = float(actual)
344 if op == "==":
345 return act_num == exp_num
346 if op == "!=":
347 return act_num != exp_num
348 if op == ">":
349 return act_num > exp_num
350 if op == "<":
351 return act_num < exp_num
352 if op == ">=":
353 return act_num >= exp_num
354 if op == "<=":
355 return act_num <= exp_num
356 raise ValueError(f"Unknown operator {op!r}")
357
358 def _resolve_field(field: str, ctx: QueryContext) -> list[str | int | float]:
359 """Resolve a field path to a list of candidate values from *ctx*.
360
361 Note fields return one value per note in the bar; all other fields
362 return a single-element list. The evaluator matches if *any* candidate
363 satisfies the predicate.
364 """
365 # --- Note fields ---
366 if field == "note.pitch":
367 return [n.pitch for n in ctx.notes]
368 if field == "note.pitch_class":
369 return [n.pitch_class_name for n in ctx.notes]
370 if field == "note.velocity":
371 return [n.velocity for n in ctx.notes]
372 if field == "note.channel":
373 return [n.channel for n in ctx.notes]
374 if field == "note.duration":
375 return [n.beat_duration for n in ctx.notes]
376 # --- Bar / track ---
377 if field == "bar":
378 return [ctx.bar]
379 if field == "track":
380 return [ctx.track]
381 # --- Harmony ---
382 if field == "harmony.chord":
383 return [ctx.chord]
384 if field == "harmony.quality":
385 for suffix in ("dim7", "maj7", "min7", "dom7", "sus2", "sus4", "aug", "dim", "maj", "min", "5"):
386 if ctx.chord.endswith(suffix):
387 return [suffix]
388 return [""]
389 # --- Commit provenance ---
390 if field == "author":
391 return [ctx.commit.author]
392 if field == "agent_id":
393 return [ctx.commit.agent_id]
394 if field == "model_id":
395 return [ctx.commit.model_id]
396 if field == "toolchain_id":
397 return [ctx.commit.toolchain_id]
398
399 raise ValueError(f"Unknown field: {field!r}")
400
401 def evaluate_node(node: QueryNode, ctx: QueryContext) -> bool:
402 """Recursively evaluate a query AST node against *ctx*.
403
404 Args:
405 node: The root (or sub) AST node.
406 ctx: Query context for the bar/track/commit being tested.
407
408 Returns:
409 ``True`` when the predicate matches, ``False`` otherwise.
410 """
411 if isinstance(node, EqNode):
412 try:
413 candidates = _resolve_field(node.field, ctx)
414 except ValueError:
415 return False
416 return any(_compare(c, node.op, node.value) for c in candidates)
417
418 if isinstance(node, AndNode):
419 return evaluate_node(node.left, ctx) and evaluate_node(node.right, ctx)
420
421 if isinstance(node, OrNode):
422 return evaluate_node(node.left, ctx) or evaluate_node(node.right, ctx)
423
424 if isinstance(node, NotNode):
425 return not evaluate_node(node.inner, ctx)
426
427 raise TypeError(f"Unknown AST node type: {type(node)}")
428
429 # ---------------------------------------------------------------------------
430 # Query runner
431 # ---------------------------------------------------------------------------
432
433 def run_query(
434 query_str: str,
435 root: pathlib.Path,
436 start_commit_id: str,
437 *,
438 track_filter: str | None = None,
439 from_commit_id: str | None = None,
440 max_commits: int = 10_000,
441 max_results: int = 1_000,
442 ) -> list[QueryMatch]:
443 """Evaluate a music query DSL expression over the commit history.
444
445 Walks the parent chain from *start_commit_id*, loading each MIDI track,
446 grouping notes by bar, and evaluating the query predicate against each
447 (commit, track, bar) triple.
448
449 Args:
450 query_str: Music query expression string.
451 root: Repository root.
452 start_commit_id: Start of the walk (inclusive; usually HEAD).
453 track_filter: Restrict search to a single MIDI file path.
454 from_commit_id: Stop before this commit ID (exclusive).
455 max_commits: Safety cap on commits walked.
456 max_results: Safety cap on results returned.
457
458 Returns:
459 List of :class:`QueryMatch` dicts, chronologically ordered
460 (oldest first).
461
462 Raises:
463 ValueError: When the query string cannot be parsed.
464 """
465 from muse.core.graph import iter_ancestors
466
467 ast = parse_query(query_str)
468 results: list[QueryMatch] = []
469
470 prune = (lambda cid: cid == from_commit_id) if from_commit_id else None
471 for commit in iter_ancestors(
472 root,
473 start_commit_id,
474 first_parent_only=True,
475 prune=prune,
476 max_commits=max_commits,
477 ):
478 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
479
480 midi_paths = [
481 p for p in manifest
482 if p.lower().endswith(".mid")
483 and (track_filter is None or p == track_filter)
484 ]
485
486 for track_path in sorted(midi_paths):
487 obj_hash = manifest.get(track_path)
488 if obj_hash is None:
489 continue
490 raw = read_object(root, obj_hash)
491 if raw is None:
492 continue
493 try:
494 keys, tpb = extract_notes(raw)
495 except ValueError:
496 continue
497
498 notes = [NoteInfo.from_note_key(k, tpb) for k in keys]
499 bar_map = notes_by_bar(notes)
500
501 for bar_num, bar_notes in sorted(bar_map.items()):
502 pcs = frozenset(n.pitch_class for n in bar_notes)
503 chord = detect_chord(pcs)
504 ctx = QueryContext(
505 commit=commit,
506 track=track_path,
507 bar=bar_num,
508 notes=bar_notes,
509 chord=chord,
510 ticks_per_beat=tpb,
511 )
512 if evaluate_node(ast, ctx):
513 results.append(
514 QueryMatch(
515 commit_id=commit.commit_id,
516 commit_short=short_id(commit.commit_id, strip=True),
517 commit_message=commit.message,
518 author=commit.author,
519 agent_id=commit.agent_id,
520 committed_at=commit.committed_at.isoformat(),
521 track=track_path,
522 bar=bar_num,
523 notes=[
524 NoteDict(
525 pitch=n.pitch,
526 pitch_class=n.pitch_class_name,
527 velocity=n.velocity,
528 channel=n.channel,
529 beat=round(n.beat, 4),
530 duration_beats=round(n.beat_duration, 4),
531 )
532 for n in bar_notes
533 ],
534 chord=chord,
535 matched_on=query_str,
536 )
537 )
538 if len(results) >= max_results:
539 logger.warning(
540 "⚠️ music-query hit max_results=%d — truncating",
541 max_results,
542 )
543 results.reverse()
544 return results
545
546 results.reverse() # oldest-first
547 return results
File History 2 commits
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378 docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14) Sonnet 5 13 days ago
sha256:2562dffa0a0822ac1bdea854f9b267843c6ce95b497a9dc5c55837c80a3ebd0a feat: domain_command_registry — Phase 1 of muse#74 (SCR_01-03) Sonnet 4.6 patch 13 days ago