234 lines
14 KiB
Python
234 lines
14 KiB
Python
"""Independently authored operator, fitting-boundary and cache regressions."""
|
|
import copy
|
|
import dataclasses
|
|
from fractions import Fraction as F
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
import contextlib
|
|
import io
|
|
from unittest.mock import patch
|
|
|
|
from generator import resources, vocabulary
|
|
from generator.solo import expressions, harmonic, operators, selection, types
|
|
from tests import solo_fixtures as fixture, rhythm_fixtures, scale_fixtures
|
|
from tests.test_phrase_composer import builder
|
|
|
|
class Operators(unittest.TestCase):
|
|
def test_supported_sixteenth_tail_can_finish_the_allocation(self):
|
|
b=builder(('CM7',),count=1)
|
|
actions=b.operators.at(F(15,4))
|
|
self.assertTrue(any(a.kind=='tone' and a.end==4 for a in actions))
|
|
# A source/role cutoff does not require a new long cadence.
|
|
b.start=F(15,4)
|
|
phrases=b.compose()
|
|
self.assertEqual(phrases[-1].end,F(4))
|
|
self.assertLessEqual(sum(len(p.notes) for p in phrases),1)
|
|
|
|
def test_source_relation_uses_order_and_does_not_reward_subdivision(self):
|
|
b=builder(('CM7',),count=1)
|
|
slow=operators.Action(F(),F(4),tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('c','d','e','f'))),'scalar')
|
|
fast=dataclasses.replace(slow,end=F(2),notes=tuple(dataclasses.replace(n,start=n.start/2,end=n.end/2) for n in slow.notes))
|
|
self.assertEqual(b.selector.evaluate(slow).raw['prior/source_relation'],b.selector.evaluate(fast).raw['prior/source_relation'])
|
|
changed=dataclasses.replace(b.analysis,melody=tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('e','f','g','a'))))
|
|
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',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',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'])
|
|
|
|
def test_anticipated_reattack_is_not_an_invented_tie(self):
|
|
b=builder(('C7','FM7'),count=2)
|
|
actions=b.operators.at(F(7,2))
|
|
a=next(a for a in actions if a.kind=='anticipation' and a.source=='early_reattack')
|
|
self.assertEqual(len(a.notes),2)
|
|
self.assertEqual(a.notes[0].end,a.notes[1].start)
|
|
self.assertEqual(a.notes[0].pitch,a.notes[1].pitch)
|
|
self.assertFalse(a.notes[1].continuation)
|
|
self.assertTrue(b.harmony.evaluate(a.notes,expressions=a.contracts).valid)
|
|
self.assertFalse(b.harmony.evaluate(a.notes[:1],expressions=a.contracts).valid)
|
|
|
|
def test_open_motion_and_all_color_octaves_are_constructed(self):
|
|
b=builder(('C7','FM7'),count=2)
|
|
actions=b.operators.at(F())
|
|
self.assertTrue(any(len(a.notes)==1 and a.notes[0].pitch.number()%12==2 and a.end==F(1,2) for a in actions))
|
|
h=builder(('E7','Am7'),count=2)
|
|
values=h.operators.at(F())
|
|
self.assertTrue(any(a.kind=='color' and len(a.notes)==1 and a.notes[0].pitch.number()==66 for a in values))
|
|
self.assertTrue(any(a.kind=='rest' for a in values))
|
|
|
|
def test_anticipation_color_has_actual_future_resolution(self):
|
|
h=harmonic.Harmony(fixture.choices(('Dm7','G7','CM7')))
|
|
held=fixture.note(F(7,2),F(11,2),'c',alter=1,event_id='held')
|
|
target=fixture.note(F(11,2),7,'d',event_id='target')
|
|
c=types.ExpressionContract('anticipated','anticipated_color','lydian_color',(held,),target,'G7',boundary=F(4))
|
|
self.assertTrue(h.evaluate((held,target),expressions=(c,)).valid)
|
|
self.assertFalse(h.evaluate((held,),expressions=(c,)).valid)
|
|
changed=dataclasses.replace(target,pitch=dataclasses.replace(target.pitch,octave=5))
|
|
self.assertFalse(h.evaluate((held,changed),expressions=(c,)).valid)
|
|
self.assertFalse(h.evaluate((held,target)).valid)
|
|
|
|
def test_delayed_approach_is_pending_and_expires(self):
|
|
h=harmonic.Harmony(fixture.choices(('Dm7',)))
|
|
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
|
|
target=fixture.note(3,F(7,2),'d',event_id='target')
|
|
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
|
|
r=h.evaluate((approach,),expressions=(c,),until=F(1,2),complete=False)
|
|
self.assertTrue(r.valid,r.error);self.assertEqual(r.pending,(c,))
|
|
self.assertFalse(h.evaluate((approach,),expressions=(c,),until=4,complete=True).valid)
|
|
self.assertTrue(h.evaluate((approach,target),expressions=(c,)).valid)
|
|
|
|
def test_distinct_pending_targets_are_visible_to_the_selector(self):
|
|
b=builder(('Dm7',),count=2)
|
|
actions=[a for a in b.operators.at(F()) if a.kind=='delayed_approach']
|
|
first=actions[0]
|
|
second=next(a for a in actions if a.notes==first.notes and a.contracts[0].target.start!=first.contracts[0].target.start)
|
|
left,_=b.selector.features(first);right,_=b.selector.features(second)
|
|
self.assertNotEqual(left,right)
|
|
selection.validate_weights(dict.fromkeys(left,1))
|
|
|
|
def test_missed_pending_target_is_rejected_before_expensive_validation(self):
|
|
from generator.solo.construction import State
|
|
b=builder(('Dm7',),count=2)
|
|
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
|
|
target=fixture.note(3,4,'d',event_id='target')
|
|
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
|
|
state=State(pending=(c,))
|
|
wrong=operators.Action(F(3),F(4),(fixture.note(3,4,'e',event_id='wrong'),),'tone')
|
|
with patch.object(b.harmony,'evaluate',side_effect=AssertionError('unnecessary full check')):
|
|
self.assertIsNone(b.advance(state,None,wrong))
|
|
good=dataclasses.replace(wrong,notes=(target,))
|
|
self.assertIsNotNone(b.advance(state,None,good))
|
|
|
|
def test_functional_degree_does_not_depend_on_enharmonic_label(self):
|
|
h=harmonic.Harmony(fixture.choices(('E7','Am7')))
|
|
n=fixture.note(0,2,'g',event_id='sharp9')
|
|
c=types.ExpressionContract('color','color','dominant_sharp_ninth',(n,),n,'E7')
|
|
self.assertTrue(h.evaluate((n,),expressions=(c,)).valid)
|
|
wrong=dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,alter=F(1)))
|
|
self.assertFalse(h.evaluate((wrong,),expressions=(dataclasses.replace(c,events=(wrong,),target=wrong),)).valid)
|
|
|
|
def test_weight_contributions_and_target_free_features(self):
|
|
b=builder(count=1)
|
|
a=next(a for a in b.operators.at(F()) if a.kind=='tone')
|
|
v=b.selector.evaluate(a)
|
|
self.assertEqual(v.total,sum(v.contributions.values()))
|
|
self.assertEqual(v.total,b.selector.cost(a))
|
|
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])
|
|
with self.assertRaises(ValueError):selection.validate_weights({'song/all_of_me':1})
|
|
|
|
def test_derivation_cannot_inject_an_illegal_reference_note(self):
|
|
from audition.fit_selection import derive
|
|
b=builder(('CM7',),count=1)
|
|
target={'alignment':{'encoded_offset':'0'},'sounding_events':[{'start':'0','end':'4','sounding_midi':'99','pitch':{},'measure':1}]}
|
|
path,result=derive(b,target)
|
|
self.assertFalse(result['complete']);self.assertFalse(path)
|
|
|
|
class CompiledResources(unittest.TestCase):
|
|
def test_compiled_and_xml_libraries_have_identical_contents(self):
|
|
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
|
import json
|
|
encoded=json.loads(json.dumps(resources.encode(original),default=str))
|
|
restored=resources.decode(encoded)
|
|
self.assertEqual(original[0],restored[0])
|
|
self.assertEqual(original[1].paths,restored[1].paths)
|
|
self.assertEqual(original[2].snippets,restored[2].snippets)
|
|
|
|
def test_warm_cache_avoids_xml_import_and_corruption_recompiles(self):
|
|
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
|
|
with tempfile.TemporaryDirectory() as root:
|
|
with patch('generator.resources.scales.load_library',return_value=original[0]) as sc,patch('generator.resources.sequences.load_library',return_value=original[1]),patch('generator.resources.rhythm.load_library',return_value=original[2]):
|
|
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
|
self.assertEqual(sc.call_count,1)
|
|
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
|
self.assertEqual(sc.call_count,1)
|
|
next((Path(root)/'_resources').glob('*.json')).write_text('{}')
|
|
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
|
|
self.assertEqual(sc.call_count,2)
|