105 lines
4.8 KiB
Python
105 lines
4.8 KiB
Python
"""Bounded comparison of completed source-related phrases."""
|
||
import collections
|
||
import dataclasses
|
||
import fractions
|
||
import time
|
||
import generator.solo.phrases
|
||
import generator.solo.selection
|
||
import generator.solo.sequences
|
||
import generator.solo.policy
|
||
import generator.vocabulary
|
||
|
||
|
||
class SearchExhausted(ValueError):
|
||
pass
|
||
|
||
|
||
@dataclasses.dataclass(frozen=True)
|
||
class State:
|
||
phrases: tuple = ()
|
||
home: tuple = ()
|
||
total: fractions.Fraction = fractions.Fraction()
|
||
|
||
@property
|
||
def notes(self):
|
||
return tuple(n for p in self.phrases for n in p.notes)
|
||
|
||
|
||
class Builder:
|
||
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
|
||
self.opening_candidates = []
|
||
|
||
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,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes))
|
||
|
||
@staticmethod
|
||
def character(state):
|
||
phrase = state.phrases[-1]
|
||
ns = phrase.notes
|
||
# Diversity describes the realized phrase, not its constructor label.
|
||
return (tuple((n.start-phrase.start,n.end-n.start) for n in ns[:4]),
|
||
tuple((b.pitch.number()>a.pitch.number())-(b.pitch.number()<a.pitch.number()) for a,b in zip(ns[:3],ns[1:4])),
|
||
tuple((a.earliest%1,a.effect) for a in phrase.arrivals))
|
||
|
||
def compose(self):
|
||
began = time.monotonic(); self.frontiers = []; self.decisions = []
|
||
openings = []
|
||
states = (State(),); start = self.start
|
||
while start<self.end:
|
||
end = self.phrases.horizon(start)
|
||
successors = []
|
||
for state in states:
|
||
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)}')
|
||
if start>=0 and not openings:
|
||
openings = [s.phrases[-1] for s in successors]
|
||
successors.sort(key=self.rank)
|
||
states = tuple(generator.solo.sequences.diverse(successors,self.rank,
|
||
self.character,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]
|
||
selected_opening = next((p for p in winner.phrases if p.start>=0),None)
|
||
if selected_opening is not None:
|
||
remaining = [p for p in openings if p.notes!=selected_opening.notes]
|
||
examples = (selected_opening,*generator.solo.sequences.diverse(remaining,
|
||
lambda p:(p.evaluation.total,p.id),lambda p:self.character(State((p,))),3))
|
||
self.opening_candidates = [{'selected_in_final_solo':p is selected_opening,'start':str(p.start),'end':str(p.end),
|
||
'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation),
|
||
'events':[(str(n.start),str(n.end),n.pitch.number()) for n in p.notes]} for p in examples]
|
||
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
|