This commit is contained in:
khodowany
2026-10-05 08:51:03 -05:00
parent ef62af892b
commit 654299decd
25 changed files with 1695 additions and 351 deletions
+2 -2
View File
@@ -24,9 +24,9 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
by_id={n.event_id:n for n in placed[1]}
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'joint_actions','version':policy.DEFAULT.composition_version,
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'complete_phrase_frontiers','version':policy.DEFAULT.composition_version,
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':dataclasses.asdict(s.evaluation),'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
validate(result)
return result
+181 -56
View File
@@ -1,13 +1,16 @@
"""Bounded search of joint musical actions; one normal production path.
Selection compares complete actions by cost per quarter. Actions are indivisible;
cross-bar paths and resolution contracts never restart at search boundaries.
Selection compares several histories at common exact-time frontiers. Actions
are indivisible; crossing a reporting window does not restart their contracts.
"""
from __future__ import annotations
from collections import Counter
import dataclasses
from fractions import Fraction as F
import time
import heapq
import itertools
import functools
from generator import vocabulary
from generator.solo import types, groups, operators, selection, sequences, scoring
@@ -26,6 +29,14 @@ class State:
tie: tuple = ()
pending: tuple = ()
alternatives: tuple = ()
actions: tuple = ()
notes: tuple = ()
full_evaluation: object = None
@functools.cached_property
def music_key(self):return tuple((n.start,n.end,n.pitch.number()) for n in self.notes)
@functools.cached_property
def path_key(self):return tuple(a.key for a in self.actions)
class Builder:
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=F()):
@@ -33,32 +44,66 @@ class Builder:
self.operators=operators.Operators(harmony,analysis,end,start,enabled,sequences,rhythms)
self.selector=selection.Selector(harmony,analysis,harmony.profile.reference or 60)
self.rules=vocabulary.data('solo')['operators'];self.decisions=[];self.failures=Counter();self.coverage={};self.timings=Counter()
self.observe=None # Optional read-only diagnostic; its return is ignored.
self.successor_cache={};self.retrieval_at=None;self.retrieval_weights=None
def candidates(self,at,state):
candidates=self.operators.at(at)
candidates=self.operators.proposals(at)
if state.home and state.last_kind!='return':
candidates+=self.operators.related(at,state.home,'return')+self.operators.related(at,state.home,'answer')
evaluated=[]
for a in candidates:
if state.pending and self.advance(state,None,a) is None:continue
cost=self.selector.cost(a,state.recent,state.home,state.last_kind)
evaluated.append((cost,a))
# Rank complete actions by cost per quarter before bounded repair.
# Accumulated prefix totals are diagnostic, not a global search objective.
retained=sequences.diverse(evaluated,
lambda v:(v[0],v[1].key),
lambda v:(v[1].kind,v[1].end-v[1].start),self.rules['per_state'])
# Retrieval never compares cost rates of differently sized actions. Each
# endpoint has its own ranking; round-robin the endpoints before cutoff.
context=(tuple(n.pitch.number() for n in state.recent[-3:]),state.recent[-1].end if state.recent else None)
settled=state.recent[-1].end-state.recent[-1].start>=F(3,2) if state.recent else None
if at!=self.retrieval_at or self.retrieval_weights is not self.selector.weights:
self.successor_cache.clear();self.retrieval_at=at;self.retrieval_weights=self.selector.weights
# Cache only endpoint retrieval, whose sufficient incoming state is three
# pitches, release, rest function and pending contracts. Home-specific
# actions change the immutable catalog identity; full evaluation below
# still uses the actual complete home and recent history on every call.
key=(id(candidates),context,settled,state.pending,self.rules['per_state'])
cached=self.successor_cache.get(key)
if cached is not None and cached[0] is candidates:
self.timings['retrieval_cache_hits']+=1;retained=cached[1]
else:
self.timings['retrieval_cache_misses']+=1;buckets={}
for order,a in enumerate(candidates):
if not self.pending_compatible(state,a):continue
cost=self.selector.fast_cost(a,state.recent,context=context)
buckets.setdefault(a.end,[]).append((cost,order,a))
ranked=[]
for end,rows in sorted(buckets.items()):
heapq.heapify(rows);ranked.append(rows)
retained=[]
while ranked and len(retained)<self.rules['per_state']:
again=[]
for rows in ranked:
while rows:
cost,_,action=heapq.heappop(rows)
if self.operators.is_valid(action):
retained.append((cost,action));break
if rows:again.append(rows)
if len(retained)==self.rules['per_state']:break
ranked=again
retained=tuple(retained)
self.successor_cache[key]=(candidates,retained) # Retain object; no id reuse.
if len(self.successor_cache)>64:self.successor_cache.pop(next(iter(self.successor_cache)))
for _,a in retained:self.operators.coverage['retained:'+a.kind]+=1
if self.observe:self.observe('successors',at,state,tuple(candidates),tuple(a for _,a in retained))
return tuple((self.selector.evaluate(a,state.recent,state.home,state.last_kind),a) for _,a in retained)
def advance(self,state,evaluation,action):
def pending_compatible(self,state,action):
for contract in state.pending:
target=contract.target
if action.start<=target.start<action.end:
# Reject a missed committed attack/hold before revalidating all the
# harmonic relationships. Partial target evidence remains possible.
if not any(n.event_id==target.event_id and n.start==target.start
and n.end==min(action.end,target.end) and n.pitch==target.pitch for n in action.notes):return None
and n.end==min(action.end,target.end) and n.pitch==target.pitch for n in action.notes):return False
return True
def advance(self,state,evaluation,action):
if not self.pending_compatible(state,action):return None
obligations=(*state.pending,*action.contracts);pending=()
if not state.pending:
pending=tuple(c for c in action.contracts if c.target.end>action.end)
@@ -69,55 +114,139 @@ class Builder:
expressions=obligations,until=action.end,complete=action.end==self.end)
if not check.valid:return None
pending=check.pending
recent=(*state.recent,*action.notes)[-8:];home=state.home
if not home and len(recent)>=4:
proposed=recent[:4]
if proposed[-1].end-proposed[0].start<=8:home=proposed
return State(state.total+(evaluation.total if evaluation else 0),recent,home,action.kind,state,action,evaluation,(),pending)
recent=(*state.recent,*action.notes)[-8:]
notes=types.sounding_notes((*state.notes,*action.notes))
actions=(*state.actions,action)
# A complete opening, rather than the first four eligible notes, owns the
# theme. Keep it separate from the small context used for retrieval.
home=state.home
boundaries=[x-self.analysis.body_offset for x in self.analysis.boundaries if x-self.analysis.body_offset>self.start]
opening_end=boundaries[min(3,len(boundaries)-1)] if boundaries else self.end
if not home and action.end>=opening_end and not pending and len(notes)>=3:
# The same sounded opening has the same home identity regardless of
# whether its last generating action extends beyond this source window.
home=tuple(n for n in notes if n.start<opening_end)
if evaluation is None:
return State(recent=recent,home=home,last_kind=action.kind,parent=state,action=action,pending=pending,actions=actions,notes=notes)
full=self.selector.extend(state.full_evaluation,state.notes,action,home,old_home=state.home,start=self.start,
contracts=tuple(dict.fromkeys(c for a in actions for c in a.contracts)))
return State(full.total,recent,home,action.kind,state,action,full,(),pending,(),actions,notes,full)
@staticmethod
def identity(state):
# Keep rhythmic histories, home identity, and obligations, not just pitch.
return (state.music_key,
selection.signature(state.home),state.pending)
def rank(self,state):
return (state.total,state.music_key,state.path_key)
def diversity(self,state):
# Protect alternative *openings* before a home theme has been selected.
# Recent-note diversity alone lets every history inherit the same opening.
first_bar=next((x-self.analysis.body_offset for x in self.analysis.boundaries if x-self.analysis.body_offset>max(F(),self.start)),self.end)
opening=tuple(itertools.takewhile(lambda n:n.start<first_bar,state.notes))
return (selection.signature(opening),opening[0].pitch.number()//12 if opening else None)
def retain(self,states,at):
normalized=[]
for s in states:
if s.actions and (s.evaluation is None or F(s.evaluation.denominators['span'])!=at-self.start):
e=self.frontier_score(s,at);s=dataclasses.replace(s,total=e.total,evaluation=e)
normalized.append(s)
states=normalized
unique={}
for s in states:
key=self.identity(s)
if key not in unique or self.rank(s)<self.rank(unique[key]):unique[key]=s
values=sorted(unique.values(),key=self.rank)
# Half by score, half by realized pitch/rhythm/obligation diversity. Labels
# do not earn a bonus, nor do duplicate derivations receive extra votes.
limit=self.rules['histories'];best=values[:max(1,limit//2)]
rest=sequences.diverse(values[len(best):],self.rank,self.diversity,limit-len(best))
result=tuple(sorted((*best,*rest),key=self.rank))
self.frontiers.append({'time':str(at),'proposed':len(states),'distinct':len(unique),'retained':len(result),
'best':str(result[0].total) if result else None})
if self.observe:self.observe('frontier',at,None,tuple(states),result)
return result
def frontier_score(self,state,until):
# The already chosen action can continue beyond this computational cut.
# Only its heard prefix participates in an equal-time comparison. The
# original notes, full route and obligations remain intact in the state.
if until==state.action.end and state.full_evaluation is not None:return state.full_evaluation
a=state.action
ns=tuple(dataclasses.replace(n,end=min(n.end,until)) if n.end>until else n for n in a.notes if n.start<until)
clipped=dataclasses.replace(a,end=until,notes=ns)
home=state.home if state.home and state.home[-1].end<=until else ()
parent=state.parent
return self.selector.extend(parent.full_evaluation,parent.notes,clipped,home,old_home=parent.home,start=self.start,
contracts=tuple(dict.fromkeys(c for action in state.actions for c in action.contracts)))
def compose(self):
self.decisions=[];began=time.monotonic();backtracks=0
# Compare full actions by cost per exact quarter, with a bounded repair
# frontier. This is a local policy, not a claim of globally optimal melody.
stack=[(self.start,State(),None)];rankings={}
while stack:
at,state,iterator=stack[-1]
if at==self.end:final=state;break
if iterator is None:
options=sorted(self.candidates(at,state),key=lambda v:(v[0].total,v[1].key))
rankings[id(state)]=tuple({'kind':a.kind,'start':str(a.start),'end':str(a.end),
'notes':[(str(n.start),str(n.end),n.pitch.number()) for n in a.notes],
'evaluation':dataclasses.asdict(e)} for e,a in options[:4])
iterator=iter(options);stack[-1]=(at,state,iterator)
for evaluation,action in iterator:
next_state=self.advance(state,evaluation,action)
if next_state is not None:
next_state=dataclasses.replace(next_state,alternatives=rankings[id(state)])
stack.append((action.end,next_state,None));break
else:
stack.pop();backtracks+=1
if backtracks>self.rules['backtracks']:raise SearchExhausted(f'at {at}: action repair budget exhausted')
else:raise SearchExhausted('no complete action path')
self.timings['backtracks']=backtracks
self.decisions=[];self.frontiers=[];began=time.monotonic()
self.successor_cache.clear()
self.timings['retrieval_cache_hits']=self.timings['retrieval_cache_misses']=0
states=(State(),);at=self.start
while at<self.end:
# Source quarter-time frontiers are computational, not phrase endings.
# Tuplets, delayed approaches and sustained notes retain their full tails.
until=min(self.end,F(at.numerator//at.denominator+1))
agenda={};ready=[];queue=[];queued=set()
for state in states:
cursor=state.action.end if state.action else self.start
if cursor>=until:ready.append(state)
else:
agenda.setdefault(cursor,[]).append(state)
if cursor not in queued:heapq.heappush(queue,cursor);queued.add(cursor)
while queue:
cursor=heapq.heappop(queue)
partial=self.retain(agenda.pop(cursor),cursor)
for state in partial:
for evaluation,action in self.candidates(cursor,state):
next_state=self.advance(state,evaluation,action)
if next_state is None:continue
if action.end>=until:ready.append(next_state)
else:
agenda.setdefault(action.end,[]).append(next_state)
if action.end not in queued:heapq.heappush(queue,action.end);queued.add(action.end)
# This exact-time frontier will never be expanded again. Keep chosen
# actions in immutable histories, not tens of thousands of rejected
# concrete actions from obsolete frontiers (especially across workers).
self.operators.cache.clear();self.operators.validity_cache.clear()
self.selector.static.clear();self.selector.cost_static.clear()
if not ready:raise SearchExhausted(f'No continuation at {until} within deterministic search budget')
scored=[]
for state in ready:
evaluation=self.frontier_score(state,until)
scored.append(dataclasses.replace(state,total=evaluation.total,evaluation=evaluation))
states=self.retain(scored,until);at=until
completed=tuple(s for s in states if not s.pending)
if not completed:raise SearchExhausted('No complete phrase within deterministic frontier budget; not a proof of musical impossibility')
completed=tuple(sorted(completed,key=self.rank))
self.completed=(completed[0],*sequences.diverse(completed[1:],self.rank,self.diversity,self.rules['completed']-1))
final=completed[0]
self.timings['action_search_seconds']=time.monotonic()-began
self.timings['frontiers']=len(self.frontiers)
path=[];state=final
while state.action:
path.append(state);state=state.parent
while state.action:path.append(state);state=state.parent
path.reverse();phrases=[];pending=[]
self.selected_actions=tuple(s.action for s in path)
self.selected_actions=final.actions
boundaries=[x-self.analysis.body_offset for x in self.analysis.boundaries]
checkpoints=set(boundaries[4::4])
for state in path:
action=state.action;pending.append(state)
self.operators.coverage['selected:'+action.kind]+=1
self.decisions.append({'start':str(action.start),'end':str(action.end),'selected':action.kind,
'evaluation':dataclasses.asdict(state.evaluation),'prefix_total':state.total,'alternatives':state.alternatives})
# Reporting groups the selected thought; it does not insert events.
held=bool(action.notes and action.notes[-1].end-action.notes[-1].start>=2)
breath=not action.notes and action.end-action.start>=1
if held or breath or action.end==self.end:
'evaluation':dataclasses.asdict(state.evaluation),'prefix_total':state.total})
# Reporting partitions never invent a cadence or split an active action.
boundary=any(action.start<x<=action.end for x in checkpoints)
if (boundary and not state.pending) or action.end==self.end:
phrases.append(self.phrase(pending,len(phrases)));pending=[]
for name,count in self.operators.coverage.items():
stage,kind=name.split(':',1);self.coverage.setdefault(kind,{})[stage]=count
self.timings['selected_cost']=final.total
self.winner_evaluation=final.evaluation
return tuple(phrases)
def phrase(self,states,index):
@@ -132,11 +261,7 @@ class Builder:
anchor=types.Anchor(f'gesture_{index}:arrival',last.start,last.start,last.pitch,
frozenset({last.pitch.number()%12}),self.harmony.profile.register,last.end-last.start,
'terminal' if ending=='handoff' else 'intermediate',region.chord,False,last.end,ending)
raw=Counter();contributions=Counter();weights={}
for s in states:
raw.update(s.evaluation.raw);contributions.update(s.evaluation.contributions);weights.update(s.evaluation.weights)
evaluation=scoring.Evaluation(selection.VERSION,True,dict(raw),weights,dict(contributions),sum(s.evaluation.total for s in states),
{'span':str(end-start),'attacks':len(ns),'ticks_per_quarter':12})
evaluation=self.selector.complete(actions,states[-1].home,start=start,end=end)
direction='up' if len(ns)>1 and ns[-1].pitch.number()>ns[0].pitch.number() else 'down'
rhythm=tuple(g for a in actions for g in groups.describe(tuple((n.start-a.start,n.end-a.start) for n in a.notes),origin=a.start,source=a.kind))
return types.Phrase(f'gesture_{index}',start,end,ns,actions[0].kind,direction,ending,anchor,
+9 -5
View File
@@ -44,6 +44,10 @@ def chromatic(target,delta):
def eligible_color(rule,span,harmony):
# An explicit no-chord span (or missing harmony) has no optional colors.
# In particular, looking ahead for an anticipation must not try to parse
# the display label N.C. as a written chord.
if not span or not span.scale or not span.chord:return False
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span)
cache=harmony.color_eligibility
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
@@ -91,7 +95,7 @@ def validate(notes,contracts,harmony):
arrival=c.boundary if c.boundary is not None else target.start
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
planned_color=any(a.kind=='color' and a.target==target and a.events==(target,) for a in contracts)
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end)):return {},c.id+': invalid destination hold'
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in harmony.permitted(arrival,target.end)):return {},c.id+': invalid destination hold'
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1))))
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps'
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F())
@@ -101,7 +105,7 @@ def validate(notes,contracts,harmony):
cursor=n.end+(gaps[i] if i<len(gaps) else 0)
for index in rule.get('mapped_indices',[]):
n=c.events[index]
if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
elif c.kind=='delayed_approach':
@@ -110,7 +114,7 @@ def validate(notes,contracts,harmony):
n,=c.events
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach'
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register'
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid delayed destination'
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in harmony.permitted(target.start,target.end):return {},c.id+': invalid delayed destination'
elif c.kind=='color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule:return {},c.id+': unknown color policy'
@@ -140,7 +144,7 @@ def validate(notes,contracts,harmony):
if held.end-c.boundary>F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']:
return {},c.id+': missed suspension resolution'
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
if held.pitch.number()%12 not in types.permitted(harmony.choices,held.start,held.end):return {},c.id+': suspension outside mapped collections'
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside mapped collections'
elif c.kind=='anticipated_color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
@@ -158,7 +162,7 @@ def validate(notes,contracts,harmony):
if c.boundary is None or not n.start<c.boundary or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation'
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack'
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,target.end):return {},c.id+': invalid future destination'
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in harmony.permitted(c.boundary,target.end):return {},c.id+': invalid future destination'
elif c.kind=='blues':
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
+6
View File
@@ -57,6 +57,7 @@ class Harmony:
self._at = {}
self._segments = {}
self._members = {}
self._permitted = {}
self.evaluations = {}
self.color_eligibility = {}
def at(self, time):
@@ -74,6 +75,11 @@ class Harmony:
spans = [s for s in self.spans if s.start < end and s.end > start]
self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
return self._members[key]
def permitted(self,start,end):
key=(start,end)
if key not in self._permitted:
self._permitted[key]=generator.solo.types.permitted(self.choices,start,end)
return self._permitted[key]
def function(self, number, span):
pitch = number % 12
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
+51 -21
View File
@@ -33,8 +33,9 @@ class Operators:
def __init__(self,harmony,analysis,end,start=F(),enabled=True,sequences=None,rhythms=None):
self.harmony=harmony;self.analysis=analysis;self.start=start;self.end=end;self.enabled=enabled
self.rules=vocabulary.data('solo')['operators'];self.cache={};self.pitch_cache={};self.coverage=Counter()
self.ordinary_cache={}
self.ordinary_cache={};self.validity_cache={}
self.sequences=sequences
self.licensed=frozenset(s.steps for values in sequences.paths.values() for s in values) if sequences else None
self.patterns=[]
if rhythms is not None:
library=rhythm.Rhythms(rhythms,analysis.tempo)
@@ -57,6 +58,7 @@ class Operators:
if self.harmony.profile.permits((p:=types.scale_pitch(span.scale,i)).number()))
return self.pitch_cache[span.scale]
@functools.lru_cache(maxsize=32768)
def note(self,start,end,pitch,role):
# Identity follows exact musical content, not an output filename or hash.
return types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
@@ -68,7 +70,7 @@ class Operators:
return tuple(p for octave in range(1,6)
if self.harmony.profile.permits((p:=dataclasses.replace(spelling,octave=octave)).number()))
def _accepted(self,action):
def _accepted(self,action,validate=True):
self.coverage['proposed:'+action.kind]+=1
if action.end>self.end or action.end<=action.start:
self.coverage['rejected_by_allocation:'+action.kind]+=1;return False
@@ -76,40 +78,51 @@ class Operators:
self.coverage['rejected_by_physical_domain:'+action.kind]+=1;return False
if not groups.valid(tuple((n.start-action.start,n.end-action.start) for n in action.notes)):
self.coverage['rejected_by_rhythm:'+action.kind]+=1;return False
self.coverage['feasible:'+action.kind]+=1
return self.is_valid(action) if validate else True
def is_valid(self,action):
key=action.key
if key in self.validity_cache:return self.validity_cache[key]
if action.contracts:
valid=self.harmony.evaluate(action.notes,expressions=action.contracts,complete=False,until=action.end).valid
else:
valid=True
for n in action.notes:
key=(n.start,n.end,n.pitch.number())
if key not in self.ordinary_cache:self.ordinary_cache[key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
if not self.ordinary_cache[key]:valid=False;break
if not valid:
self.coverage['rejected_by_rule:'+action.kind]+=1
return False
self.coverage['valid:'+action.kind]+=1
return True
note_key=(n.start,n.end,n.pitch.number())
if note_key not in self.ordinary_cache:self.ordinary_cache[note_key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
if not self.ordinary_cache[note_key]:valid=False;break
self.coverage[('valid:' if valid else 'rejected_by_rule:')+action.kind]+=1
self.validity_cache[key]=valid
if len(self.validity_cache)>8192:self.validity_cache.pop(next(iter(self.validity_cache)))
return valid
@functools.lru_cache(maxsize=8192)
def _color(self,n,span):
result=[]
for rule in vocabulary.data('harmony')['colors']:
if rule.get('resolve') or not expressions.eligible_color(rule,span,self.harmony):continue
expected=expressions.degree_pitch(expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],n.pitch.number())
if expected.number()!=n.pitch.number():continue
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
yield Action(n.start,n.end,(n,),'color',(c,),rule['id'])
result.append(Action(n.start,n.end,(n,),'color',(c,),rule['id']))
return tuple(result)
def at(self,time,eligible=None,first=None):
def proposals(self,time):
return self.at(time,validate=False)
def at(self,time,eligible=None,first=None,*,validate=True):
"""Unpruned legal actions. Incoming-state ranking is a separate operation."""
if eligible is None and first is None and time in self.cache:return self.cache[time]
if eligible is None and first is None and (time,validate) in self.cache:return self.cache[(time,validate)]
span=self.harmony.at(time);values=[]
if not span or not span.scale:
end=min(self.end,span.end if span else self.end)
result=(Action(time,end,(),'space'),)
if eligible is not None:return tuple(a for a in result if eligible(a))
self.cache[time]=result;return result
self.cache[(time,validate)]=result;return result
def add(action):
if eligible is not None and not eligible(action):return
if self._accepted(action):values.append(action)
if self._accepted(action,validate=validate):values.append(action)
def entry(a,b,pitch):
return first is None or first==(a,b,pitch.number())
durations=tuple(map(F,self.rules['durations']))
@@ -122,7 +135,7 @@ class Operators:
for pitch in self.pitches(time):
if not entry(time,time+duration,pitch):continue
n=self.note(time,time+duration,pitch,'motion')
if pitch.number()%12 in types.permitted(self.harmony.choices,time,n.end):
if pitch.number()%12 in self.harmony.permitted(time,n.end):
add(Action(time,n.end,(n,),'tone'))
if self.enabled:
delayed=vocabulary.data('lead_ins').get('delayed_approach')
@@ -139,7 +152,7 @@ class Operators:
for step in delayed['steps']:
n=self.note(time,time+F(delayed['max_note']),expressions.chromatic(p,-step),'delayed_approach')
if not entry(n.start,n.end,n.pitch):continue
if n.pitch.number()%12 in types.permitted(self.harmony.choices,n.start,n.end):continue
if n.pitch.number()%12 in self.harmony.permitted(n.start,n.end):continue
c=types.ExpressionContract(n.event_id+':delayed','delayed_approach',delayed['id'],(n,),target,region.chord)
add(Action(time,n.end,(n,),'delayed_approach',(c,),delayed['id']))
# Optional colors retain functional degree identity independently of
@@ -254,7 +267,7 @@ class Operators:
if first is not None and (time+pattern[0][0],time+pattern[0][1])!=first[:2]:continue
for stride in (-2,-1,1,2):
# Catalog licenses relative paths, but never sets family probability.
if self.sequences and not any(s.steps==(stride,)*(len(pattern)-1) for s in self.sequences.paths.get(len(pattern),())):continue
if self.licensed is not None and (stride,)*(len(pattern)-1) not in self.licensed:continue
for initial in self.pitches(time):
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
cursor=time;p=initial;notes=[]
@@ -265,8 +278,24 @@ class Operators:
if index:
try:p=types.scale_pitch(region.scale,types.scale_index(region.scale,p.number())+stride)
except ValueError:break
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'arpeggio'))
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'arpeggio',source=f'{name}:degree_stride:{stride}'))
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'scale_thirds'))
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'scale_thirds',source=f'{name}:degree_stride:{stride}'))
# A real arpeggio walks successive members of the WRITTEN chord, not
# alternate degrees of its selected seven-note collection.
for direction in (-1,1):
for initial in self.pitches(time):
if initial.number()%12 not in span.roles.basic:continue
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
p=initial;notes=[]
for index,(a,b) in enumerate(pattern):
region=self.harmony.at(time+a)
if not region or not region.scale:break
if index:
choices=[q for q in self.pitches(time+a) if q.number()%12 in region.roles.basic and (q.number()-p.number())*direction>0]
if not choices:break
p=min(choices,key=lambda q:abs(q.number()-p.number()))
notes.append(self.note(time+a,time+b,p,'arpeggio'))
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'arpeggio',source=f'{name}:written_chord:{direction}'))
# Equivalent musical actions need one stable derivation, not extra votes.
unique={}
for a in values:unique.setdefault(a.key,a)
@@ -274,9 +303,10 @@ class Operators:
if eligible is not None or first is not None:return result
if len(self.cache)>=32:self.cache.pop(next(iter(self.cache)))
if len(self.ordinary_cache)>8192:self.ordinary_cache.clear()
self.cache[time]=result
self.cache[(time,validate)]=result
return result
@functools.lru_cache(maxsize=512)
def related(self,time,home,kind):
"""Construct a heard-theme return or answer as a whole group, never a prefix."""
if len(home)<3:return ()
+1 -1
View File
@@ -4,7 +4,7 @@ import dataclasses, hashlib
class Policy:
long_note: float = 2.
stopping_note: float = 1.
composition_version: str = 'joint-actions-1'
composition_version: str = 'phrase-frontiers-2'
DEFAULT=Policy()
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
+260 -48
View File
@@ -1,25 +1,42 @@
"""Interpretable shared action ranking; no song identity or absolute bar features.
"""Canonical sounded-event evaluation, shared by inference and training.
Complete actions compete by exact cost per quarter, with bounded backtracking.
The final report exposes the exact same contributions used by search.
Role-specific learned integer coefficients can augment these general priors.
Constructor names are not musical evidence. Complete equal-span candidates
are compared using this record; local retrieval is explicitly only a heuristic.
"""
from collections import Counter
from fractions import Fraction as F
import dataclasses
import functools
from types import MappingProxyType
from generator import vocabulary, harmony as chords, model
from generator.solo import expressions, scoring, types
VERSION='action-selection-1'
VERSION='phrase-selection-2'
DEFAULT={'excessive_leap':12,'register_distance':1,'oscillation':6,'repeated_attack':3,
'unprepared_space':12,'rest_after_rest':24,'source_relation':-2,'theme_relation':-4,
'unprepared_space':12,'source_relation':-2,'theme_relation':-4,
'sustained_arrival':-1,'directed_motion':-1}
CONDITIONS={
'quality':frozenset(('major','minor','dominant','half_diminished','other')),
'degree':frozenset(map(str,range(12))),
'motion':frozenset(map(str,range(-12,13))),
'metric':frozenset(('barline','beat','eighth','subdivision')),
'duration':frozenset(str(F(i,4)).replace('/','_') for i in range(1,17))|{'1_3','2_3'},
'source_pair':frozenset(('yes','no')),
}
def rule_conditions(key):
if not key.startswith('joint/'):return None
pairs=tuple(p.split('=',1) for p in key[6:].split('&'))
if not 2<=len(pairs)<=3 or any(len(p)!=2 for p in pairs):raise ValueError('Expected two or three contextual conditions')
if len({p[0] for p in pairs})!=len(pairs) or list(pairs)!=sorted(pairs):raise ValueError('Noncanonical contextual rule')
if any(k not in CONDITIONS or v not in CONDITIONS[k] for k,v in pairs):raise ValueError('Forbidden contextual rule condition')
return dict(pairs)
def validate_weights(weights):
durations={str(F(i,4)).replace('/','_') for i in range(1,17)}|{'1_3','2_3'}
kinds={'tone','color','color_resolution','anticipation','anticipated_color','approach','enclosure','connection','delayed_approach','space','rest','scalar','arpeggio','return','answer'}
allowed={f'kind/{k}' for k in kinds}|{f'duration/{d}' for d in durations}
allowed={f'duration/{d}' for d in durations}
allowed|={f'harmony/{q}/{p}/{d}' for q in ('major','minor','dominant','half_diminished','other') for p in range(12) for d in durations}
allowed|={f'motion/{p}/{d}' for p in range(-12,13) for d in durations}
allowed|={f'metric/{m}/{d}' for m in ('barline','beat','eighth','subdivision') for d in durations}
@@ -30,9 +47,10 @@ def validate_weights(weights):
allowed|={f'continuation/{a}/{b}' for a in ('up','down','repeat') for b in ('up','down','repeat')}
allowed|={'source/rhythm_match','theme/realized_identity',*(f'space/{s}' for s in ('unharmonized','breath','after_arrival','delayed_entrance'))}
for key,value in weights.items():
if key not in allowed or not isinstance(value,int) or isinstance(value,bool):
joint=rule_conditions(key)
if (key not in allowed and joint is None) or not isinstance(value,int) or isinstance(value,bool) or abs(value)>1024:
raise ValueError('Unsupported fitted feature (only shared musical categories): '+key)
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-' for c in key):
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-=&' for c in key):
raise ValueError('Noncanonical fitted feature: '+key)
return dict(weights)
@@ -43,7 +61,21 @@ def signature(notes):
return tuple((n.start-first.start,n.end-n.start,n.pitch.number()-first.pitch.number()) for n in notes)
def matches_signature(notes,expected):
"""Exact signature equality, stopping at the first differing feature."""
if len(notes)!=len(expected):return False
if not notes:return True
first=notes[0];pitch=first.pitch.number()
return all(n.pitch.number()-pitch==relative and n.end-n.start==duration and n.start-first.start==onset
for n,(onset,duration,relative) in zip(notes,expected))
class Selector:
@property
def weights(self):return self._weights
@weights.setter
def weights(self,value):self._weights=MappingProxyType(validate_weights(value))
def __init__(self,harmony,analysis,center,weights=None):
self.harmony=harmony;self.analysis=analysis;self.center=center
self.weights=validate_weights(weights if weights is not None else vocabulary.role().get('selection_weights',{}))
@@ -52,11 +84,29 @@ class Selector:
for a,b,c in zip(analysis.melody,analysis.melody[1:],analysis.melody[2:]))
self.source_rhythms=Counter(n.end-n.start for n in analysis.melody)
self.static={}
self.home_signatures={}
self.cost_static={};self.cost_weights=None
def contexts(self,notes,recent=()):
history=list(recent[-2:])
for n in notes:
region=self.harmony.at(n.start)
if region and region.chord:
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
delta=n.pitch.number()-history[-1].pitch.number() if history else None
previous=history[-1].pitch.number()-history[-2].pitch.number() if len(history)>1 else None
metric='barline' if n.start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if n.start%1==0 else 'eighth' if n.start%1==F(1,2) else 'subdivision'
context={'quality':expressions.quality(chord),'degree':str((n.pitch.number()-root)%12),
'metric':metric,'duration':str(n.end-n.start).replace('/','_'),
'source_pair':'yes' if (previous,delta) in self.source_pairs else 'no'}
if delta is not None:context['motion']=str(max(-12,min(12,delta)))
yield context,int((n.end-n.start)*12)
history.append(n)
def base(self,action):
if action.key in self.static:return self.static[action.key]
values=Counter();prior=Counter();ticks=int((action.end-action.start)*12)
values['kind/'+action.kind]=ticks
for contract in action.contracts:
if contract.target.start>=action.end and contract.events:
# Pending destinations must be visible to selection. Identical heard
@@ -65,41 +115,48 @@ class Selector:
values[f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}']+=ticks
values['obligation/delay/'+str(target.start-last.start).replace('/','_')]+=ticks
values['obligation/hold/'+str(target.end-target.start).replace('/','_')]+=ticks
if not action.notes:
values['space/'+('unharmonized' if action.kind=='space' else 'breath')]=ticks
for n in action.notes:
duration=n.end-n.start;length=int(duration*12)
label=str(duration).replace('/','_')
values['duration/'+label]+=length
beat=(n.start%1)
metric='barline' if n.start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if beat==0 else 'eighth' if beat==F(1,2) else 'subdivision'
values['metric/'+metric+'/'+label]+=length
band=max(-4,min(4,(n.pitch.number()-self.center+6)//12))
values[f'register/{band}']+=length
region=self.harmony.at(n.start)
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
degree=(n.pitch.number()-root)%12
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
prior['register_distance']+=length*max(0,abs(n.pitch.number()-self.center)-7)
if duration>=2 and n.pitch.number()%12 in self.harmony.members(n.start,n.end):
prior['sustained_arrival']+=length
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
f,p=self.note_base(n.start,n.end,n.pitch.number())
values.update(f);prior.update(p)
self.static[action.key]=(values,prior)
if len(self.static)>8192:self.static.pop(next(iter(self.static)))
return values,prior
@functools.lru_cache(maxsize=32768)
def note_base(self,start,end,number):
values=Counter();prior=Counter()
duration=end-start;length=int(duration*12)
label=str(duration).replace('/','_')
values['duration/'+label]+=length
beat=(start%1)
metric='barline' if start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if beat==0 else 'eighth' if beat==F(1,2) else 'subdivision'
values['metric/'+metric+'/'+label]+=length
band=max(-4,min(4,(number-self.center+6)//12))
values[f'register/{band}']+=length
region=self.harmony.at(start)
if not region or not region.chord:return values,prior
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
degree=(number-root)%12
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
prior['register_distance']+=length*max(0,abs(number-self.center)-7)
if duration>=2 and number%12 in self.harmony.members(start,end):
prior['sustained_arrival']+=length
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
return values,prior
def features(self,action,recent=(),home=(),previous_kind=''):
if any(n.continuation for n in action.notes):action=dataclasses.replace(action,notes=types.sounding_notes(action.notes))
static,base=self.base(action);values=Counter(static);prior=Counter(base)
if not action.notes:
if action.kind=='space':return values,prior
ticks=int((action.end-action.start)*12)
if previous_kind in ('rest','space'):prior['rest_after_rest']+=ticks
elif recent and recent[-1].end-recent[-1].start>=F(3,2):values['space/after_arrival']+=ticks
elif recent:prior['unprepared_space']+=ticks
else:values['space/delayed_entrance']+=ticks
return values,prior
# Silence is measured as intervals in the sounded stream, independent of
# the number of rest actions used to encode it. Missing harmony is distinct.
cursor=action.start;before=recent[-1] if recent else None
for n in (*action.notes,None):
until=n.start if n else action.end
if until>cursor:
f,p=self.space_features(cursor,until,None if before is None else before.end-before.start>=F(3,2))
values.update(f);prior.update(p)
if n:cursor=n.end;before=n
previous=recent[-1] if recent else None
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(recent,recent[1:])]
for n in action.notes:
@@ -121,24 +178,72 @@ class Selector:
if 0<abs(delta)<=2 and intervals and delta*intervals[-1]>0:prior['directed_motion']+=length
intervals.append(delta)
previous=n
if action.kind=='return' and len(home)>=3:
if signature(action.notes)==signature(home):
recently_heard=any(signature(recent[i:i+len(home)])==signature(home)
for i in range(max(0,len(recent)-len(home)+1)))
if not recently_heard:prior['theme_relation']+=int((action.end-action.start)*12)
if recent and home:
if len(home)>=3:
key=id(home)
if key not in self.home_signatures:
self.home_signatures[key]=(home,signature(home))
if len(self.home_signatures)>32:self.home_signatures.pop(next(iter(self.home_signatures)))
expected=self.home_signatures[key][1]
joined=(*recent,*action.notes)
count=min(len(home),len(joined))
if count>=3 and signature(joined[-count:])==signature(home[:count]):
values['theme/realized_identity']+=12
for i in range(max(0,len(recent)-len(home)+1),len(joined)-len(home)+1):
part=joined[i:i+len(home)]
if part[0].start>home[-1].end and matches_signature(part,expected):
values['theme/realized_identity']+=12
prior['theme_relation']+=12
joint=[(key,rule_conditions(key)) for key in self.weights if key.startswith('joint/')]
if joint:
for context,ticks in self.contexts(action.notes,recent):
for key,conditions in joint:
if all(context.get(k)==v for k,v in conditions.items()):values[key]+=ticks
return values,prior
def complete(self,actions,home=(),*,start=None,end=None):
"""The sole authoritative score. Ties/rests/partitions are canonicalized."""
from generator.solo.operators import Action
actions=tuple(actions)
first=start if start is not None else actions[0].start
last=end if end is not None else actions[-1].end
notes=types.sounding_notes(tuple(n for a in actions for n in a.notes))
contracts=tuple(dict.fromkeys(c for a in actions for c in a.contracts))
return self.evaluate(Action(first,last,notes,'sounded_phrase',contracts),home=home)
def extend(self,previous,old_notes,action,home=(),*,old_home=(),start=F(),contracts=()):
"""Exact sufficient-statistic update, checked against complete() in tests.
Reevaluate the seam and the tail containing any possible new home match.
Previously completed events are not rescored on every search expansion.
"""
from generator.solo.operators import Action
if previous is None or home!=old_home or any(n.continuation for n in action.notes):
notes=types.sounding_notes((*old_notes,*action.notes))
return self.evaluate(Action(start,action.end,notes,'sounded_phrase',contracts),home=home)
span=F(previous.denominators['span'])
raw=Counter({k:v*span for k,v in previous.raw.items() if not k.startswith('obligation/')})
index=max(0,len(old_notes)-max(3,len(home)+2))
tail=old_notes[index:];context=old_notes[max(0,index-3):index]
begin=tail[0].start if index else start
before=Action(begin,action.start,tail,'sounded_phrase')
after=Action(begin,action.end,(*tail,*action.notes),'sounded_phrase')
for sign,part in ((-1,before),(1,after)):
f,p=self.features(part,context,home)
for k,v in f.items():
if not k.startswith('obligation/'):raw[k]+=sign*v
for k,v in p.items():raw['prior/'+k]+=sign*v
obligations,_=self.base(Action(start,action.end,(),'sounded_phrase',contracts))
raw.update({k:v for k,v in obligations.items() if k.startswith('obligation/')})
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
duration=action.end-start
raw={k:F(v)/duration for k,v in raw.items() if v}
contributions={k:v*weights.get(k,0) for k,v in raw.items()}
return scoring.Evaluation(VERSION,True,raw,weights,contributions,sum(contributions.values(),F()),
{'span':str(duration),'ticks_per_quarter':12,'attacks':len(old_notes)+len(action.notes)})
def evaluate(self,action,recent=(),home=(),previous_kind=''):
features,prior=self.features(action,recent,home,previous_kind)
contributions={'prior/'+k:v*DEFAULT[k] for k,v in prior.items() if v}
contributions.update({k:v*self.weights.get(k,0) for k,v in features.items() if v})
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
raw={'prior/'+k:v for k,v in prior.items()}|dict(features)
raw={'prior/'+k:v for k,v in prior.items() if v}|{k:v for k,v in features.items() if v}
span=action.end-action.start
contributions={k:F(v)/span for k,v in contributions.items()}
raw={k:F(v)/span for k,v in raw.items()}
@@ -152,3 +257,110 @@ class Selector:
numerator=sum(v*DEFAULT[k] for k,v in prior.items())
numerator+=sum(v*self.weights.get(k,0) for k,v in features.items())
return F(numerator)/(action.end-action.start)
@functools.lru_cache(maxsize=32768)
def transition_cost(self,delta,preceding,earlier,length,label,contiguous):
token=max(-12,min(12,delta))
sign=lambda x:'up' if x>0 else 'down' if x<0 else 'repeat'
cost=length*self.weights.get(f'motion/{token}/{label}',0)
cost+=length*DEFAULT['excessive_leap']*max(0,abs(delta)-7)
if delta==0 and contiguous:cost+=length*DEFAULT['repeated_attack']
if preceding is not None:
cost+=length*self.weights.get(f'continuation/{sign(preceding)}/{sign(delta)}',0)
if (preceding,delta) in self.source_pairs:cost+=length*DEFAULT['source_relation']
if 0<abs(delta)<=2 and delta*preceding>0:cost+=length*DEFAULT['directed_motion']
if earlier is not None and earlier==-preceding==delta and delta:cost+=length*DEFAULT['oscillation']
return cost
@functools.lru_cache(maxsize=32768)
def seam_cost(self,prefix,context,contiguous):
def total(initial):
pitches=list(initial);value=0
for index,(number,length,label,touch) in enumerate(prefix):
if pitches:
value+=self.transition_cost(number-pitches[-1],pitches[-1]-pitches[-2] if len(pitches)>1 else None,
pitches[-2]-pitches[-3] if len(pitches)>2 else None,length,label,contiguous if index==0 and initial else touch)
pitches.append(number)
return value
return total(context)-total(())
@functools.lru_cache(maxsize=32768)
def unary_cost(self,start,end,number):
features,prior=self.note_base(start,end,number)
return sum(v*DEFAULT[k] for k,v in prior.items())+sum(v*self.weights.get(k,0) for k,v in features.items())
def intrinsic_cost(self,action):
"""Exact ordinary retrieval cost without constructing discarded reports.
Unary, transition, silence and pending-target contributions are identical
to features(action). Contextual learned conjunctions use the full path.
"""
notes=types.sounding_notes(action.notes) if any(n.continuation for n in action.notes) else action.notes
cost=0;history=[];cursor=action.start;settled=None;previous=None
for n in notes:
if n.start>cursor:cost+=self.gap_cost(cursor,n.start,settled)
number=n.pitch.number();length=int((n.end-n.start)*12);label=str(n.end-n.start).replace('/','_')
cost+=self.unary_cost(n.start,n.end,number)
if history:
cost+=self.transition_cost(number-history[-1],history[-1]-history[-2] if len(history)>1 else None,
history[-2]-history[-3] if len(history)>2 else None,length,label,n.start==previous.end)
history.append(number);previous=n;cursor=n.end;settled=n.end-n.start>=F(3,2)
if action.end>cursor:cost+=self.gap_cost(cursor,action.end,settled)
ticks=int((action.end-action.start)*12)
for contract in action.contracts:
if contract.target.start>=action.end and contract.events:
last=contract.events[-1];target=contract.target
cost+=ticks*self.weights.get(f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}',0)
cost+=ticks*self.weights.get('obligation/delay/'+str(target.start-last.start).replace('/','_'),0)
cost+=ticks*self.weights.get('obligation/hold/'+str(target.end-target.start).replace('/','_'),0)
return cost
def fast_cost(self,action,recent=(),context=None):
"""Unnormalized endpoint-bucket retrieval, with cached internal routes.
Only the first three transitions depend on incoming history. Final ranking
always uses complete(); this heuristic does not compare unequal spans.
"""
# Learned conjunctions include incoming context; do not use a shortcut
# that forgets it. This small fallback is explicit and profileable.
if any(n.continuation for n in action.notes) or any(k.startswith('joint/') for k in self.weights):
f,p=self.features(action,recent)
return sum(v*DEFAULT[k] for k,v in p.items())+sum(v*self.weights.get(k,0) for k,v in f.items())
if self.cost_weights is not self.weights:
self.cost_static.clear();self.transition_cost.cache_clear();self.seam_cost.cache_clear();self.gap_cost.cache_clear();self.unary_cost.cache_clear();self.cost_weights=self.weights
key=id(action)
if key not in self.cost_static:
value=self.intrinsic_cost(action)
prefix=tuple((n.pitch.number(),int((n.end-n.start)*12),str(n.end-n.start).replace('/','_'),
i>0 and action.notes[i-1].end==n.start) for i,n in enumerate(action.notes[:3]))
self.cost_static[key]=(action,value,prefix)
if len(self.cost_static)>32768:self.cost_static.pop(next(iter(self.cost_static)))
_,value,prefix=self.cost_static[key]
if context is None:context=(tuple(n.pitch.number() for n in recent[-3:]),recent[-1].end if recent else None)
if context[0] and prefix:value+=self.seam_cost(prefix,context[0],context[1]==action.notes[0].start)
gap_end=action.notes[0].start if action.notes else action.end
if recent and gap_end>action.start:
# Incoming sustained arrivals change the role of a leading rest, just
# as incoming pitches change the first intervals. Do not cache it as a
# fresh silent entrance in every action.
value+=self.gap_cost(action.start,gap_end,recent[-1].end-recent[-1].start>=F(3,2))-self.gap_cost(action.start,gap_end,None)
return value
@functools.lru_cache(maxsize=8192)
def space_features(self,start,end,settled):
values=Counter();prior=Counter();delayed=0
boundaries=sorted({start,end,*[s.start for s in self.harmony.spans if start<s.start<end],
*[s.end for s in self.harmony.spans if start<s.end<end]})
for left,right in zip(boundaries,boundaries[1:]):
ticks=int((right-left)*12);region=self.harmony.at(left)
label='unharmonized' if not region or not region.scale else 'delayed_entrance' if settled is None else 'after_arrival' if settled else 'breath'
values['space/'+label]+=ticks
if label=='breath':prior['unprepared_space']+=ticks
if label=='delayed_entrance':delayed+=ticks
prior['unprepared_space']+=max(0,delayed-12)
return values,prior
@functools.lru_cache(maxsize=8192)
def gap_cost(self,start,end,settled):
f,p=self.space_features(start,end,settled)
return sum(v*DEFAULT[k] for k,v in p.items())+sum(v*self.weights.get(k,0) for k,v in f.items())
+1 -1
View File
@@ -32,7 +32,7 @@ def _load(contents):
cells=result['rhythms']['cells']+result['solo']['cells']
if len({c['id'] for c in cells})!=len(cells):raise ValueError('Duplicate rhythm identity')
rules=result['solo']
if any(type(rules['operators'][k]) is not int or rules['operators'][k]<=0 for k in ('backtracks','per_state')):raise ValueError('Invalid deterministic search budget')
if any(type(rules['operators'][k]) is not int or rules['operators'][k]<=0 for k in ('histories','completed','per_state')):raise ValueError('Invalid deterministic search budget')
if any(F(d)<=0 for d in rules['operators']['durations']):raise ValueError('Invalid action duration')
for group in rules['groups']:
if not group['values'] or any(F(v.removeprefix('r:'))<=0 for v in group['values']):raise ValueError('Invalid rhythm group')