Simplification

This commit is contained in:
khodowany
2026-10-05 16:40:22 -05:00
parent 654299decd
commit bcaf2f1403
49 changed files with 922 additions and 3327 deletions
+66 -251
View File
@@ -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