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
+65 -3
View File
@@ -34,26 +34,88 @@ class Operators(unittest.TestCase):
other=selection.Selector(b.harmony,changed,b.selector.center,{})
self.assertNotEqual(b.selector.evaluate(slow).raw.get('prior/source_relation',0),other.evaluate(slow).raw.get('prior/source_relation',0))
@unittest.skipUnless(__import__('importlib.util',fromlist=['find_spec']).find_spec('z3'),'development-only pinned solver not installed')
def test_sparse_integer_intervals_are_exact_and_role_separable(self):
from audition.structured_learning import solve
rows=[{'role':'acoustic_solo','delta':{'duration/1':F(2,3)},'prior':F(1,2),'case':'independent'},
{'role':'electric_solo','delta':{'duration/1_2':F(-1,2)},'prior':F(1,4),'case':'independent'}]
result=solve(rows)
self.assertEqual(result['weights'],{'acoustic_solo':{'duration/1':-3},'electric_solo':{'duration/1_2':3}})
self.assertEqual(result,solve(list(reversed(rows))))
impossible=[*rows,{'role':'acoustic_solo','delta':{'duration/1':F(-2,3)},'prior':F(1,2),'case':'opposite'}]
self.assertEqual(solve(impossible)['status'],'incompatible_with_current_features_and_bounds')
def test_full_rollout_uses_the_requested_data_and_role_context(self):
import copy
from generator import vocabulary
from audition.fit_selection import fit
resources=copy.deepcopy(vocabulary.current()['data'])
resources['roles']['difficulty']['triplet_max_bpm']=123
b=builder(('CM7',),count=1)
action=operators.Action(F(),F(4),(fixture.note(0,4,'c'),),'tone')
def proposed(at,**kwargs):
self.assertEqual(vocabulary.current()['role'],'acoustic_solo')
self.assertEqual(vocabulary.data('roles')['difficulty']['triplet_max_bpm'],123)
return (action,) if at==0 else ()
with patch.object(b.operators,'at',side_effect=proposed),contextlib.redirect_stdout(io.StringIO()):
result=fit([('synthetic','acoustic_solo',b,(action,))],1,resources=resources)
self.assertTrue(result['complete'])
def test_complete_training_rounds_accumulate_only_compatible_evidence(self):
from audition.train_completed import training_context,previous_constraints
data=copy.deepcopy(vocabulary.current()['data'])
context=training_context(data)
data['roles']['roles']['electric_solo']['selection_weights']={'duration/1':7}
data['roles']['provenance']='Explicit development trial'
self.assertEqual(context,training_context(data))
cases={'example':{'role':'electric_solo','source_sha256':'source','target_sha256':'example'}}
old={'schema':'complete-candidate-training-1','context':context,'training_dependency':cases,
'constraints':[{'case':'example','role':'electric_solo','delta':{'duration/1':'2/3'},'prior':'1/2'}]}
row=previous_constraints(old,cases,context)[0]
self.assertEqual(row['delta']['duration/1'],F(2,3));self.assertEqual(row['prior'],F(1,2))
changed=copy.deepcopy(cases);changed['example']['target_sha256']='different'
with self.assertRaises(ValueError):previous_constraints(old,changed,context)
data['roles']['roles']['electric_solo']['register_center']+=12
with self.assertRaises(ValueError):previous_constraints(old,cases,training_context(data))
bad=copy.deepcopy(old);bad['constraints'][0]['delta']={'song/example':1}
with self.assertRaises(ValueError):previous_constraints(bad,cases,context)
def test_coverage_diagnostic_does_not_accept_a_shortened_target_hold(self):
from audition.structured_learning import CoverageTrace
from generator.solo.construction import State
held=fixture.note(0,2,'e')
target=operators.Action(F(),F(2),(held,),'tone')
trace=CoverageTrace((target,))
self.assertFalse(trace.matches((dataclasses.replace(held,end=F(1)),),F(1)))
self.assertTrue(trace.matches((held,),F(2)))
# A computational cut through an already committed hold is different
# from selecting a shorter attack and promising to repair it afterward.
s=State(action=target,notes=(held,))
trace('frontier',F(1),None,(s,),(s,))
self.assertFalse(trace.records)
@unittest.skipUnless(__import__('importlib.util',fromlist=['find_spec']).find_spec('z3'),'development-only pinned solver not installed')
def test_shared_fitter_learns_an_independent_synthetic_decision(self):
from audition.fit_selection import fit
b=builder(('CM7',),count=1)
low=operators.Action(F(),F(4),(fixture.note(0,4,'c',event_id='low'),),'tone')
high=operators.Action(F(),F(4),(fixture.note(0,4,'e',event_id='high'),),'tone')
b.selector.weights={}
with patch.object(b.operators,'at',return_value=(low,high)),contextlib.redirect_stdout(io.StringIO()):
with patch.object(b.operators,'at',side_effect=lambda at,**kwargs: (low,high) if at==0 else ()),contextlib.redirect_stdout(io.StringIO()):
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
self.assertTrue(result['complete'])
b.selector.weights=result['weights']['acoustic_solo']
self.assertLess(b.selector.evaluate(high).total,b.selector.evaluate(low).total)
selection.validate_weights(b.selector.weights)
@unittest.skipUnless(__import__('importlib.util',fromlist=['find_spec']).find_spec('z3'),'development-only pinned solver not installed')
def test_fitting_normalization_covers_pickup_plus_whole_note(self):
from audition.fit_selection import fit
b=builder(('CM7',),count=2);b.end=F(17,4)
low=operators.Action(F(),b.end,(fixture.note(0,F(1,4),'c',event_id='lead'),fixture.note(F(1,4),b.end,'e',event_id='low')),'anticipation')
high=dataclasses.replace(low,notes=(low.notes[0],fixture.note(F(1,4),b.end,'g',event_id='high')))
b.selector.weights={}
with patch.object(b.operators,'at',return_value=(low,high)),contextlib.redirect_stdout(io.StringIO()):
with patch.object(b.operators,'at',side_effect=lambda at,**kwargs: (low,high) if at==0 else ()),contextlib.redirect_stdout(io.StringIO()):
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
self.assertTrue(result['complete'])
selection.validate_weights(result['weights']['acoustic_solo'])
@@ -135,7 +197,7 @@ class Operators(unittest.TestCase):
v=b.selector.evaluate(a)
self.assertEqual(v.total,sum(v.contributions.values()))
self.assertEqual(v.total,b.selector.cost(a))
key='kind/tone';b.selector.weights={key:3}
key='duration/'+str(a.end-a.start).replace('/','_');b.selector.weights={key:3}
changed=b.selector.evaluate(a)
self.assertEqual(changed.total,b.selector.cost(a))
self.assertEqual(changed.total-v.total,3*v.raw[key])
+208
View File
@@ -0,0 +1,208 @@
"""Independent counterfactuals: no authored target needed."""
import dataclasses
from fractions import Fraction as F
from unittest.mock import patch
import unittest
from generator.solo import construction, operators, selection
from tests.solo_fixtures import note
from tests.test_phrase_composer import builder
class CompleteSelection(unittest.TestCase):
def test_fast_signature_comparison_is_exact(self):
home=tuple(note(F(i,3),F(i+1,3),('c','e','g')[i%3]) for i in range(12))
variants=[(),home,home[:1],tuple(dataclasses.replace(n,start=n.start+5,end=n.end+5) for n in home)]
for i in range(len(home)):
for field in ('start','end'):
values=list(home);values[i]=dataclasses.replace(values[i],**{field:getattr(values[i],field)+F(1,12)})
variants.append(tuple(values))
values=list(home);values[i]=dataclasses.replace(values[i],pitch=dataclasses.replace(values[i].pitch,octave=5))
variants.append(tuple(values))
expected=selection.signature(home)
for notes in variants:self.assertEqual(selection.matches_signature(notes,expected),selection.signature(notes)==expected)
def test_locally_cheaper_opening_can_lose_complete_phrase(self):
b=builder(('CM7',),count=1)
# C is locally cheaper. Its only continuation leaps to high C, whereas
# the more costly E opening reaches nearby G. All notes are chord tones.
low=operators.Action(F(),F(2),(note(0,2,'c',octave=4),),'tone')
high=operators.Action(F(),F(2),(note(0,2,'e',octave=4),),'tone')
bad=operators.Action(F(2),F(4),(note(2,4,'c',octave=5),),'tone')
good=operators.Action(F(2),F(4),(note(2,4,'g',octave=4),),'tone')
b.selector.weights={'harmony/major/4/2':1}
self.assertLess(b.selector.cost(low),b.selector.cost(high))
def options(at,state):
xs=(low,high) if at==0 else (bad,) if state.notes[-1].pitch.number()==60 else (good,)
return tuple((b.selector.evaluate(a),a) for a in xs)
with patch.object(b,'candidates',side_effect=options):b.compose()
self.assertEqual(b.selected_actions,(high,good))
self.assertEqual(len(b.completed),2)
def test_constructor_labels_and_segmentation_do_not_change_score(self):
b=builder(('CM7',),count=1)
ns=(note(0,1,'c'),note(1,2,'e'),note(3,4,'g'))
whole=operators.Action(F(),F(4),ns,'arpeggio')
split=(operators.Action(F(),F(2),ns[:2],'scalar'),operators.Action(F(2),F(5,2),(),'rest'),
operators.Action(F(5,2),F(3),(),'rest'),operators.Action(F(3),F(4),ns[2:],'answer'))
self.assertEqual(b.selector.complete((whole,)),b.selector.complete(split))
self.assertEqual(b.selector.evaluate(whole),b.selector.evaluate(dataclasses.replace(whole,kind='enclosure')))
held=note(0,4,'e')
tied=(operators.Action(F(),F(2),(dataclasses.replace(held,end=F(2)),),'tone'),
operators.Action(F(2),F(4),(dataclasses.replace(held,start=F(2),continuation=True),),'tone'))
self.assertEqual(b.selector.complete((operators.Action(F(),F(4),(held,),'tone'),)),b.selector.complete(tied))
def test_home_belongs_to_complete_opening_not_recent_buffer(self):
b=builder(('CM7',),count=4);state=construction.State()
for i in range(16):
a=operators.Action(F(i),F(i+1),(note(i,i+1,('c','e','g','b')[i%4]),),'tone')
state=b.advance(state,None,a)
if i<15:self.assertFalse(state.home)
self.assertEqual(len(state.home),16);self.assertEqual(len(state.recent),8)
self.assertEqual(state.home[0].start,0)
def test_true_arpeggio_uses_written_chord_members(self):
b=builder(('C7',),count=1)
values=b.operators.at(F())
arps=[a for a in values if a.kind=='arpeggio']
thirds=[a for a in values if a.kind=='scale_thirds']
self.assertTrue(arps);self.assertTrue(thirds)
for a in arps:
self.assertTrue(all(n.pitch.number()%12 in b.harmony.at(n.start).roles.basic for n in a.notes))
self.assertTrue(any(any(n.pitch.number()%12 not in b.harmony.at(n.start).roles.basic for n in a.notes) for a in thirds))
def test_optional_color_lookahead_rejects_missing_and_no_chord_harmony(self):
from generator.solo import expressions
from generator import vocabulary
b=builder(('CM7',),count=1)
absent=dataclasses.replace(b.harmony.at(F()),scale=None,chord='N.C.')
for rule in vocabulary.data('harmony')['colors']:
self.assertFalse(expressions.eligible_color(rule,None,b.harmony))
self.assertFalse(expressions.eligible_color(rule,absent,b.harmony))
def test_training_features_cannot_encode_role_song_lookup(self):
for key in ('kind/arpeggio','song/all_of_me','target/pitch_sequence','source/hash'):
with self.assertRaises(ValueError):selection.validate_weights({key:1})
with self.assertRaises(ValueError):selection.validate_weights({'duration/1':1025})
from audition.structured_learning import constraint
row=constraint('acoustic_solo',({'duration/1':12},F(3)),({'duration/1':12},F(2)),'test')
self.assertFalse(row['delta']);self.assertEqual(row['prior'],1)
def test_contextual_rules_are_bounded_transposable_and_operational(self):
from audition.structured_learning import contextual_candidates
b=builder(('C7',),count=1);d=builder(('D7',),count=1)
ns=(note(0,1,'d'),note(1,2,'e'))
moved=(note(0,1,'e'),note(1,2,'f',alter=1))
key='joint/degree=4&motion=2&quality=dominant'
selection.validate_weights({key:-3})
left=contextual_candidates(b.selector,ns);right=contextual_candidates(d.selector,moved)
self.assertEqual(left[key],12);self.assertEqual(left[key],right[key])
self.assertNotIn(key,contextual_candidates(b.selector,(ns[0],note(1,2,'f'))))
a=operators.Action(F(),F(2),ns,'tone');plain=b.selector.evaluate(a)
b.selector.weights={key:-3};changed=b.selector.evaluate(a)
self.assertEqual(changed.total-plain.total,-18)
for forbidden in ('joint/degree=4&metric=beat&motion=2&quality=dominant',
'joint/degree=4&song=all_of_me','joint/absolute_bar=4&quality=dominant'):
with self.assertRaises(ValueError):selection.validate_weights({forbidden:1})
def test_open_route_survives_equal_time_frontier_without_extra_attack(self):
b=builder(('CM7',),count=1)
n=note(0,4,'e',event_id='held')
a=operators.Action(F(),F(4),(n,),'tone')
with patch.object(b.operators,'at',return_value=(a,)):b.compose()
self.assertEqual(b.completed[0].notes,(n,))
self.assertEqual(b.selected_actions,(a,))
self.assertEqual(b.completed[0].evaluation,b.selector.complete((a,)))
def test_deferred_validation_preserves_eager_successor_selection(self):
b=builder(('C7',),count=1)
state=construction.State(recent=(note(-1,0,'g'),))
lazy=b.candidates(F(),state)
with patch.object(b.operators,'proposals',side_effect=lambda at:b.operators.at(at)):
eager=b.candidates(F(),state)
self.assertEqual(tuple(a.key for _,a in lazy),tuple(a.key for _,a in eager))
def test_incremental_evaluation_equals_complete_event_evaluation(self):
b=builder(('CM7','Dm7','G7','CM7'),count=8)
actions=[];notes=();previous=None;home=();old_home=();cursor=F()
for i in range(18):
gap=F(1,2) if i%4==2 else F()
length=(F(1,2),F(1),F(3,2))[i%3]
ns=() if i%7==6 else (note(cursor+gap,cursor+gap+length,('c','d','e','g')[i%4]),)
a=operators.Action(cursor,cursor+gap+length,ns,'arbitrary_label')
if i==5:home=notes
contracts=()
fast=b.selector.extend(previous,notes,a,home,old_home=old_home,contracts=contracts)
actions.append(a)
full=b.selector.complete(actions,home)
self.assertEqual(fast,full,(i,fast.raw,full.raw))
previous=fast;notes=(*notes,*ns);old_home=home;cursor=a.end
def test_cached_retrieval_retains_incoming_rhythm_and_pitch_context(self):
b=builder(('C7',),count=1)
incoming=(note(-3,-2,'c'),note(-2,0,'e'))
b.selector.weights={'space/after_arrival':-2,'motion/3/1':2}
values=b.operators.at(F())
selected=[a for a in values if a.kind in ('rest','scalar','arpeggio')][:80]
for a in selected:
self.assertEqual(b.selector.fast_cost(a,incoming),b.selector.cost(a,incoming)*(a.end-a.start),a.key)
def test_intrinsic_retrieval_matches_reports_for_every_action_family(self):
from collections import Counter
b=builder(('C7','FM7'),count=2)
incoming=(note(0,1,'c'),note(1,F(7,2),'e'))
b.selector.weights={'duration/1':4,'harmony/dominant/4/1_2':-3,'obligation/step/-1':-2,
'space/after_arrival':-1,'metric/eighth/1_2':-1,'source/rhythm_match':2,
'register/1':1,'continuation/up/up':-1,'motion/2/1_2':3}
checked=Counter()
for a in b.operators.proposals(F(7,2)):
if checked[a.kind]>=32:continue
checked[a.kind]+=1
self.assertEqual(b.selector.fast_cost(a,incoming),b.selector.cost(a,incoming)*(a.end-a.start),a.key)
self.assertGreaterEqual(len(checked),9)
b.selector.weights={'duration/1':-3}
a=operators.Action(F(7,2),F(9,2),(note(F(7,2),F(9,2),'e'),),'tone')
self.assertEqual(b.selector.fast_cost(a,incoming),b.selector.cost(a,incoming)*(a.end-a.start))
def test_cached_retrieval_treats_tied_fragments_as_one_sounding_event(self):
b=builder(('CM7',),count=1)
incoming=(note(-2,-1,'c'),note(-1,0,'d'))
whole=operators.Action(F(),F(3),(note(0,2,'e'),note(2,3,'g')),'tone')
tied=dataclasses.replace(whole,notes=(note(0,1,'e'),note(1,2,'e',continuation=True),whole.notes[-1]))
expected=b.selector.cost(whole,incoming)*3
self.assertEqual(b.selector.fast_cost(whole,incoming),expected)
self.assertEqual(b.selector.fast_cost(tied,incoming),expected)
def test_successor_cache_preserves_context_and_invalidates_changed_weights(self):
b=builder(('C7',),count=1)
state=construction.State(recent=(note(-3,-2,'c'),note(-2,-1,'e'),note(-1,0,'g')))
with patch.object(b.selector,'fast_cost',wraps=b.selector.fast_cost) as score:
first=b.candidates(F(),state);calls=score.call_count
self.assertGreater(calls,0)
self.assertEqual(first,b.candidates(F(),state));self.assertEqual(score.call_count,calls)
b.selector.weights={'duration/1':9}
changed=b.candidates(F(),state);self.assertGreater(score.call_count,calls)
b.successor_cache.clear()
self.assertEqual(changed,b.candidates(F(),state))
before=score.call_count
# Same pitches and final release; a longer incoming hold changes how
# leading space is classified, so it is a distinct retrieval context.
held=dataclasses.replace(state,recent=(note(-4,-3,'c'),note(-3,-2,'e'),note(-2,0,'g')))
b.candidates(F(),held);self.assertGreater(score.call_count,before)
def test_successor_cache_keeps_pending_destinations_distinct(self):
from generator.solo import types
b=builder(('C7',),count=1)
incoming=(note(-1,0,'d',alter=1),)
state=construction.State(recent=incoming)
target=next(a.notes[0] for a in b.operators.at(F()) if a.kind=='tone' and a.end==1 and a.notes[0].pitch.number()==64)
contract=types.ExpressionContract('pending','delayed_approach','bounded_semitone_resolution',incoming,target,'C7')
with patch.object(b.selector,'fast_cost',wraps=b.selector.fast_cost) as score:
b.candidates(F(),state);calls=score.call_count
pending=dataclasses.replace(state,pending=(contract,))
values=b.candidates(F(),pending)
self.assertTrue(values);self.assertGreater(score.call_count,calls)
self.assertTrue(all(b.pending_compatible(pending,a) for _,a in values))
b.successor_cache.clear()
self.assertEqual(values,b.candidates(F(),pending))
if __name__=='__main__':unittest.main()