This commit is contained in:
khodowany
2026-10-04 15:51:18 -05:00
parent 405066eb43
commit f234dd6d51
54 changed files with 2104 additions and 330 deletions
+106
View File
@@ -89,6 +89,17 @@ def validate(notes,contracts,harmony):
last=c.events[-1]
if rule.get('resolve'):
if target is last or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
elif c.kind=='suspension':
rule=next((r for r in vocabulary.data('harmony').get('suspensions',[]) if r['id']==c.rule),None)
if not rule or len(c.events)!=1 or c.boundary is None:return {},c.id+': unsupported suspension'
held,=c.events
if not held.start<=c.boundary-F(rule['preparation'])<c.boundary<held.end:
return {},c.id+': insufficient sounding preparation'
if held.pitch.number()%12 not in harmony.members(held.start,c.boundary):return {},c.id+': nonchordal preparation'
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'
elif c.kind=='anticipation':
if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] or span.start!=c.boundary:
return {},c.id+': unknown anticipation or non-boundary target'
@@ -116,6 +127,34 @@ def validate(notes,contracts,harmony):
return approved,''
def validate_frontier(notes,contracts,harmony,until,complete):
"""Validate heard prefixes and bounded future obligations separately.
Future contract events are used ONLY to establish rule feasibility. They do
not count as heard resolution; the contract stays pending until its entire
target hold is present. Missing past events and expired deadlines fail.
"""
if complete:
approved,error=validate(notes,contracts,harmony)
return approved,(),error
actual={n.event_id:n for n in notes};future=dict(actual);pending=[]
for c in contracts:
required=tuple(dict.fromkeys((*c.events,c.target,*((c.preparation,) if c.preparation else ()))))
incomplete=False
for expected in required:
heard=actual.get(expected.event_id)
if expected.start>=until:
future[expected.event_id]=expected;incomplete=True
elif heard and heard.pitch==expected.pitch and heard.start==expected.start and heard.end==min(until,expected.end):
future[expected.event_id]=expected
incomplete |= heard.end<expected.end
elif not heard or (heard.start,heard.end,heard.pitch)!=(expected.start,expected.end,expected.pitch):
return {},(),c.id+': missing/changed event before resolution deadline'
if incomplete:pending.append(c)
approved,error=validate(tuple(sorted(future.values(),key=lambda n:(n.start,n.event_id))),contracts,harmony)
return {key:c for key,c in approved.items() if key in actual},tuple(pending),error
def proposals(phrase,harmony):
draft=phrase.draft
if not draft or any(n.continuation for n in draft.notes):return
@@ -160,6 +199,73 @@ def proposals(phrase,harmony):
if actual:
actual=tuple(n if a.event_id==target.event_id else a for a in actual)
yield actual,types.ExpressionContract(target.event_id+':anticipate','anticipation','early_chord_tone',(n,),n,harmony.at(target.start).chord,boundary=target.start)
for i,(before,target) in enumerate(zip(notes,notes[1:])):
boundary=target.start
if before.end!=boundary or boundary not in {s.start for s in harmony.spans}:continue
if not enclosures.editable(draft,before.start,target.end):continue
for rule in vocabulary.data('harmony').get('suspensions',[]):
delay=min(F(1,2),F(rule['max_delay']))
if target.end<=boundary+delay:continue
held=dataclasses.replace(before,end=boundary+delay,role='suspension')
resolved=dataclasses.replace(target,start=boundary+delay)
yield (*notes[:i],held,resolved,*notes[i+2:]),types.ExpressionContract(before.event_id+':suspend',
'suspension',rule['id'],(held,),resolved,harmony.at(boundary).chord,boundary=boundary)
def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
"""Expression alternatives compete BEFORE complete-phrase selection.
This rewrites provisional gestures, never the selected chorus. The direct
realization remains available and every alternative uses the same evaluator.
"""
from generator.solo import realization, sequences, phrasing, groups
result=list(phrases)
trace=context.diagnostics.setdefault('expression_operations',{})
for phrase in phrases:
proposed={}
for actual,c in proposals(phrase,harmony):
counts=trace.setdefault(c.kind,dict(proposed=0,valid=0,retained=0,rejected_by_rule=0))
counts['proposed']+=1
old_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes)
new_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in actual)
if groups.valid(old_pattern) and not groups.valid(new_pattern):
counts['rejected_by_rule']+=1;continue
if not drafts.contract_valid(phrase.draft,actual)[0]:
counts['rejected_by_rule']+=1;continue
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,))
if not checked.valid:
counts['rejected_by_rule']+=1;continue
counts['valid']+=1
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
rank=max(0,join-5)+bool(prior and prior.pitch==c.events[0].pitch)
proposed.setdefault(c.kind,[]).append((rank,actual,c))
# One representative per treatment kind, then a bounded second sweep.
options=sequences.diverse([v for values in proposed.values() for v in values],
lambda v:(v[0],policy.stable_rank(phrase.plan.seed,v[2].id)),lambda v:v[2].kind,8)
for _,actual,c in options:
transforms=tuple(dataclasses.replace(t,
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
for t in phrase.draft.transformations)
changed=dataclasses.replace(phrase.draft,id=phrase.draft.id+':'+c.rule,
notes=actual,expressions=(c,),transformations=transforms)
candidate=realization.checked_phrase(phrase.plan,changed,actual,incoming.treatments,incoming,harmony,tempo,phrase.edits)
if candidate is None:continue
trace[c.kind]['retained']+=1
state=dataclasses.replace(phrase.state,notes=candidate.state.notes,last=candidate.state.last,
motifs=candidate.state.motifs,travel=candidate.state.travel)
history=incoming.history
for t in transforms:
ns=tuple(n for n in actual if n.event_id in t.events)
if ns:history=phrasing.remember(history,phrasing.heard(ns,t.operation,bar=bar),bar)
state=dataclasses.replace(state,history=history)
kept_routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==
(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
original=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
result.append(dataclasses.replace(candidate,state=state,initial=phrase.initial,
refined=phrase.refined,before_rhythm=phrase.before_rhythm,before_enclosure=original,routes=kept_routes))
return result
def refine(solo,enabled=True):