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
+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())