Simplification
This commit is contained in:
+66
-251
@@ -1,268 +1,83 @@
|
||||
"""Bounded search of joint musical actions; one normal production path.
|
||||
|
||||
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
|
||||
"""Bounded comparison of completed source-related phrases."""
|
||||
import collections
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import fractions
|
||||
import time
|
||||
import heapq
|
||||
import itertools
|
||||
import functools
|
||||
import generator.solo.phrases
|
||||
import generator.solo.selection
|
||||
import generator.solo.sequences
|
||||
import generator.solo.policy
|
||||
import generator.vocabulary
|
||||
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, groups, operators, selection, sequences, scoring
|
||||
|
||||
class SearchExhausted(ValueError):pass
|
||||
class SearchExhausted(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class State:
|
||||
total: F = F()
|
||||
recent: tuple = ()
|
||||
phrases: tuple = ()
|
||||
home: tuple = ()
|
||||
last_kind: str = ''
|
||||
parent: object = None
|
||||
action: object = None
|
||||
evaluation: object = None
|
||||
tie: tuple = ()
|
||||
pending: tuple = ()
|
||||
alternatives: tuple = ()
|
||||
actions: tuple = ()
|
||||
notes: tuple = ()
|
||||
full_evaluation: object = None
|
||||
total: fractions.Fraction = fractions.Fraction()
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
return tuple(n for p in self.phrases for n in p.notes)
|
||||
|
||||
@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()):
|
||||
self.analysis=analysis;self.harmony=harmony;self.start=start;self.end=end
|
||||
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 __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=fractions.Fraction()):
|
||||
self.analysis,self.harmony = analysis,harmony
|
||||
self.start,self.end,self.seed = start,end,seed
|
||||
self.phrases = generator.solo.phrases.Phrases(analysis,harmony,sequences,rhythms,start,end,seed,enabled)
|
||||
self.rules = generator.vocabulary.data('solo')['search']
|
||||
self.timings = {}; self.frontiers = []; self.coverage = collections.defaultdict(collections.Counter); self.decisions = []
|
||||
self.failures = self.phrases.rejections
|
||||
|
||||
def candidates(self,at,state):
|
||||
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')
|
||||
# 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 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 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)
|
||||
elif obligations:
|
||||
heard={n.event_id:n for c in state.pending for n in c.events if n.start<action.start}
|
||||
heard.update({n.event_id:n for n in action.notes})
|
||||
check=self.harmony.evaluate(tuple(sorted(heard.values(),key=lambda n:n.start)),
|
||||
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:]
|
||||
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 candidates(self,start,end,state):
|
||||
incoming = state.notes[-1] if state.notes else None
|
||||
values = {}
|
||||
for variant in range(self.rules['alternatives']):
|
||||
value = self.phrases.make(start,end,incoming,state.home,state.phrases,variant)
|
||||
if value is not None:
|
||||
phrase,introduced = value
|
||||
self.coverage[phrase.purpose]['valid'] += 1
|
||||
key = tuple((n.start,n.end,n.pitch.number()) for n in phrase.notes)
|
||||
values.setdefault(key,value)
|
||||
for phrase,_ in values.values():
|
||||
self.coverage[phrase.purpose]['distinct'] += 1
|
||||
return tuple(values.values())
|
||||
|
||||
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)))
|
||||
return state.total,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes))
|
||||
|
||||
def compose(self):
|
||||
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()
|
||||
began = time.monotonic(); self.frontiers = []; self.decisions = []
|
||||
states = (State(),); start = self.start
|
||||
while start<self.end:
|
||||
end = self.phrases.horizon(start)
|
||||
successors = []
|
||||
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
|
||||
path.reverse();phrases=[];pending=[]
|
||||
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})
|
||||
# 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):
|
||||
actions=tuple(s.action for s in states);ns=tuple(n for a in actions for n in a.notes)
|
||||
start,end=actions[0].start,actions[-1].end
|
||||
last=ns[-1] if ns else None
|
||||
held=last and last.end-last.start>=2
|
||||
ending='handoff' if end==self.end else 'settle' if held else 'open'
|
||||
anchor=None
|
||||
if held:
|
||||
region=self.harmony.at(last.start)
|
||||
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)
|
||||
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,
|
||||
tuple(c for a in actions for c in a.contracts),rhythm,'home',evaluation.total,evaluation,tuple(a.source or a.kind for a in actions))
|
||||
for phrase,introduced in self.candidates(start,end,state):
|
||||
total = state.total+phrase.evaluation.total*(end-start)
|
||||
successors.append(State((*state.phrases,phrase),state.home or (introduced if start>=0 and len(introduced)>=3 else ()),total))
|
||||
if not successors:
|
||||
raise SearchExhausted(f'No valid complete phrase at {start}–{end}; failures: {dict(self.failures)}')
|
||||
successors.sort(key=self.rank)
|
||||
states = tuple(generator.solo.sequences.diverse(successors,self.rank,
|
||||
lambda s:(s.phrases[-1].purpose,s.phrases[-1].direction,s.phrases[-1].ending),self.rules['histories']))
|
||||
for state in states:
|
||||
self.coverage[state.phrases[-1].purpose]['retained_histories'] += 1
|
||||
self.frontiers.append({'start':str(start),'end':str(end),'completed':len(successors),'retained':len(states),'best':str(min(s.total for s in states))})
|
||||
start = end
|
||||
self.completed = tuple(sorted(states,key=self.rank))
|
||||
winner = self.completed[0]
|
||||
self.timings['phrase_search_seconds'] = time.monotonic()-began
|
||||
self.winner_evaluation = generator.solo.selection.evaluate(winner.notes,self.start,self.end,self.harmony,
|
||||
home=winner.home,contracts=tuple(c for p in winner.phrases for c in p.expressions),center=self.phrases.center)
|
||||
for k,v in self.phrases.proposals.items():
|
||||
self.coverage[k]['proposed'] = v
|
||||
for p in winner.phrases:
|
||||
self.coverage[p.purpose]['selected'] += 1
|
||||
self.decisions = [{'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation)} for p in winner.phrases]
|
||||
return winner.phrases
|
||||
|
||||
Reference in New Issue
Block a user