"""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()