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
+39 -109
View File
@@ -7,22 +7,20 @@ normal composer; accepting injected pitches is not a construction proof.
from __future__ import annotations
import argparse
import bisect
from collections import Counter
import dataclasses
from fractions import Fraction as F
import hashlib
import heapq
import json
import math
from pathlib import Path
import time
from audition.reference_review import read
from audition.solo_compare import events, rests
from generator import accompaniment, model, musicxml, parts, production, resources, score, vocabulary
from audition.solo_compare import events
from generator import accompaniment, musicxml, parts, production, resources, score, vocabulary
from generator.arrange import form, generate
from generator.scales import selection, timeline
from generator.solo import analysis, construction, harmonic, profiles, report, types
from generator.solo import analysis, construction, harmonic, profiles
def build(source_path,role,data_dir,cache_dir):
@@ -54,122 +52,50 @@ def canonical(notes):
def derive(builder,target):
"""Search grammar derivations constrained to the exact supplied events.
"""Stateful target-constrained coverage probe; NEVER production success.
This is a development diagnostic, not normal production inference. Failure
is recorded at its earliest uncovered event. No target action is injected.
Separate histories carry actual pending contracts. This bounded diagnostic
can report exhaustion, not prove a phrase impossible. No notes are injected.
"""
encoded=tuple((F(n['start']),F(n['end']),int(n['pitch'])) for n in events(target))
starts=tuple(n[0] for n in encoded);encoded_set=set(encoded)
queue=[builder.start];paths={builder.start:()};seen=set();furthest=builder.start
queue=[builder.start];chart={builder.start:[construction.State()]};queued={builder.start}
furthest=builder.start;last=construction.State();cuts=0
while queue:
at=heapq.heappop(queue)
if at in seen:continue
seen.add(at);furthest=max(furthest,at)
at=heapq.heappop(queue);states=chart.pop(at);furthest=max(furthest,at)
unique={builder.identity(s):s for s in states};states=list(unique.values())
cuts+=max(0,len(states)-16);states=states[:16]
if at==builder.end:
path=paths[at]
checked=builder.harmony.evaluate(tuple(n for a in path for n in a.notes),expressions=tuple(c for a in path for c in a.contracts),complete=True,until=at)
return path,{'complete':checked.valid,'covered_until':str(at),'actions':len(path),
'status':'constructible' if checked.valid else 'rejected_by_full_context','error':checked.error}
for state in states:
checked=builder.harmony.evaluate(state.notes,expressions=tuple(c for a in state.actions for c in a.contracts),complete=True,until=at)
if checked.valid and canonical(state.notes)==encoded:
return state.actions,{'complete':True,'covered_until':str(at),'actions':len(state.actions),
'status':'statefully_constructible','diagnostic_only':True,'pruned_histories':cuts}
left=bisect.bisect_left(starts,at)
def matches(action):
if any((c.target.start,c.target.end,c.target.pitch.number()) not in encoded_set for c in action.contracts):return False
expected=encoded[left:bisect.bisect_left(starts,action.end)]
return not (left and encoded[left-1][1]>at) and canonical(action.notes)==expected
first=encoded[left] if left<len(encoded) else None
for action in builder.operators.at(at,eligible=matches,first=first):
if action.end not in paths:
paths[action.end]=(*paths[at],action);heapq.heappush(queue,action.end)
actions=builder.operators.at(at,eligible=matches,first=first)
for state in states:
last=state
for action in actions:
next_state=builder.advance(state,None,action)
if next_state is None:continue
chart.setdefault(action.end,[]).append(next_state)
if action.end not in queued:heapq.heappush(queue,action.end);queued.add(action.end)
next_event=next((n for n in encoded if n[0]>=furthest),None)
reasons=[]
if next_event:
start,end,pitch=next_event;region=builder.harmony.at(start)
reasons=[{'pitch':pitch,'start':str(start),'end':str(end),
'source_chord':region.chord if region else None,'mapped':bool(region and region.scale and pitch%12 in region.scale.pitches),
'basic':bool(region and pitch%12 in region.roles.basic),
'function':builder.harmony.function(pitch,region) if region else 'missing_harmony'}]
return paths.get(furthest,()),{'complete':False,'covered_until':str(furthest),
'status':'not_proposed_or_rejected_by_rule','next_events':reasons,
'budget':'Exhaustive derivation through available unpruned grammar actions, not unrestricted music search.',
'action_coverage':dict(builder.operators.coverage)}
return last.actions,{'complete':False,'covered_until':str(furthest),
'status':'coverage_search_exhausted' if cuts else 'not_proposed_or_rejected_by_rule',
'next_event':tuple(map(str,next_event)) if next_event else None,
'pruned_histories':cuts,'diagnostic_only':True,'action_coverage':dict(builder.operators.coverage)}
def fit(examples,passes,on_checkpoint=None):
"""Integer early-update structured perceptron on shared musical features.
Each roll-in is normal source-only ranking until its first wrong action.
Only the development command finds a compatible positive action and updates
coefficients. Production never receives the positive prefix. A zero-error
pass means every unrestricted roll-in produced the entire expected stream.
"""
weights={r:dict(b.selector.weights) for _,r,b,_ in examples};history=[];best=None;tables={}
# Whole actions include pickup+hold and compound groups (e.g. 17/4).
# Eight beats on the supported twelfth-beat lattice need numerators up to
# 96. The previous LCM 1..16 covered note values, not complete actions.
normalization=math.lcm(*range(1,97))
def table(name,b,at,state):
key=(name,at,canonical(state.recent),canonical(state.home),state.last_kind,state.pending)
if key in tables:return tables[key]
rows=[]
actions=b.operators.at(at)
if state.home and state.last_kind!='return':actions+=b.operators.related(at,state.home,'return')+b.operators.related(at,state.home,'answer')
for action in actions:
if b.advance(state,None,action) is None:continue
features,prior=b.selector.features(action,state.recent,state.home,state.last_kind)
factor=F(normalization)/(action.end-action.start)
if factor.denominator!=1:raise ValueError('Unsupported training normalization span')
factor=int(factor)
rows.append((action,tuple((k,v*factor) for k,v in features.items() if v),
sum(v*b.selector_default[k] for k,v in prior.items())*factor))
if len(tables)>=24:tables.pop(next(iter(tables)))
tables[key]=tuple(rows);return tables[key]
from generator.solo.selection import DEFAULT
for _,_,b,_ in examples:b.selector_default=DEFAULT
for iteration in range(passes):
updates={r:Counter() for r in weights};records=[];errors=0;covered=0
for name,role,b,gold in examples:
b.selector.weights=weights[role];state=construction.State();at=b.start
encoded=canonical(tuple(n for a in gold for n in a.notes));encoded_set=set(encoded);starts=tuple(n[0] for n in encoded)
while at<b.end:
rows=table(name,b,at,state)
rank=lambda row:(sum(weights[role].get(k,0)*v for k,v in row[1])+row[2],row[0].key)
if not rows:raise ValueError('No feasible action during development roll-in')
predicted=min(rows,key=rank)
left=bisect.bisect_left(starts,at)
def matches(row):
action=row[0]
return canonical(action.notes)==encoded[left:bisect.bisect_left(starts,action.end)] and all((c.target.start,c.target.end,c.target.pitch.number()) in encoded_set for c in action.contracts)
if not matches(predicted):
positives=sorted((row for row in rows if matches(row)),key=rank)
# An alternate valid derivation can be learnable even when the
# cheapest positive shares every feature with the wrong action.
positive=next((row for row in positives if dict(row[1])!=dict(predicted[1])),positives[0] if positives else None)
if positive is None:
records.append({'case':name,'first_error':str(at),'status':'coverage_lost_after_alternate_derivation'})
errors+=1;break
updates[role].update(dict(predicted[1]));updates[role].subtract(dict(positive[1]))
records.append({'case':name,'first_error':str(at),'predicted':predicted[0].kind,'positive':positive[0].kind,
'identical_features':dict(predicted[1])==dict(positive[1]),'status':'selection_error'})
errors+=1;break
action=predicted[0]
state=b.advance(state,b.selector.evaluate(action,state.recent,state.home,state.last_kind),action)
at=action.end
covered+=bisect.bisect_left(starts,at)
if at==b.end:records.append({'case':name,'status':'exact_rollout','events':len(encoded)})
complexity=sum(abs(v) for w in weights.values() for v in w.values())
history.append({'pass':iteration+1,'incorrect_rollouts':errors,'matching_prefix_attacks':covered,'cases':records})
checkpoint=(errors,-covered,complexity)
if best is None or checkpoint<best[0]:best=(checkpoint,json.loads(json.dumps(weights)))
if on_checkpoint:on_checkpoint({'weights':best[1],'history':history,'complete':errors==0})
print(f'fit pass {iteration+1}: {errors} incorrect rollouts; {covered} matching prefix attacks',flush=True)
if not errors:break
for role,difference in updates.items():
for feature,delta in difference.items():weights[role][feature]=weights[role].get(feature,0)+delta
weights[role]={k:v for k,v in weights[role].items() if v}
return {'weights':best[1],'history':history,'complete':history[-1]['incorrect_rollouts']==0,
'method':'integer early-update structured perceptron, fixed case order and 200-pass maximum',
'checkpoint_metric':'incorrect rollouts, then longest correct prefixes, then coefficient magnitude; full-output exact gate remains separate',
'limitation':'Training examples are not unseen evaluation. Runtime independence does not imply development independence.'}
def fit(examples,passes,on_checkpoint=None,resources=None):
# Imported only by this explicitly requested development operation.
from audition.structured_learning import fit_complete
return fit_complete(examples,passes,on_checkpoint,resources)
def main():
@@ -182,8 +108,12 @@ def main():
p.add_argument('--data-dir',type=Path,default=Path('data'))
p.add_argument('--cache-dir',type=Path)
p.add_argument('--output',type=Path,required=True)
p.add_argument('--passes',type=int,default=200)
p.add_argument('--passes',type=int,default=8)
p.add_argument('--solver-path',type=Path,help='Development-only pinned solver installation directory')
args=p.parse_args()
if args.solver_path:
import sys
sys.path.insert(0,str(args.solver_path.resolve()))
if not 1<=args.passes<=200:p.error('passes must be 1–200')
pairs=lambda values:dict(v.split('=',1) for v in values)
sources,refs,roles=map(pairs,(args.source,args.reference,args.role));starts=pairs(args.form_start)
@@ -199,7 +129,7 @@ def main():
'derivation':[{'kind':a.kind,'start':str(a.start),'end':str(a.end),'events':[(str(n.start),str(n.end),n.pitch.number()) for n in a.notes],'rules':[c.rule for c in a.contracts]} for a in path]}
if args.operation=='fit' and result['complete']:examples.append((name,roles[name],b,path))
print(name,result['status'],result['covered_until'],flush=True)
result={'schema':'shared-rule-coverage-1','development_only':True,'coverage_seconds':time.monotonic()-began,'cases':results}
result={'schema':'shared-phrase-learning-2','development_only':True,'coverage_seconds':time.monotonic()-began,'cases':results}
if args.operation=='fit':
if not all(r['complete'] for r in results.values()):result['fit_status']='blocked_by_construction_coverage'
else:
@@ -207,7 +137,7 @@ def main():
path=args.output/'checkpoint.json';temporary=path.with_suffix('.tmp')
temporary.write_text(json.dumps(value,indent=2)+'\n');temporary.replace(path)
fitting_start=time.monotonic()
result['fit']=fit(examples,args.passes,checkpoint)
result['fit']=fit(examples,args.passes,checkpoint,data)
result['fit_seconds']=time.monotonic()-fitting_start
result['total_seconds']=time.monotonic()-began
(args.output/'coverage.json').write_text(json.dumps(result,indent=2)+'\n')
+74
View File
@@ -0,0 +1,74 @@
"""Export source-only complete candidates; references are not accepted here."""
import argparse
import copy
import dataclasses
from fractions import Fraction as F
import json
from pathlib import Path
import time
import cProfile
from audition.fit_selection import build
from generator import model, musicxml_writer, score, vocabulary
from generator.solo import notation, placement
def export(builder,document,output):
output.mkdir(parents=True,exist_ok=True)
source=score.source_staff(document)['measures']
start_index=score.body_start_measure_index(source)
if builder.start<0:start_index-=1
length=F();selected=[]
for m in source[start_index:]:
if length>=builder.end-builder.start:break
selected.append(m);length+=model.fraction(m['duration'])
if length!=builder.end-builder.start:raise ValueError('Diagnostic export requires complete source measures')
records=[]
for index,state in enumerate(builder.completed,1):
played=placement.place(state.notes,builder.analysis.tempo,profile=builder.harmony.profile)
if played is None:raise ValueError('Completed candidate is unplayable')
shifted=tuple(dataclasses.replace(n,start=n.start-builder.start,end=n.end-builder.start) for n in played[1])
part=copy.deepcopy(document);staff=part['staves'][0]
part['staves']=[staff];staff['measures']=notation.measures(selected,shifted,'candidate')
staff['tuplets']=notation.tuplets(staff['measures']);staff['tablature']=True
for key in ('scale_spans','octave_shifts','slurs','wedges'):staff.pop(key,None)
path=output/f'candidate-{index}.xml'
musicxml_writer.write_musicxml(path,part,tablature=True)
records.append({'file':path.name,'automatic_winner':index==1,'evaluation':dataclasses.asdict(state.evaluation),
'home':[(str(n.start),str(n.end),n.pitch.number()) for n in state.home],
'events':[(str(n.start),str(n.end),n.pitch.number()) for n in state.notes],
'actions':[{'kind':a.kind,'start':str(a.start),'end':str(a.end),'contracts':[c.rule for c in a.contracts]} for a in state.actions]})
value={'source_only':True,'automatic_winner':'candidate-1.xml','start':str(builder.start),'end':str(builder.end),
'candidates':records,'frontiers':builder.frontiers,'timings':dict(builder.timings),
'note':'Diagnostic candidates, not publication or evidence of exact reproduction.'}
(output/'candidates.json').write_text(json.dumps(value,indent=2,default=str)+'\n')
return value
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--source',type=Path,required=True);p.add_argument('--role',choices=('acoustic_solo','electric_solo'),required=True)
p.add_argument('--data-dir',type=Path,default=Path('data'));p.add_argument('--output',type=Path,required=True)
p.add_argument('--cache-dir',type=Path);p.add_argument('--bars',type=int,default=4)
p.add_argument('--profile',action='store_true')
args=p.parse_args()
if args.bars<1:p.error('--bars must be positive')
with vocabulary.using(vocabulary.load(args.data_dir/'composition'),args.role):
b,document=build(args.source,args.role,args.data_dir,args.cache_dir)
ends=[x-b.analysis.body_offset for x in b.analysis.boundaries if x-b.analysis.body_offset>0]
b.end=min(b.end,ends[min(args.bars-1,len(ends)-1)]);b.operators.end=b.end
started=time.monotonic();seen=set()
def progress(stage,at,state,proposed,retained):
if stage=='frontier' and at.denominator==1 and at not in seen:
seen.add(at);print(f'frontier {at}: {len(proposed)} histories, {len(retained)} retained; {time.monotonic()-started:.1f}s',flush=True)
b.observe=progress
args.output.mkdir(parents=True,exist_ok=True)
profiler=cProfile.Profile() if args.profile else None
if profiler:profiler.enable()
try:b.compose()
finally:
if profiler:profiler.disable();profiler.dump_stats(args.output/'search.prof')
export(b,document,args.output)
print(json.dumps({'completed':len(b.completed),'seconds':b.timings['action_search_seconds'],'winner':str(b.winner_evaluation.total)}))
if __name__=='__main__':main()
+2
View File
@@ -0,0 +1,2 @@
# Development only. Runtime composition must not import this solver.
z3-solver==4.13.3.0
+197
View File
@@ -0,0 +1,197 @@
"""Development-only shared integer ranking from COMPLETE ordinary rollouts.
The returned coefficients depend on the training examples. Target event arrays,
source identifiers and diagnostic oracle paths are never production artifacts.
"""
from collections import Counter
from fractions import Fraction as F
import itertools
import math
from generator import vocabulary
from generator.solo import selection
def contextual_candidates(selector,notes):
"""Induce only whitelisted two/three-condition event contexts.
These counts are training-dependent. No title, time index, song key, event
array, fingerprint or arbitrary predicate can appear in the rule schema.
"""
values=Counter()
for context,ticks in selector.contexts(notes):
terms=sorted((k,v) for k,v in context.items() if v in selection.CONDITIONS[k])
for size in (2,3):
for terms_ in itertools.combinations(terms,size):
values['joint/'+'&'.join(k+'='+v for k,v in terms_)]+=ticks
return values
def vector(builder,actions,home=()):
e=builder.selector.complete(actions,home,start=builder.start,end=builder.end)
# Equal spans: clear normalization without rounding any musical time.
span=builder.end-builder.start
features={k:F(v)*span for k,v in e.raw.items() if not k.startswith('prior/') and v}
prior=sum(F(v)*span for k,v in e.contributions.items() if k.startswith('prior/'))
return features,prior
def constraint(role,gold,other,case):
left,lp=gold;right,rp=other
delta={k:left.get(k,0)-right.get(k,0) for k in left.keys()|right.keys()}
delta={k:v for k,v in delta.items() if v}
selection.validate_weights(dict.fromkeys(delta,0))
return {'role':role,'delta':delta,'prior':lp-rp,'case':case}
def solve(rows,bound=1024):
"""One shared coefficient table per role; deterministic pinned integer solver."""
import z3
if z3.get_version_string()!='4.13.3':raise ValueError('Use pinned z3-solver==4.13.3.0')
z3.set_param('smt.random_seed',0)
# Exact zero/one-coefficient cases need no combinatorial cardinality search.
# Checking every feature's integer interval proves the minimum support and
# L1 objective when all independent roles have such a solution. This does
# not claim the resulting sparse model reproduces any complete melody.
simple={}
for role in sorted({r['role'] for r in rows}):
subset=[r for r in rows if r['role']==role]
if all(r['prior']<=-1 for r in subset):simple[role]={};continue
choices=[]
for key in sorted({k for r in subset for k in r['delta']}):
low,high=-bound,bound
for r in subset:
slope=F(r['delta'].get(key,0));limit=-1-F(r['prior'])
if slope>0:high=min(high,math.floor(limit/slope))
elif slope<0:low=max(low,math.ceil(limit/slope))
elif limit<0:low,high=1,0;break
if low<=high:
value=low if low>0 else high if high<0 else 0
choices.append((abs(value),key,value))
if not choices:break
_,key,value=min(choices);simple[role]={key:value}
if set(simple)=={r['role'] for r in rows}:
for weights in simple.values():selection.validate_weights(weights)
return {'status':'solved','weights':simple,'bound':bound,
'nonzero':sum(len(w) for w in simple.values()),'l1':sum(abs(v) for w in simple.values() for v in w.values()),
'method':'exhaustive zero/one-coefficient integer intervals; minimum support and L1 within that support proven'}
names=sorted({(r['role'],k) for r in rows for k in r['delta']})
variables={n:z3.Int('w_'+str(i)) for i,n in enumerate(names)}
limits=[v>=-bound for v in variables.values()]+[v<=bound for v in variables.values()]
rational=lambda v:z3.RealVal(str(F(v)))
constraints=[z3.Sum([variables[(r['role'],k)]*rational(v) for k,v in r['delta'].items()])+rational(r['prior'])<=-1 for r in rows]
check=z3.Solver();check.set(rlimit=2000000);check.add(*limits)
for i,c in enumerate(constraints):check.assert_and_track(c,'comparison_'+str(i))
status=check.check()
if status==z3.unknown:return {'status':'solver_unknown','stage':'feasibility','reason':check.reason_unknown()}
if status==z3.unsat:
return {'status':'incompatible_with_current_features_and_bounds','bound':bound,
'conflicts':[str(x) for x in check.unsat_core()],
'identical_feature_pairs':[i for i,r in enumerate(rows) if not r['delta'] and r['prior']>=0]}
opt=z3.Optimize();opt.add(*limits,*constraints)
# Deterministic solver work budget. Exhaustion yields no proposed model;
# elapsed wall time never chooses a musical winner.
opt.set(rlimit=2000000)
opt.minimize(z3.Sum([z3.If(v==0,0,1) for v in variables.values()]))
opt.minimize(z3.Sum([z3.If(v<0,-v,v) for v in variables.values()]))
if opt.check()!=z3.sat:return {'status':'solver_unknown','reason':opt.reason_unknown()}
model=opt.model();weights={r:{} for r,_ in names}
for (role,key),v in variables.items():
value=model.eval(v,model_completion=True).as_long()
if value:weights[role][key]=value
for w in weights.values():selection.validate_weights(w)
return {'status':'solved','weights':weights,'bound':bound,
'nonzero':sum(len(w) for w in weights.values()),'l1':sum(abs(v) for w in weights.values() for v in w.values())}
def sounded(actions):
from generator.solo.types import sounding_notes
return tuple((n.start,n.end,n.pitch.number()) for n in sounding_notes(tuple(n for a in actions for n in a.notes)))
class CoverageTrace:
"""Read-only observer. Targets diagnose cuts, never supply search decisions."""
def __init__(self,gold):
self.gold=sounded(gold);self.records=[]
def matches(self,notes,end):
# These are committed whole actions, not a truncated playback preview.
# A quarter-note attack cannot reproduce a half-note target merely because
# its first quarter sounds the same. Future actions do not reopen it.
from generator.solo.types import sounding_notes
actual=tuple((n.start,n.end,n.pitch.number()) for n in sounding_notes(notes))
expected=tuple((a,b,p) for a,b,p in self.gold if a<end)
return actual==expected
def __call__(self,stage,at,state,proposed,retained):
if stage=='successors':
if not self.matches(state.notes,at):return
eligible=[a for a in proposed if self.matches((*state.notes,*a.notes),a.end)]
survivors=[a for a in retained if self.matches((*state.notes,*a.notes),a.end)]
else:
eligible=[s for s in proposed if self.matches(s.notes,s.action.end if s.action else at)]
survivors=[s for s in retained if self.matches(s.notes,s.action.end if s.action else at)]
if eligible and not survivors:
self.records.append({'time':str(at),'status':'pruned_at_'+stage,'matching_proposals':len(eligible)})
elif stage=='successors' and not eligible:
self.records.append({'time':str(at),'status':'not_proposed_in_actual_incoming_state'})
def fit_complete(examples,passes,on_checkpoint=None,resources=None):
weights={role:dict(b.selector.weights) for _,role,b,_ in examples}
rows=[];history=[];last_solver={};exact=False;comparisons=[];induced=[]
for iteration in range(passes):
cases=[];errors=0
for name,role,b,gold in examples:
b.selector.weights=weights[role]
trace=CoverageTrace(gold);b.observe=trace
# Honor the explicitly selected development data directory during lazy
# operator expansion too, not just when constructing the builder.
with vocabulary.using(resources or vocabulary.current()['data'],role):
b.compose() # No target argument, prefix, candidate injection or oracle.
b.observe=None
state=__import__('generator.solo.construction',fromlist=['State']).State()
for a in gold:state=b.advance(state,None,a)
positive=vector(b,gold,state.home)
correct=sounded(b.selected_actions)==sounded(gold)
cases.append({'case':name,'exact_complete_output':correct,
'completed_candidates':len(b.completed),'winner':str(b.winner_evaluation.total),
'earliest_observed_failures':trace.records[:8],
'target_in_completed_pool':any(sounded(s.actions)==sounded(gold) for s in b.completed)})
if not correct:errors+=1
for s in b.completed:
if sounded(s.actions)!=sounded(gold):
comparisons.append((name,role,b,gold,state.home,s.actions,s.home))
rows.append(constraint(role,positive,vector(b,s.actions,s.home),name))
print(name,'exact' if correct else 'complete-output mismatch',flush=True)
history.append({'iteration':iteration+1,'incorrect_complete_outputs':errors,'cases':cases})
if not errors:exact=True
result={'weights':weights,'history':history,'complete':exact,'solver':last_solver,
'induced_rules':induced,
'training_dependency':'Coefficients learned from supplied authored examples; runtime target-free is not training independence.',
'release_gate':'Ordinary cold full-song outputs must independently pass exact comparison; derivation is not success.'}
if on_checkpoint:on_checkpoint(result)
if exact:break
last_solver=solve(rows)
if last_solver['status']!='solved':
# Add a context only for a demonstrated feature collision. General
# ranking failure is not permission to expand vocabulary indiscriminately.
collisions=last_solver.get('identical_feature_pairs',[])
if collisions and len(induced)<8 and iteration+1<passes:
i=collisions[0];name,role,b,gold,gh,other,oh=comparisons[i]
g=contextual_candidates(b.selector,tuple(n for a in gold for n in a.notes))
o=contextual_candidates(b.selector,tuple(n for a in other for n in a.notes))
keys=sorted((k for k in g.keys()|o.keys() if g.get(k,0)!=o.get(k,0) and k not in weights[role]),key=lambda k:(k.count('&'),k))
if keys:
key=keys[0]
for r in weights:weights[r][key]=0
induced.append({'rule':key,'reason':'complete-candidate feature collision','training_case':name,
'schema':'two or three whitelisted chord/relative-motion/rhythm/source conditions; no song identity'})
rows=[]
for name,r,b,gold,gh,other,oh in comparisons:
b.selector.weights=weights[r]
rows.append(constraint(r,vector(b,gold,gh),vector(b,other,oh),name))
continue
result['solver']=last_solver;break
if iteration+1<passes:weights={r:last_solver['weights'].get(r,{}) for r in weights}
# Do not call an untested final solver update a successful checkpoint.
result['solver']=last_solver
result['constraints']=[{**r,'delta':{k:str(v) for k,v in r['delta'].items()},'prior':str(r['prior'])} for r in rows]
return result
+144
View File
@@ -0,0 +1,144 @@
"""Fit shared ranking from completed ordinary generation, never prefix roll-ins.
This development command reads explicit targets and saved complete alternatives.
It writes a *proposal*, not a released model. A staged data copy may be passed to
the ordinary generator for a separate target-free cold reproduction test.
"""
import argparse
import copy
from fractions import Fraction as F
import hashlib
import json
from pathlib import Path
import shutil
import sys
from audition.fit_selection import build,derive
from audition.reference_review import read
from audition.structured_learning import vector,constraint,solve,sounded,CoverageTrace
from generator import production,score,vocabulary
from generator.solo import construction,selection,policy
def evaluation_vector(value):
if value['version']!=selection.VERSION:raise ValueError('Completed output uses another evaluator version')
span=F(value['denominators']['span'])
features={k:F(v)*span for k,v in value['raw'].items() if not k.startswith('prior/') and F(v)}
selection.validate_weights(dict.fromkeys(features,0))
prior=sum(F(v)*span*selection.DEFAULT[k.removeprefix('prior/')] for k,v in value['raw'].items() if k.startswith('prior/'))
return features,prior
def training_context(data):
"""Changing learned weights is allowed between rounds; musical rules are not."""
musical=copy.deepcopy(data)
musical['roles'].pop('provenance',None)
for role in musical['roles']['roles'].values():role.pop('selection_weights',None)
return {'evaluator':selection.VERSION,'algorithm':policy.DEFAULT.composition_version,
'fixed_priors':selection.DEFAULT,'musical_data':vocabulary.fingerprint(musical)}
def previous_constraints(evidence,cases,context):
if evidence.get('schema')!='complete-candidate-training-1' or evidence.get('context')!=context:
raise ValueError('Previous round has incompatible evaluation or musical data')
earlier=evidence['training_dependency']
if earlier.keys()!=cases.keys():raise ValueError('Previous round has different training cases')
for name in cases:
for key in ('source_sha256','target_sha256','role'):
if cases[name][key]!=earlier[name][key]:raise ValueError('Previous round changed '+name+'/'+key)
rows=[]
for row in evidence['constraints']:
if row['case'] not in cases or row['role']!=cases[row['case']]['role']:
raise ValueError('Previous constraint has a mismatched case/role')
selection.validate_weights(dict.fromkeys(row['delta'],0))
rows.append({**row,'delta':{k:F(v) for k,v in row['delta'].items()},'prior':F(row['prior'])})
return rows
def main():
p=argparse.ArgumentParser(description=__doc__)
for name in ('source','reference','role'):p.add_argument('--'+name,action='append',required=True,metavar='CASE=VALUE')
p.add_argument('--form-start',action='append',default=[])
p.add_argument('--data-dir',type=Path,default=Path('data'))
p.add_argument('--completed-dir',type=Path,required=True,help='Completed ordinary assets directory containing analysis/*.roles')
p.add_argument('--output',type=Path,required=True);p.add_argument('--cache-dir',type=Path)
p.add_argument('--solver-path',type=Path,required=True)
p.add_argument('--stage-data',action='store_true',help='Write an isolated data copy for ordinary inference; does not publish it')
p.add_argument('--previous-round',type=Path,action='append',default=[],help='Accumulate complete-output constraints from a compatible training.json; never a production input')
args=p.parse_args();sys.path.insert(0,str(args.solver_path.resolve()))
pairs=lambda values:dict(v.split('=',1) for v in values)
sources,refs,roles=map(pairs,(args.source,args.reference,args.role));starts=pairs(args.form_start)
if sources.keys()!=refs.keys() or sources.keys()!=roles.keys():p.error('Cases must agree')
args.output.mkdir(parents=True,exist_ok=True);rows=[];cases={};contexts=[];data=vocabulary.load(args.data_dir/'composition')
for name in sorted(sources):
with vocabulary.using(data,roles[name]):
b,document=build(sources[name],roles[name],args.data_dir,args.cache_dir)
target=read(refs[name],name,int(starts[name]) if name in starts else None,'guitar-written')
gold,coverage=derive(b,target)
if not coverage['complete']:raise ValueError(name+': '+coverage['status'])
s=construction.State()
for a in gold:s=b.advance(s,None,a)
positive=vector(b,gold,s.home)
contexts.append((name,roles[name],b,gold))
path=args.completed_dir/'analysis'/(score.slugify(Path(sources[name]).stem)+'.roles')
generated=json.loads(path.read_text())[roles[name]]
if generated['trace']['source']!=production.SourceAnalysis.read(document).musical_key:
raise ValueError(name+': completed candidates belong to different canonical source music')
if generated['trace']['role']!=roles[name]:raise ValueError(name+': wrong completed role')
if generated['trace']['vocabulary']!=vocabulary.fingerprint(production.dependencies(roles[name],data)):
raise ValueError(name+': completed candidates use different production data/settings')
candidates=generated['details']['selection_trace']['completed_candidates']
negatives=0;exact=False
for c in candidates:
stream=tuple((F(a),F(z),int(n)) for a,z,n in c['events'])
same=stream==sounded(gold)
if c['rank']==1:exact=same
if not same:rows.append(constraint(roles[name],positive,evaluation_vector(c['evaluation']),name));negatives+=1
cases[name]={'source':sources[name],'reference':refs[name],'role':roles[name],
'source_sha256':hashlib.sha256(Path(sources[name]).read_bytes()).hexdigest(),
'target_sha256':target['sha256'],'completed_artifact_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),
'coverage':coverage,'ordinary_winner_exact':exact,'complete_negative_candidates':negatives}
print(name,exact,negatives,flush=True)
context=training_context(data);parents=[]
for path in args.previous_round:
raw=path.read_bytes();rows.extend(previous_constraints(json.loads(raw),cases,context))
parents.append({'path':str(path),'sha256':hashlib.sha256(raw).hexdigest()})
unique={}
for row in rows:
key=(row['role'],row['case'],tuple(sorted(row['delta'].items())),row['prior'])
unique[key]=row
rows=[unique[key] for key in sorted(unique)]
result=solve(rows)
evidence={'schema':'complete-candidate-training-1','training_dependency':cases,'solver':result,
'context':context,'previous_rounds':parents,
'constraints':[{**r,'delta':{k:str(v) for k,v in r['delta'].items()},'prior':str(r['prior'])} for r in rows],
'released':False,'acceptance':'Exact four full outputs from ordinary cold target-free generation, plus transfer; not satisfied by this training solve.'}
if result['status']=='solved':
# A zero here proves this proposed model cannot reproduce the full output.
# A nonzero result proves neither full retention nor exact generation.
# This is read-only diagnosis, not prefix fitting or candidate injection.
cuts={}
for name,role,b,gold in contexts:
with vocabulary.using(data,role):
b.selector.weights=result['weights'].get(role,{})
trace=CoverageTrace(gold);b.observe=trace
retained=b.candidates(b.start,construction.State());b.observe=None
count=sum(trace.matches(a.notes,a.end) for _,a in retained)
cuts[name]={'matching_first_successors':count,'observed_failures':trace.records,
'full_reproduction_already_excluded':count==0}
evidence['post_fit_cutoff_diagnosis']=cuts
(args.output/'training.json').write_text(json.dumps(evidence,indent=2)+'\n')
if result['status']=='solved':
(args.output/'proposed-weights.json').write_text(json.dumps(result['weights'],indent=2)+'\n')
if args.stage_data:
staged=args.output/'data'
if staged.exists():raise ValueError('Refusing to overwrite a previous staged data proposal')
shutil.copytree(args.data_dir,staged)
path=staged/'composition'/'roles.json';value=json.loads(path.read_text())
for role,weights in result['weights'].items():value['roles'][role]['selection_weights']=weights
value['provenance']='Shared bounded coefficients learned from explicit authored training examples; see adjacent development training.json. No runtime target reads or stored phrases.'
path.write_text(json.dumps(value,indent=2)+'\n')
print(json.dumps(result,indent=2))
return 0 if result['status']=='solved' else 2
if __name__=='__main__':raise SystemExit(main())
+1 -5
View File
@@ -7,8 +7,6 @@
"seed": "acoustic",
"register_center": 57,
"selection_weights": {
"kind/arpeggio": -2,
"kind/answer": -2,
"duration/1": -1,
"space/after_arrival": -2
}
@@ -17,9 +15,7 @@
"seed": "electric",
"register_center": 62,
"selection_weights": {
"kind/scalar": -2,
"metric/eighth/1_2": -1,
"kind/enclosure": -1
"metric/eighth/1_2": -1
}
},
"acoustic_accompaniment": {
+2 -1
View File
@@ -304,6 +304,7 @@
"1/2",
"1"
],
"backtracks": 16
"histories": 16,
"completed": 8
}
}
+56 -17
View File
@@ -1,25 +1,64 @@
# Active path audit
# Active selector audit
Starting revision: `0e68f53` (`Cleanup`). The earlier implementation report is preserved in [baseline_0e68f53.md](baseline_0e68f53.md); its test and timing results do not describe this change.
Starting revision: `ef62af892bce168e2e9846dc6035b414f55d8959`.
Previous results are preserved in [baseline_ef62af8.md](baseline_ef62af8.md).
The current candidate baseline is under `build/solo_reference_comparison/structured/baseline`;
previously published assets were not replaced.
Before: `production.generate_role → composer.compose → construction.Builder.propose → rhythm.Rhythms.realize + routes.Routes.realize → scoring.evaluate → one-phrase lookahead → placement → validation → notation/export`.
Before: `production.generate_role → composer.compose → Builder.compose → greedy action cost per quarter → occasional dead-end backtrack → reporting phrases → placement → validation/export`.
After: `production.generate_role → composer.compose → construction.Builder → operators.Operators + selection.Selector → bounded action repair → whole-solo placement → validation → notation/export`.
After: `production.generate_role → composer.compose → Builder.compose → common-time multi-history frontiers → complete sounded-event evaluation → whole-solo winner → placement → validation/export`.
| Finding | Verified starting behavior | Change / remaining limitation |
| Finding | Reproduced evidence | Change |
|---|---|---|
| Alternatives chosen by hashes before evaluation | `propose` hash-selected timing, destination, treatment, register and first pitch; 32 proposals per batch | Enumerate shared actions before contextual ranking. Stable content ordering breaks score ties. |
| Complete target cannot pass allocation | Autumn acoustic lead-in was outside export; electric tail clipped before returning vocal | Source pickup measure allocated before composition. Full electric tail allowed with authorized vocal overlap; vocal unchanged. |
| Scorer overwritten / density silently omitted | Not reproduced: only four named phrase terms remained | One rational action evaluator; no hidden density setting. |
| Every unit closes | Not reproduced: old constructor had open alternatives, but handoff forced a 2–3 beat hold | Individual actions need no chord-tone stop; handoff can be short or silent. |
| Answer unreachable | Not reproduced: old chooser requested answers | New answer transforms the actual heard group as a complete candidate. |
| Expressions are post-processing only | Not reproduced: old proposals planned them jointly | Keep joint treatments; carry pending exact-target obligations across actions. |
| Missing expressive coverage | Reproduced against actual enumerated operators | General delayed approaches, reattacked anticipations, anticipated colors and separated enclosures. |
| Color octaves disappear | Midpoint rounding skipped alternating F-sharp octaves | Enumerate explicit legal octaves. |
| Repeated catalog parsing | Parent parsed catalogs that independent workers parsed again | Parent parse removed; content-addressed immutable JSON catalogs shared by workers. |
| Immediate commitment | DFS continued the first cheapest action; alternative histories were visited only after failure | Multiple histories survive to equal-time checkpoints; whole completed candidates compete |
| Segmentation-dependent objective | Prefix total summed individually normalized action rates | One canonical event-stream evaluation; notation ties merge and rest partitions disappear |
| Constructor-family bonuses | Acoustic `kind/arpeggio`, `kind/answer`; electric `kind/scalar`, `kind/enclosure` | Removed from data and rejected by fitted-feature schema |
| False arpeggio label | Constant scale-degree stride ±2 | Renamed `scale_thirds`; actual arpeggios walk members of the written chord |
| Premature home identity | First four eligible recent notes | Home comes from the completed opening source window and survives separately from the last-eight-note context |
| Prefix fitting | Early-update perceptron optimized first mismatch | Replaced by shared bounded integer constraints from complete outputs |
| Coverage proof lost obligations | Constrained derivation merged histories solely by time | Stateful diagnostic retains pending contracts and validates the complete resulting stream |
| Repeated expensive work | One-bar cProfile spent most cumulative time in action enumeration/validation | Cached note/route calculations; indexed sequence licensing; deferred full validation with replacement of rejected proposals |
`Operators.at` consumes source harmony, physical domain and versioned data. It records proposed, rejected-by-rule and valid counts. `Builder.candidates` retains 24 representatives by kind/duration; pending obligations filter candidates before this cutoff. `Builder.compose` ranks complete actions and allows 16 deterministic backtracks. There is no wall-clock winner, reference-reader import or old-constructor fallback.
Budget: 16 histories, 24 valid successors per history, eight completed exports.
Retrieval ranks within equal-endpoint buckets, then round-robins endpoints. It does
not compare variable-duration actions by their individual cost rates. Frontier
scores cover the same heard span. A selected action may extend beyond a checkpoint;
its actual unsplit notes, route and obligations remain stored. Computational
quarter-note checkpoints and four-source-measure home selection are not forced
cadences. Final phrase reporting does not insert notes or endings.
This is a local policy, not a globally optimized complete-phrase model. An all-onset frontier experiment was abandoned because of excessive work. Phrase objects group selected actions for validation/reporting; that grouping does not mean whole-phrase alternatives competed. This limitation matters to exact selection.
Each destructive frontier records proposed/distinct/retained counts. A read-only
development observer can compare those cuts with a training example; its return
value cannot change selection. Absence from the final eight is not proof of
unrepresentability. The ordinary build never installs that observer or imports
a training/reference reader.
The development-only `audition.fit_selection` constrains the same enumerator to an explicit target to diagnose representability. Its fitter rolls in with ordinary ranking and updates shared coefficients at the first mismatch. A valid derivation is not proof that production selects it. The release comparison checks the exported unrestricted winner separately.
This is bounded phrase/chorus comparison, not globally optimal search. Early cuts
can still lose a desirable complete phrase. Opening diversity is retained in
addition to recent rhythmic diversity; it does not guarantee eight aesthetically
different or convincing tunes.
## Reproduction failure observed in the current candidate
The optional `structured/exact-opening-pruning.json` probe observes the ordinary
first successor cutoff without changing decisions. All Me acoustic has one exact
compatible action among 9,853 and retains none; electric has two and retains none.
Autumn acoustic has two among 8,492 and retains none; electric has nine and retains
none. Every case retains 24 successors. All four therefore fail at retrieval,
before complete-output selection. The earlier `opening-pruning.json` compared
clipped audible prefixes and counted a shortened hold as surviving. That weaker
diagnostic was corrected: committed attacks must match their complete durations.
Separate stateful constrained derivation reaches all four complete targets with
no diagnostic history pruning; that establishes construction coverage only.
Full generation also confirms a ranking failure: all four complete targets score
worse than their selected outputs under the default evaluator. Exact totals and
contribution differences are in `evaluation-diagnosis-all.json`. Eight surviving
complete alternatives are not proof that the necessary alternatives survived.
The first full regression run also exposed optional-color lookahead parsing an
explicit `N.C.` span as a chord. `eligible_color` now rejects missing/no-collection
harmonic spans before parsing. The current production sources' affected contexts
are recorded separately so a build started before that repair is not silently
described as a build from identical code.
+76
View File
@@ -0,0 +1,76 @@
# Exact-target composer repair — release status
**Not released. Exact production reproduction of the four targets has not been achieved.** Valid derivations exist for all four complete event streams, but a target-constrained derivation is not an unrestricted production winner. The existing published assets remain intact. The previous release's report is [archived here](baseline_0e68f53.md); its tests and performance numbers do not apply to this rewrite.
Starting revision: `0e68f5364bb0196ab1b7087ecc186f5d7446c42b`. Preserved baseline: `build/solo_reference_comparison/repair/baseline/`. Its `inputs.json` records source/data/reference hashes and initial cache state. The sources remain `data/songs/all-of-me.xml` and `data/songs/autumn_leaves.xml`; no archive source has replaced either.
## Acceptance and target identity
The exact target files are `build/all-of-me-solo-1.xml`, `build/all-of-me-solo-2.xml`, `build/autumn-leaves-solo-1.xml`, and `build/autumn-leaves-solo-2.xml`. Solo 1 is acoustic; Solo 2 is electric. They are untouched. The optional adapter applies the confirmed guitar-written octave convention and merges ties while preserving repeated attacks. Autumn aligns at the first Gm7 in measure 2, including the entire preceding measure at negative chorus-relative times. All of Me aligns at measure 1.
The latest approved development scope allows target-guided general-rule work and shared-coefficient fitting, but prohibits stored tunes, per-song recipes and runtime target reads. No fitted model is promoted automatically. Optional development tools and their artifacts remain outside production imports and cache identities.
The baseline exact comparison returned failure: All of Me acoustic/electric had 0/3 exact matching attacks; Autumn acoustic/electric had 0/0. Differing onset positions were 205, 201, 211 and 178 respectively, and rests differed in all four. These are exact-event diagnostics, not musical-quality scores.
## Changes in this working tree
The hash-preselected phrase constructor and separate route solver are replaced by enumerable joint musical actions, one interpretable rational evaluator and bounded deterministic backtracking. Actions include ordinary notes/rests, complete degree routes, realized-theme returns/answers, colors, approaches, enclosures and anticipations. Pending treatments carry their actual future targets and deadlines across actions. Whole-line guitar placement follows selection; it cannot change the tune or inspect another generated part.
General expressive rules now cover delayed exact-target approaches, separated enclosures, reattacked anticipations and anticipated colors. Color octave enumeration no longer loses alternating F-sharp registers through rounding. The base scale vocabulary remains the same 21 collections; exceptions have typed, bounded contracts. Full target endings and source-derived pickup allocations are supported. Electric may overlap the returning vocal pickup as authorized; source vocal events remain unchanged.
Performance changes remove redundant parent catalog parsing, add content-addressed non-executable catalog JSON, memoize pure chord/color/spelling checks, and avoid building full rational contribution reports for thousands of rejected candidates. The lightweight ranking total and detailed evaluation are equal and tested. Existing independent process workers remain available.
The development fitter uses only shared whitelisted musical features and integer updates. An initial trial exposed two defects: unordered source-interval evidence and per-attack rewards that favored subdivisions. Ordered source relationships and duration-normalized rewards repaired them. A later trace exposed indistinguishable pending targets; their relative delay, step and hold now enter selection explicitly. Interrupted diagnostic trials are not reported as completed fitting or benchmark results.
## Evidence and limitations
The staged build completed with `--check`: six performance scores, complete solo XML, notation/TAB, playback assets, and independent accompaniment for both current sources. It is under `build/solo_reference_comparison/repair/staged`, not the published asset directory. The exact gate returned exit code 1:
| Output | Generated / target attacks | Exactly matching attacks | Differing onset positions | Exact result |
|---|---:|---:|---:|---|
| All of Me acoustic | 118 / 137 | 2 | 189 | Fail |
| All of Me electric | 128 / 141 | 0 | 232 | Fail |
| Autumn Leaves acoustic | 106 / 121 | 1 | 177 | Fail |
| Autumn Leaves electric | 143 / 115 | 5 | 195 | Fail |
Rests differ in every case. These results are not close reproduction, and attack counts are not a substitute for matching the requested music. The full comparison page is `build/solo_reference_comparison/repair/comparison/index.html`. It includes untouched references, the baseline and staged XML, complete solo-only and fixed-backing WAVs, opening excerpts, and exact note differences. Rendering used one neutral treatment and constant shared gains; no ear-based audition was performed.
The actual selected events and TAB were checked against all four reparsed exports: all agree. Thus these discrepancies arise in composition/selection, not from an exporter shifting the selected melody. Source vocal and electric head documents are unchanged from the baseline. Accompaniment documents changed with the shared difficulty configuration and the source-derived participation allocation; they are still independently generated. Source and reference SHA-256 values, and the previously published solo exports, remain unchanged. Evidence: `repair/asset-verification.json`.
The fitting trial was stopped after **40 completed passes of its configured 200-pass maximum**, without convergence. Every pass still had four incorrect rollouts; the best had only eight matching prefix attacks in total across the four cases. This is an interrupted diagnostic trial, not a completed 200-pass result and not a proof that every shared model is infeasible. Its best coefficients remain development-only under `repair/fitting`; none were copied to production data.
A further normal-production check of that best checkpoint completed All of Me acoustic: **5 matching attacks, 223 differing onset positions, and different rests**; it also failed the exact gate. The other fitted-model jobs were interrupted without completed exports. This is not a four-case fitted-model release test. Its completed XML and comparison are in `repair/fitted` and `repair/fitted-comparison`; the interruption is recorded in `repair/fitted-build-status.json`. The complete four-case results above use the unfitted production defaults.
The default selection trace explains an important failure. All of Me acoustic selected 29 arpeggio actions and six returns; electric selected 32 scalar actions, nine returns and one ordinary tone. No standalone rest won in either output. Many legal expressive candidates were proposed and rejected by score. The role's action-kind preferences overwhelm much of the vocabulary, and the local policy has no complete-phrase destination comparison. Adding legal actions has not repaired that selection limitation.
### Commands actually validated
All commands use `build/build_common/python/bin/python3 build/build_tools/python_runner.py build/build_common/modulus` as their prefix. Exact invocations are in [commands.md](commands.md).
- `-m unittest discover -s tests -p 'test_*.py'`: **446 tests passed in 348.905 seconds** (`repair/verified-tests.log`).
- `-m unittest tests.test_solo_comparison`: **9 tests passed in 0.083 seconds** after removing an invalid optional report link (`repair/comparison-tests.log`).
- `-m audition.dependency_check --output .../dependency-final`: passed all six roles, including an operational electric register change that changed that role's document and left the other roles intact.
- Full staged `-m generator ... --check --jobs 2 --composition-jobs 2 --export-solo-xml`: passed. The first attempt exposed an unfillable final quarter-beat; a tempo-gated sixteenth note/rest primitive and a regression test repaired that failure before the successful build.
- `-m audition.solo_compare ... --require-exact`: failed all four outputs, as shown above. This is the release-blocking musical result, not a passing check.
- Full-score selected-event/TAB verification and source/reference/published-baseline hash checks: passed (`repair/asset-verification.json`).
The staged asset run took **430.402 seconds** with two composition workers and warm generic catalog/non-solo caches. Tests and development fitting were running concurrently, so that time is not an isolated speed benchmark. Solo role times were 156.117, 228.292, 182.301 and 174.160 seconds in that run. No cold-generation speedup is claimed from these numbers. The full recorded stage timings and actual read traces are in `repair/build-profile.json`.
After other generation/rendering jobs stopped, `-m audition.benchmark --source data/songs/all-of-me.xml --role acoustic_solo --cache .../performance/cache --output .../performance/cold` took **134.722 seconds** for the role, plus 0.046 seconds for source analysis, with one worker and an empty concrete/catalog cache. Repeating it with `--output .../performance/warm` took **2.068 seconds** with a role-cache hit. Hardware/runtime: 12 reported CPUs, Linux 7.2.3 x86-64, repository Python environment; cProfile disabled. Cold, warm and parallel-full-build documents have the same hash `adcc372315aa10cae8a956cc5002700859c81cdcf4eeaa59b4ae6f8b41161baf`. Evidence: `repair/performance/summary.json` and the two benchmark records.
The requested cold-generation reduction is **not achieved**. Warm memoization avoids recomposition, but the wider action enumeration is still expensive. The historical baseline's different music, algorithm and benchmark conditions do not support a controlled speedup ratio. Candidate construction/evaluation remains the dominant recorded stage; no further beam expansion is justified by these results.
- [Active path, reproduced findings and search limits](active_path_audit.md)
- [Data migration and provenance](data_migration.md)
- [Authoritative evaluation and fitting contract](evaluation_contract.md)
- [Capabilities and unimplemented behavior](musical_capability_matrix.md)
- [Independent-input checks](dependency_proof.md)
- [Reproduction and exact-release commands](commands.md)
Detailed winning/runner-up contributions, selected note groups and proposal/valid/retained/selected counts are in `build/solo_reference_comparison/repair/decisions.md`. Superseded diagnostic logs are consolidated in `repair/diagnostic-history.zip`; current evidence, the baseline and targets remain intact.
The search is a local action policy, not a global complete-phrase optimizer. Dedicated suspension/blues constructors and two-sided phrase revision are not present in this replacement; validators remain. Source influence and the short realized home theme are limited models of melodic identity. A catalog containing thousands of paths does not mean all those paths currently compete as complete musical ideas. No new held-out source-standard evaluation set was added. Existing synthetic transposition/meter/harmony tests do not establish generalization to an unseen standard or subjective musical quality.
The earliest demonstrated remaining failure is selection: legal target-compatible actions lose even at the opening, and later action choices collapse toward a few favored families. The next justified investigation is whether a shared context model can distinguish those conflicting choices over complete phrases, before adding more rules, expanding the beam, or running another long fit. The finite trial here does not settle that question. There is no automatic fallback to reference notes or previous output.
Technical completion, exact target equality and listener preference are separate. No ear-based audition is claimed. The release remains blocked unless all four unrestricted exported comparisons pass; a valid or stylistically plausible mismatch is not substituted for that requirement.
+62 -9
View File
@@ -10,13 +10,13 @@ The commands below go after that prefix. They write only the consolidated repair
```sh
-m unittest discover -s tests -p 'test_*.py'
-m audition.dependency_check --output build/solo_reference_comparison/repair/dependency
-m audition.dependency_check --output build/solo_reference_comparison/structured/dependency
```
Development-only derivation/fitting (use `explain` instead of `fit` for coverage alone):
Development-only stateful derivation / complete-output fitting (use `explain` instead of `fit` for coverage alone). Install the optional solver with repository Python and `-m pip install --target build/development_packages -r audition/requirements-training.txt`. Production does not load that directory:
```sh
-m audition.fit_selection fit --passes 200 \
-m audition.fit_selection fit --passes 8 --solver-path build/development_packages \
--source all_me_a=data/songs/all-of-me.xml --source all_me_e=data/songs/all-of-me.xml \
--source autumn_a=data/songs/autumn_leaves.xml --source autumn_e=data/songs/autumn_leaves.xml \
--reference all_me_a=build/all-of-me-solo-1.xml --reference all_me_e=build/all-of-me-solo-2.xml \
@@ -24,30 +24,83 @@ Development-only derivation/fitting (use `explain` instead of `fit` for coverage
--role all_me_a=acoustic_solo --role all_me_e=electric_solo \
--role autumn_a=acoustic_solo --role autumn_e=electric_solo \
--form-start all_me_a=1 --form-start all_me_e=1 --form-start autumn_a=2 --form-start autumn_e=2 \
--cache-dir build/solo_reference_comparison/repair/cache --output build/solo_reference_comparison/repair/fitting
--cache-dir build/solo_reference_comparison/structured/cache --output build/solo_reference_comparison/structured/fitting
```
Production stage (no reference arguments):
```sh
-m generator --data-dir data --output-dir build/solo_reference_comparison/repair/staged \
-m generator --data-dir data --output-dir build/solo_reference_comparison/structured/staged \
--static-assets-dir package/assets --check --jobs 2 --composition-jobs 2 \
--composition-cache build/solo_reference_comparison/repair/cache \
--composition-cache build/solo_reference_comparison/structured/verified-cache \
--engraving-cache build/build_common/engraving_cache --export-solo-xml --trace-level full \
--profile-output build/solo_reference_comparison/repair/build-profile.json --progress
--profile-output build/solo_reference_comparison/structured/build-profile.json --progress
```
Exact gate after generation:
```sh
-m audition.solo_compare --generated-dir build/solo_reference_comparison/repair/staged/solo_xml \
-m audition.solo_compare --generated-dir build/solo_reference_comparison/structured/staged/solo_xml \
--reference all_of_me/acoustic=build/all-of-me-solo-1.xml --reference all_of_me/electric=build/all-of-me-solo-2.xml \
--reference autumn_leaves/acoustic=build/autumn-leaves-solo-1.xml --reference autumn_leaves/electric=build/autumn-leaves-solo-2.xml \
--form-start all_of_me/acoustic=1 --form-start all_of_me/electric=1 \
--form-start autumn_leaves/acoustic=2 --form-start autumn_leaves/electric=2 \
--reference-pitch guitar-written --output build/solo_reference_comparison/repair/comparison --require-exact
--reference-pitch guitar-written \
--baseline-dir build/solo_reference_comparison/structured/baseline/solo_xml \
--assets build/solo_reference_comparison/structured/staged \
--output build/solo_reference_comparison/structured/comparison --require-exact
```
`--require-exact` returns 1 on any sounding-event/rest mismatch; it does not reinterpret a similar style as success. Add `--assets <completed-assets>` for consistent neutral rendering. A failed fit or exact gate is not permission to copy targets, promote a target-constrained derivation, or publish a mismatched stage. Normal CMake publication is intentionally deferred until the approved acceptance gate passes.
Individual roles remain available through `-m generator.compose --song ... --role acoustic_solo --data-dir data --cache-dir ... --output ... --solo-xml ...`. Use `-m audition.benchmark --source ... --role ... --cache ... --output ...` for an isolated measurement after other builds/tests/fits stop. Profiling changes measured runtime; do not compare a cProfile run to an unprofiled build as a speedup ratio.
Source-only four-bar completed alternatives (the XML marked candidate 1 is the automatic winner):
```sh
-m audition.phrase_candidates --source data/songs/all-of-me.xml --role acoustic_solo \
--bars 4 --cache-dir build/solo_reference_comparison/structured/cache \
--output build/solo_reference_comparison/structured/four-bar
```
No solver output or constrained derivation may replace an ordinary production score.
The exact four-complete-output gate remains mandatory. Reference absence proves a
runtime boundary only; learned coefficients still depend on their training data.
A completed ordinary build can supply the negative candidates for one learning
round without repeating inference just to reconstruct its report. Use
`-m audition.train_completed` with the same `--source`, `--reference`, `--role`,
and `--form-start` case arguments, plus `--completed-dir <assets>`,
`--solver-path build/development_packages`, and `--output <development-round>`.
`--stage-data` creates a separate production-data copy with the proposed shared
weights. It does not edit `data/` or published assets. Feed that copy to the normal
`-m generator --data-dir ...` command for a new cold target-free build, then run
the complete four-output exact gate. A solver that separates eight saved
candidates has not demonstrated exact generation.
For later complete-output rounds, add `--previous-round <earlier-training.json>`
to retain prior counterexamples. Incompatible source/example identities,
evaluation versions or fixed musical policy are rejected. Only learned weights
may change between compatible rounds. Do not fit only the latest eight outputs
and silently forget the earlier failures.
The first proposed model's ordinary trial uses the same production command with
`--data-dir .../round-1/data --output-dir .../learned-stage`, separate
`--composition-cache .../learned-cache`, `--composition-jobs 4`, and
`--profile-output .../learned-build-profile.json`. This process receives no
reference arguments. Comparison and learning are separate later commands.
The completed second fit used `--data-dir .../round-1/data`,
`--completed-dir .../learned-stage`,
`--previous-round .../round-1/evidence/training.json`, and
`--output .../round-2`, with the same four source/reference/role arguments.
It accumulated 64 full-output inequalities. Its successful solver status is
not a release: the acoustic openings still fail the strict cutoff diagnosis,
and no third complete ordinary rollout was performed.
Final focused tests were run with `PYTHONPATH=build/development_packages` and
`-m unittest tests.test_joint_actions tests.test_structured_selection` (35 passed).
The latest dependency check uses `--output .../dependency-cached`.
The read-only staged verification script also accepts
`--stage learned-stage --data-dir .../round-1/data --prefix learned-`.
All comparison artifacts are linked from `structured/index.html`.
+28 -1
View File
@@ -1,6 +1,6 @@
# Data and consumer migration
The latest approved scope permits target-guided diagnosis of general rules and fitting shared theory coefficients. Production target reads, copied tunes, per-song recipes and hidden phrase banks remain prohibited. Policy provenance records this development influence; these four targets are not an unseen evaluation set. A development weight artifact is not automatically a production resource.
The latest approved scope permits target-guided diagnosis of general rules and fitting shared theory coefficients. Production target reads, copied tunes, per-song recipes and hidden phrase banks remain prohibited. Policy provenance records this development influence; these four targets are not an unseen evaluation set. A development weight artifact is not automatically a production resource. The first table records the inherited migration at the starting revision; the selector-repair section below identifies this iteration's changes.
| Path | Action | Live consumer / reason |
|---|---|---|
@@ -23,3 +23,30 @@ The latest approved scope permits target-guided diagnosis of general rules and f
The formulas are general relative intervals and durations, not extracted phrases. Supporting discussion: [Berklee tension and resolution](https://www.berklee.edu/berklee-today/summer-2014/tension-and-resolution), [available tensions](https://college.berklee.edu/berklee-today-55), [Locrian natural six](https://www.jazz-guitar-licks.com/pages/guitar-scales-modes/modes-of-the-harmonic-minor-scale/the-locrian-natural-6-mode.html). No example melody from these sources was imported. Numerical bounds are project choices, not universal jazz rules. Authored resources retain MIT licensing.
Cold-build independence cannot establish provenance alone; review policy/code diffs too. No source score, target score, tuning, fret limit, runtime score schema, velocity or playback feel was changed.
## Selector repair at ef62af8
No scale, sequence, rhythm, harmony, lead-in, source-song or accompaniment vocabulary
was added or removed in this step. The live solo budget now names `histories=16`,
`per_state=24`, and `completed=8`; the obsolete `backtracks` setting is removed.
Role-level constructor-family weights were removed. Existing duration/metrical
preferences remain. Both roles still use all six strings and frets 1–12.
`Operators` now distinguishes scale thirds from written-chord arpeggios. Sequence
licensing is indexed once. Harmonic interval permissions, note objects and color
construction are memoized without changing their underlying theory rules.
Expensive validation can be deferred past cheap retrieval, but a candidate must
pass before it advances. The ordinary `Operators.at` diagnostic still returns
fully validated actions.
The old prefix-perceptron implementation was removed from `audition/fit_selection.py`.
Its replacement is `audition/structured_learning.py`, with the pinned optional
solver in `audition/requirements-training.txt`. Neither is imported by production.
`audition/phrase_candidates.py` exports ordinary completed alternatives without
accepting any target-file arguments. Failed development models remain under
`build/solo_reference_comparison/structured`; none is automatically copied to data.
`audition/train_completed.py` can form complete-output ranking constraints from
ordinary finished candidate reports. It verifies the source, role, vocabulary,
and evaluation version, and records explicit training dependencies. Its optional
staged data copy is for a subsequent ordinary cold build, not automatic promotion.
+26 -14
View File
@@ -1,25 +1,37 @@
# Independent inputs and reproducibility
Production consumes canonical source music, relevant versioned production data, role settings and `joint-actions-1`. Optional targets, comparison outputs and completed parts are not runtime musical inputs. The separate development fitter is allowed to inspect explicit targets; that is development influence, not a runtime read. Failed fitted artifacts are not promoted.
Production consumes canonical source music, relevant production data, role settings and `phrase-frontiers-2`. References, training outputs and prior published parts are not runtime musical inputs. Any coefficients learned by the separate development commands explicitly depend on their training examples; that is not complete independence from those examples. No learned proposal has been promoted.
`input_audit.composition` records actual reads and rejects paths outside production data, generator code, the Python runtime, the current role's concrete cache and a narrowly shared `_resources` catalog cache. The latter contains only compiled generic production catalogs, keyed by content and decoder semantics, with a payload checksum. Other roles' concrete caches remain forbidden. Tests also cover corrupted catalog regeneration.
`input_audit.composition` records actual reads and rejects paths outside production data, generator code, Python, the current role's own concrete cache and the checksummed immutable `_resources` catalog cache. Other roles' concrete caches are prohibited. The optional training/comparison modules are not imported by production.
The explicit `audition.dependency_check` passed for all six roles on an independently authored one-bar source using real production catalogs:
## Current exercised boundary
- Alone/cold versus reversed order/warm.
- Serial versus two spawned processes/cold.
- Deliberately invalid optional targets present versus removed/cold.
- Rebuilding after an electric-only setting change.
`audition.dependency_check` passed for an independently authored one-bar source with real production catalogs and all six roles:
Evidence, actual read paths and all six hashes: `build/solo_reference_comparison/repair/dependency-final/dependency.json`. The changed electric register produced hash `bf16a8b06e38ab50384d98a1fdd6bf13d62eddc1eaf350dd91d2186ca39d92ee`, proving the exercised setting changed music rather than only invalidating a cache. Example unchanged solo document hashes:
- Each role alone with cold caches, then reversed order with warm caches.
- Two spawned processes with cold caches.
- Deliberately invalid optional targets present, then absent, with cold generation.
- An electric-only register change that changed the electric output, followed by unchanged hashes for the original settings of every role.
- Acoustic: `6ded04c27b1ee638644b0717be095ff1af5820da17b89876e5c89f1e18901997`.
- Electric: `56b7bc09146facb91c0dc0ffe71dbdd0239d23d5fab5955c2b3fe7e03b94a882`.
Evidence and actual read paths are in `build/solo_reference_comparison/structured/dependency-cached/dependency.json`. All six cases equal the preceding `dependency-final/dependency.json` and `dependency/dependency.json` runs. Canonical musical document hashes:
This is a dependency test, not a whole-song musical or performance assessment. Separate full-source checks in `repair/asset-verification.json` establish that all four selected event streams and TAB match reparsed exports, source vocal/head documents are unchanged, and the source/reference/published-baseline hashes remain unchanged.
- `acoustic_accompaniment`: `45658c437f6a19991246a4dcf2966d0de90ace8bbb71deae7899614ad12dc6da`.
- `electric_accompaniment`: `89bbf33108dfc428e59a141414059aa9ec8a23d6efd00e842adbeba32913d7b8`.
- `electric_melody`: `b8d18ef6fd65b204bd2fc2b69e660068890cb30aea6db827c3f3b506284ea2a1`.
- `vocal_melody`: `e3348117b9c63771cfa0439f276bdadf0d26dd6e560c9676c97e12a4a713737b`.
- `acoustic_solo`: `84328ecb944f52ab5cdd16b143546831116d9c83edefbe307e16af967e785d8e`.
- `electric_solo`: `c9741203d6d4a9df7f4d3a83b0bca72744c657bd49bc41a80842fb25ec468a07`.
The complete All of Me acoustic solo also has identical musical document hash in the independent cold benchmark, warm own-role cache build and two-worker full production build: `adcc372315aa10cae8a956cc5002700859c81cdcf4eeaa59b4ae6f8b41161baf`. This strengthens the synthetic proof for that full source/role; it does not claim an isolated cold/warm benchmark for every role.
Changed electric configuration: `146b3efa50acd7db14f40d61637bbb55f1feb456abfa7f29750ca1133218743c`.
Canonical ordering is independent of requested-role order and worker completion. Ties resolve through stable musical content keys. Catalog/report/cache implementation changes invalidate affected cached representations without intentionally changing the semantic generation version. Timing and observed-read diagnostics remain outside the musical document.
This establishes the exercised dependency boundary, not whole-song aesthetic quality or exact reproduction. It is not a four-full-song cold/warm timing benchmark. The ordinary regression suite also exercises source-melody changes, irrelevant title/ID changes, role settings, tuplets and canonical accompaniment reuse.
Deletion tests cannot detect target-derived material already embedded in code/data. Review new rule provenance and the explicit learned-feature whitelist separately. The development vocabulary is target-guided, and these examples are not claimed as unseen generalization evidence.
The two complete production builds provide an additional whole-song check:
changing the shared solo coefficient tables changed both solos but left all eight
non-solo role documents identical across both songs. Canonical hashes are in
`structured/full-song-role-independence.json`. This does not replace the standalone
role tests or establish exact reproduction; both full models failed that gate.
New model feature schemas prohibit song names, titles, file paths, hashes, absolute bar indices, arbitrary predicates and event-sequence banks. This bounds the induction representation but cannot by itself prove that every possible fitted model generalizes. Training provenance and held-out evaluation remain necessary.
The earlier local-policy dependency and full-song benchmark results belong to the archived baseline report. They must not be quoted as current whole-song equivalence evidence.
+100 -8
View File
@@ -1,15 +1,107 @@
# Authoritative action evaluation
# Canonical phrase evaluation and shared learning
`selection.Selector.evaluate` returns `Evaluation(version='action-selection-1')`: raw normalized values, enabled weights, exact rational contributions, total and denominators. Ranking uses exactly the sum of reported contributions. Tied continuations merge before evaluation; repeated attacks remain distinct.
`Selector.complete(actions, home, start, end)` is authoritative. It concatenates
selected sounding events, merges only explicit ties, reconstructs silence, and
calls the versioned `phrase-selection-2` evaluator. Its exact rational total is
the sum of its reported contributions. Constructor labels earn no reward.
The same sounded music and home identity have the same complete evaluation
regardless of action partitioning. Repeated attacks remain different from ties.
The scope is one complete musical action, not a chorus. Alternatives compete by cost per exact quarter; compound routes remain indivisible. Prefix and reporting-phrase totals sum local decisions. They are diagnostics, not global objectives or partition-invariant phrase quality scores. Computational partitioning that preserves the action objects does not change evaluation. Changing musical action boundaries can change the local search objective.
The record exposes raw normalized values, weights, contributions, total, span and
attack count. Common-time frontiers score only the heard portion; the remaining
route is preserved in search state. Pending future treatment features are not a
claim that a resolution has already sounded. Completed alternatives cover the
same allocation. Retrieval costs are explicitly heuristics, not published
whole-phrase scores. Expensive validation runs before a retained successor may
advance; rejected proposals are replaced from their endpoint bucket.
Fixed priors address excessive leaps, register distance outside a soft corridor, oscillation, repeated attacks, unprepared space, repeated rests, source interval relationships, actual theme relationships, sustained chord-tone arrivals and directed steps. Shared integer coefficients can distinguish action kind, duration, chord-relative pitch role, incoming interval, relative metric position, motion direction, register band, source rhythm, realized theme evidence and context of silence. Title, filename, absolute bar index, reference hash and event lookups are not allowed features.
General priors concern large leaps, a soft register corridor, oscillation,
repeated attacks, interruption, ordered source relationships, realized-theme
recurrence, sustained chord-tone arrival, and directed motion. Learned features
may describe duration, relative metrical placement, chord quality/relative pitch,
relative motion, register band, source rhythm, theme recurrence, silence context,
and bounded pending obligations. Silence has no fabricated register. Missing
harmony covers only the actual unharmonized interval, not the rest of a phrase.
Silence has no pitch/register value. A rest after an arrival differs from an interruption or repeated rest. A sustain is one attack with its complete duration. Approved expression is validated by contract rather than automatically rewarded or penalized as chromatic.
## Development learning
Hard validity remains separate: mapped collections, typed exceptions, exact target register/time/duration, pending deadlines, rhythm groups, physical domain, monophony and source-derived allocation. Pending obligations filter the frontier before pruning. Final placement cannot change selected pitches or rhythm.
`audition.fit_selection fit` uses completed ordinary source-only rollouts.
`audition.structured_learning` adds shared within-role ranking inequalities and
uses pinned development-only `z3-solver==4.13.3.0`. Coefficients are integers within
±1024. Objectives minimize nonzero coefficients then total absolute magnitude;
solver work uses a deterministic resource budget. Exhaustion returns no model.
An unsatisfiable core means incompatibility within this schema/bound, not a proof
that no generic musical model could reproduce the examples.
The optional fitter uses an integer early-update structured perceptron, at most 200 passes. Coefficients are shared between songs within each role. Its checkpoint prefers fewer incorrect rollouts, then longer exact prefixes, then smaller coefficients. This differs from minimizing whole-output discrepancy; full export comparison remains the acceptance gate. Fitted artifacts stay under `build/` until a verified model is deliberately promoted. Constructibility alone is not grounds for promotion.
`audition.train_completed --previous-round` retains prior complete-output
constraints. It verifies source/example hashes, role, evaluator, semantic
algorithm, fixed priors and production data with learned weights excluded.
Only changing weights or their provenance permits reuse; changing the underlying
musical conditions requires a new, explicitly identified training experiment.
Duplicate inequalities are removed. No previous generated part becomes a
production input: these are development-only learning records.
Synthetic tests check contribution sums, weight counterfactuals, tied-event invariance, forbidden feature names and a learnable two-choice fitting problem. They establish mechanics, not that this finite feature set expresses the four desired decisions or predicts musical preference.
Before combinatorial optimization, the fitter checks exact integer intervals for
zero or one nonzero coefficient per independent role. If all roles admit these,
enumerating every allowed feature proves the minimum support and its minimum L1
solution directly. This repaired a measured solver-budget exhaustion on a small
feasible problem; it changes the solver strategy, not the acceptance gate.
The post-fit first-successor diagnostic can disprove full reproduction when no
compatible opening survives. Surviving that check cannot prove reproduction.
Compatibility requires the complete committed attack duration. A clipped match
to the first portion of a target hold does not count as surviving reproduction.
Only demonstrated identical-feature conflicts may request a new conjunction of
two or three whitelisted conditions. Conditions are chord quality, root-relative
degree, relative motion, metrical category, duration, and ordered source evidence.
No source ID, title, path, hash, absolute measure, target sequence, arbitrary
predicate, or per-song branch is admitted. Constructor-family weights are rejected.
Conjunction semantics have independent positive, negative and transposition tests.
Any actually induced rule is recorded as training-dependent; no untested learned
rule or solver result is promoted automatically.
The training examples may affect coefficients and induced rules. That is an
explicit training dependency. Production target-file absence does not prove
independence of learned knowledge from those examples. Runtime composition uses
plain validated coefficient data, not Z3, target paths or a phrase-retrieval bank.
The previous prefix fitter is retired. Target-constrained derivation remains only
a stateful coverage probe, never a generated winner. A completed training iteration
is not the release gate: separate cold ordinary role builds and full exported
sounding-event/rest equality are still mandatory for all four authored targets.
## Exact performance shortcuts
`Selector.extend` updates sufficient statistics by replacing the affected tail
and seam, retaining enough preceding context for source interval pairs,
oscillation and a complete possible home-theme match. A newly established home
or explicit tied continuation takes the complete-evaluation path. Pending-target
features are recalculated for the new horizon. Regression tests compare every
incremental record against canonical complete evaluation.
Retrieval caches internal route contributions but recalculates incoming pitch
relationships and the meaning of a leading rest. Weights are immutable validated
mappings; changing the mapping invalidates weighted caches. Past-frontier concrete
candidates are evicted after all incoming histories have been considered; selected
events remain in immutable search histories. These cache lifetimes are not musical
inputs or wall-clock search budgets.
Theme signature equality short-circuits on the first actual differing feature;
home signatures are cached while retaining their immutable home objects. The
comparison is exactly equivalent to normalized onset/duration/relative-pitch
tuple equality. It does not approximate thematic similarity or change scores.
Unconditional retrieval combines cached unary, transition, gap and pending-target
costs directly instead of allocating full feature reports for every rejected
action. Tests compare the result with the authoritative feature calculation
across all action families, incoming histories and weight changes. Learned joint
conditions and explicit tied fragments take the complete feature path; no missing
contextual term or altered tied duration is silently dropped by the shortcut.
Repeated endpoint retrieval is memoized only for the same immutable action
catalog, exact frontier, last three pitches/release, incoming sustained/rest
classification, pending contracts, weights and budget. Full home-aware candidate
evaluation still runs with the real history. Weight changes invalidate the cache;
retaining the catalog object prevents object-ID reuse. Tests distinguish pending
targets and incoming hold durations, and a complete four-bar rerun preserves all
events, candidate evaluations and frontier records.
+95 -46
View File
@@ -1,76 +1,125 @@
# Exact-target composer repair — release status
# Complete-phrase selection and shared-rule learning
**Not released. Exact production reproduction of the four targets has not been achieved.** Valid derivations exist for all four complete event streams, but a target-constrained derivation is not an unrestricted production winner. The existing published assets remain intact. The previous release's report is [archived here](baseline_0e68f53.md); its tests and performance numbers do not apply to this rewrite.
**Exact reproduction is not achieved. The published baseline is unchanged.** The four complete authored solos remain the acceptance criterion. Legal construction, matching prefixes, learned rankings and passing tests do not satisfy it.
Starting revision: `0e68f5364bb0196ab1b7087ecc186f5d7446c42b`. Preserved baseline: `build/solo_reference_comparison/repair/baseline/`. Its `inputs.json` records source/data/reference hashes and initial cache state. The sources remain `data/songs/all-of-me.xml` and `data/songs/autumn_leaves.xml`; no archive source has replaced either.
Starting revision: `ef62af892bce168e2e9846dc6035b414f55d8959`. The preceding report is [archived](baseline_ef62af8.md). Current evidence is consolidated in `build/solo_reference_comparison/structured/`; `baseline/` preserves the previous complete comparison output. Saved output is never a production input.
## Acceptance and target identity
## Active implementation
The exact target files are `build/all-of-me-solo-1.xml`, `build/all-of-me-solo-2.xml`, `build/autumn-leaves-solo-1.xml`, and `build/autumn-leaves-solo-2.xml`. Solo 1 is acoustic; Solo 2 is electric. They are untouched. The optional adapter applies the confirmed guitar-written octave convention and merges ties while preserving repeated attacks. Autumn aligns at the first Gm7 in measure 2, including the entire preceding measure at negative chorus-relative times. All of Me aligns at measure 1.
The production path is `production.generate_role → solo.composer.compose → construction.Builder → placement → validation/export`. The former builder committed the cheapest next action and backtracked only after failure. It now retains histories through common exact-time frontiers and selects among completed event streams. Limits are 16 histories, 24 successors per history and eight completed exports. A route, sounding tail and pending harmonic obligation survive a computational frontier.
The latest approved development scope allows target-guided general-rule work and shared-coefficient fitting, but prohibits stored tunes, per-song recipes and runtime target reads. No fitted model is promoted automatically. Optional development tools and their artifacts remain outside production imports and cache identities.
`Selector.complete` is the authoritative `phrase-selection-2` evaluator. It merges explicit ties, reconstructs silence and reports the contributions actually used for selection. Action labels no longer earn numerical rewards. `scale_thirds` means alternate scale-degree walks; `arpeggio` walks written chord members. A completed opening owns the home identity, separately from the eight-note recent-history buffer.
The baseline exact comparison returned failure: All of Me acoustic/electric had 0/3 exact matching attacks; Autumn acoustic/electric had 0/0. Differing onset positions were 205, 201, 211 and 178 respectively, and rests differed in all four. These are exact-event diagnostics, not musical-quality scores.
A controlled test retains a locally more expensive opening and selects it when it produces the better completed phrase. Tests also cover partition invariance, pending continuation, arpeggios, home identity, deferred validation and exact incremental scoring. These establish selector behavior, not aesthetic quality or global optimality.
## Changes in this working tree
No source song, scale family, rhythm catalog, accompaniment vocabulary, fret domain, runtime schema or performance expression changed. Production data changes remove action-family bonuses and replace the backtrack budget. Missing/no-collection harmony now rejects optional colors before parsing `N.C.`. Fast retrieval uses canonical evaluation for tied fragments and learned contextual rules.
The hash-preselected phrase constructor and separate route solver are replaced by enumerable joint musical actions, one interpretable rational evaluator and bounded deterministic backtracking. Actions include ordinary notes/rests, complete degree routes, realized-theme returns/answers, colors, approaches, enclosures and anticipations. Pending treatments carry their actual future targets and deadlines across actions. Whole-line guitar placement follows selection; it cannot change the tune or inspect another generated part.
## Complete default-build result
General expressive rules now cover delayed exact-target approaches, separated enclosures, reattacked anticipations and anticipated colors. Color octave enumeration no longer loses alternating F-sharp registers through rounding. The base scale vocabulary remains the same 21 collections; exceptions have typed, bounded contracts. Full target endings and source-derived pickup allocations are supported. Electric may overlap the returning vocal pickup as authorized; source vocal events remain unchanged.
The staged build passed `--check` and produced six arrangement scores, complete solos and independent accompaniment for both songs. It is in `structured/staged`, not the published asset directory.
Performance changes remove redundant parent catalog parsing, add content-addressed non-executable catalog JSON, memoize pure chord/color/spelling checks, and avoid building full rational contribution reports for thousands of rejected candidates. The lightweight ranking total and detailed evaluation are equal and tested. Existing independent process workers remain available.
| Output | Generated / target attacks | Matching pitch/onset/release attacks | Full exact result |
|---|---:|---:|---|
| All of Me acoustic | 116 / 137 | 0 | Fail |
| All of Me electric | 333 / 141 | 2 | Fail |
| Autumn Leaves acoustic | 164 / 121 | 1 | Fail |
| Autumn Leaves electric | 311 / 115 | 6 | Fail |
The development fitter uses only shared whitelisted musical features and integer updates. An initial trial exposed two defects: unordered source-interval evidence and per-attack rewards that favored subdivisions. Ordered source relationships and duration-normalized rewards repaired them. A later trace exposed indistinguishable pending targets; their relative delay, step and hold now enter selection explicitly. Interrupted diagnostic trials are not reported as completed fitting or benchmark results.
Rests differ in every case. These are substantial failures, especially the electric parts' continuous activity. Counts are diagnostics, not substitute acceptance thresholds. The exact comparison command returned **1**.
## Evidence and limitations
The [comparison page](../../build/solo_reference_comparison/structured/comparison/index.html) includes baseline, candidate and unchanged reference XML; complete solo-only and fixed-backing WAVs; opening excerpts; final independent backing; and exact differences. Rendering uses consistent neutral treatment and constant gains. **No ear-based audition was performed.**
The staged build completed with `--check`: six performance scores, complete solo XML, notation/TAB, playback assets, and independent accompaniment for both current sources. It is under `build/solo_reference_comparison/repair/staged`, not the published asset directory. The exact gate returned exit code 1:
Reparsed exports agree with all four selected event streams. Every TAB position is legal; ties retain position. Harmonic validation reports no unresolved treatments. All 32 completed-candidate totals equal a fresh canonical evaluation (`asset-verification.json`, `evaluation-recheck.json`). The mismatch is in composition/search/selection, not export.
| Output | Generated / target attacks | Exactly matching attacks | Differing onset positions | Exact result |
|---|---:|---:|---:|---|
| All of Me acoustic | 118 / 137 | 2 | 189 | Fail |
| All of Me electric | 128 / 141 | 0 | 232 | Fail |
| Autumn Leaves acoustic | 106 / 121 | 1 | 177 | Fail |
| Autumn Leaves electric | 143 / 115 | 5 | 195 | Fail |
## Failure diagnosis
Rests differ in every case. These results are not close reproduction, and attack counts are not a substitute for matching the requested music. The full comparison page is `build/solo_reference_comparison/repair/comparison/index.html`. It includes untouched references, the baseline and staged XML, complete solo-only and fixed-backing WAVs, opening excerpts, and exact note differences. Rendering used one neutral treatment and constant shared gains; no ear-based audition was performed.
All four targets are playable on strings 1–6, frets 1–12 without open strings under the confirmed guitar-written convention. All of Me aligns at measure 1; Autumn Leaves at the first Gm7 in measure 2, retaining the preceding measure at negative form-relative time. Sources and references are unchanged.
The actual selected events and TAB were checked against all four reparsed exports: all agree. Thus these discrepancies arise in composition/selection, not from an exporter shifting the selected melody. Source vocal and electric head documents are unchanged from the baseline. Accompaniment documents changed with the shared difficulty configuration and the source-derived participation allocation; they are still independently generated. Source and reference SHA-256 values, and the previously published solo exports, remain unchanged. Evidence: `repair/asset-verification.json`.
Stateful constrained derivation reaches all four complete streams with valid harmonic contracts and zero diagnostic history cuts. This demonstrates coverage only. The ordinary first successor cutoff drops every whole-action-compatible target opening:
The fitting trial was stopped after **40 completed passes of its configured 200-pass maximum**, without convergence. Every pass still had four incorrect rollouts; the best had only eight matching prefix attacks in total across the four cases. This is an interrupted diagnostic trial, not a completed 200-pass result and not a proof that every shared model is infeasible. Its best coefficients remain development-only under `repair/fitting`; none were copied to production data.
| Case | Proposed actions | Exact compatible openings | Retained |
|---|---:|---:|---:|
| All of Me acoustic | 9,853 | 1 | 0 |
| All of Me electric | 9,853 | 2 | 0 |
| Autumn Leaves acoustic | 8,492 | 2 | 0 |
| Autumn Leaves electric | 8,492 | 9 | 0 |
A further normal-production check of that best checkpoint completed All of Me acoustic: **5 matching attacks, 223 differing onset positions, and different rests**; it also failed the exact gate. The other fitted-model jobs were interrupted without completed exports. This is not a four-case fitted-model release test. Its completed XML and comparison are in `repair/fitted` and `repair/fitted-comparison`; the interruption is recorded in `repair/fitted-build-status.json`. The complete four-case results above use the unfitted production defaults.
Evidence: `exact-opening-pruning.json`. An earlier clipped-prefix diagnostic counted a shortened Autumn acoustic hold as surviving; `opening-pruning.json` records that weaker historical observation. The observer now checks complete committed events, with a regression preventing this mistake. It never supplies candidates or changes scores.
The default selection trace explains an important failure. All of Me acoustic selected 29 arpeggio actions and six returns; electric selected 32 scalar actions, nine returns and one ordinary tone. No standalone rest won in either output. Many legal expressive candidates were proposed and rejected by score. The role's action-kind preferences overwhelm much of the vocabulary, and the local policy has no complete-phrase destination comparison. Adding legal actions has not repaired that selection limitation.
Final ranking is also insufficient. Complete targets would lose under default weights even if retained:
### Commands actually validated
| Case | Selected total | Complete target total |
|---|---:|---:|
| All of Me acoustic | −2787/64 | 945/64 |
| All of Me electric | −4551/128 | 1287/128 |
| Autumn Leaves acoustic | −1420/33 | 14/11 |
| Autumn Leaves electric | −420/11 | 239/22 |
All commands use `build/build_common/python/bin/python3 build/build_tools/python_runner.py build/build_common/modulus` as their prefix. Exact invocations are in [commands.md](commands.md).
Lower wins. `evaluation-diagnosis-all.json` records component differences. Source interval-pair resemblance, directed motion, register cost and duration preferences still favor continuous movement. The current features do not establish the longer ordered relationships needed to specify these authored tunes. This is not a proof that every generic model must fail.
- `-m unittest discover -s tests -p 'test_*.py'`: **446 tests passed in 348.905 seconds** (`repair/verified-tests.log`).
- `-m unittest tests.test_solo_comparison`: **9 tests passed in 0.083 seconds** after removing an invalid optional report link (`repair/comparison-tests.log`).
- `-m audition.dependency_check --output .../dependency-final`: passed all six roles, including an operational electric register change that changed that role's document and left the other roles intact.
- Full staged `-m generator ... --check --jobs 2 --composition-jobs 2 --export-solo-xml`: passed. The first attempt exposed an unfillable final quarter-beat; a tempo-gated sixteenth note/rest primitive and a regression test repaired that failure before the successful build.
- `-m audition.solo_compare ... --require-exact`: failed all four outputs, as shown above. This is the release-blocking musical result, not a passing check.
- Full-score selected-event/TAB verification and source/reference/published-baseline hash checks: passed (`repair/asset-verification.json`).
The source-only four-bar diagnostic exports eight completed alternatives and identifies the automatic winner. Seven first-bar realizations differ, but all share the first four attacks. Their complete events, home identities, contracts, actions, frontier counts and scores are in `completed-phrases/candidates.json`. Eight retained alternatives do not by themselves demonstrate useful opening diversity.
The staged asset run took **430.402 seconds** with two composition workers and warm generic catalog/non-solo caches. Tests and development fitting were running concurrently, so that time is not an isolated speed benchmark. Solo role times were 156.117, 228.292, 182.301 and 174.160 seconds in that run. No cold-generation speedup is claimed from these numbers. The full recorded stage timings and actual read traces are in `repair/build-profile.json`.
## Development learning
After other generation/rendering jobs stopped, `-m audition.benchmark --source data/songs/all-of-me.xml --role acoustic_solo --cache .../performance/cache --output .../performance/cold` took **134.722 seconds** for the role, plus 0.046 seconds for source analysis, with one worker and an empty concrete/catalog cache. Repeating it with `--output .../performance/warm` took **2.068 seconds** with a role-cache hit. Hardware/runtime: 12 reported CPUs, Linux 7.2.3 x86-64, repository Python environment; cProfile disabled. Cold, warm and parallel-full-build documents have the same hash `adcc372315aa10cae8a956cc5002700859c81cdcf4eeaa59b4ae6f8b41161baf`. Evidence: `repair/performance/summary.json` and the two benchmark records.
The prefix perceptron is removed. The replacement forms exact rational inequalities between complete ordinary outputs and complete examples. It learns shared bounded integer coefficients per role. Allowed features are relative musical categories and bounded two/three-condition rules, not song names, hashes, absolute measure indices, event arrays or per-song recipes. New conjunctions require demonstrated feature collisions. Production does not import the solver or read examples.
The requested cold-generation reduction is **not achieved**. Warm memoization avoids recomposition, but the wider action enumeration is still expensive. The historical baseline's different music, algorithm and benchmark conditions do not support a controlled speedup ratio. Candidate construction/evaluation remains the dominant recorded stage; no further beam expansion is justified by these results.
The optional dependency is pinned to `z3-solver==4.13.3.0`. Exact zero/one-coefficient interval enumeration handles sparse cases; integer optimization handles the remainder. Feasibility and optimization have deterministic work limits. Incompatibility or exhaustion yields a diagnosis, not a substituted tune.
- [Active path, reproduced findings and search limits](active_path_audit.md)
- [Data migration and provenance](data_migration.md)
- [Authoritative evaluation and fitting contract](evaluation_contract.md)
- [Capabilities and unimplemented behavior](musical_capability_matrix.md)
- [Independent-input checks](dependency_proof.md)
- [Reproduction and exact-release commands](commands.md)
The first four-example round compared each target with eight saved complete alternatives. It found acoustic `duration/1 = 9` and electric `metric/eighth/1_2 = 15`. These sparse coefficients separate the sampled alternatives; **they are not a learned jazz grammar or evidence of reproduction**. They remain in `structured/round-1/data`, with explicit source/target/artifact hashes and training provenance. Nothing was promoted to production `data/`.
Detailed winning/runner-up contributions, selected note groups and proposal/valid/retained/selected counts are in `build/solo_reference_comparison/repair/decisions.md`. Superseded diagnostic logs are consolidated in `repair/diagnostic-history.zip`; current evidence, the baseline and targets remain intact.
Successive rounds can accumulate full-output counterexamples using `--previous-round`. Source/example identities, evaluator, fixed priors and musical policies must agree; only learned weights may differ. Incompatible histories are rejected and duplicate inequalities removed. Later fitting cannot silently forget earlier failed complete outputs. No target-prefix update is used.
The search is a local action policy, not a global complete-phrase optimizer. Dedicated suspension/blues constructors and two-sided phrase revision are not present in this replacement; validators remain. Source influence and the short realized home theme are limited models of melodic identity. A catalog containing thousands of paths does not mean all those paths currently compete as complete musical ideas. No new held-out source-standard evaluation set was added. Existing synthetic transposition/meter/harmony tests do not establish generalization to an unseen standard or subjective musical quality.
A fresh ordinary build of that proposal completed under `learned-stage`, using four workers and no target arguments. It passed technical checks but failed the exact gate in all four cases:
The earliest demonstrated remaining failure is selection: legal target-compatible actions lose even at the opening, and later action choices collapse toward a few favored families. The next justified investigation is whether a shared context model can distinguish those conflicting choices over complete phrases, before adding more rules, expanding the beam, or running another long fit. The finite trial here does not settle that question. There is no automatic fallback to reference notes or previous output.
| Learned output | Generated / target attacks | Exact matching attacks | Exact result |
|---|---:|---:|---|
| All of Me acoustic | 214 / 137 | 0 | Fail |
| All of Me electric | 244 / 141 | 0 | Fail |
| Autumn Leaves acoustic | 315 / 121 | 0 | Fail |
| Autumn Leaves electric | 280 / 115 | 0 | Fail |
Technical completion, exact target equality and listener preference are separate. No ear-based audition is claimed. The release remains blocked unless all four unrestricted exported comparisons pass; a valid or stylistically plausible mismatch is not substituted for that requirement.
The [learned-model comparison](../../build/solo_reference_comparison/structured/learned-comparison/index.html) contains the complete exports, consistent auditions and exact differences. All 32 saved learned-candidate scores and four selected event/TAB streams passed independent rechecking. All eight complete non-solo role documents remained byte-canonically identical to the default stage despite the changed solo weights (`full-song-role-independence.json`). No inference about musical preference follows from that technical result.
The second fit accumulated **64 complete-output inequalities** from both ordinary builds. It found acoustic `space/after_arrival = -37` and electric `duration/1_4 = 19`. Both acoustic targets are still excluded at the first retrieval cutoff. The two electric openings survive that check, which proves neither full retention nor reproduction. This proposal was not staged or published, and no third full rollout is claimed. The result demonstrates how a sparse fit can separate sampled full outputs through coarse rhythmic statistics without recovering the ordered melody. Repeating the same fit or widening the beam is not a demonstrated remedy. Evidence is in `round-2/training.json`.
Learning explicitly depends on the authored examples. Runtime target-free is **not training independence**. These repeatedly studied examples are not held-out data. Transfer to separate standards has not been demonstrated here and is not substituted for exact reproduction.
## Validation
Commands use `build/build_common/python/bin/python3 build/build_tools/python_runner.py build/build_common/modulus`; [commands.md](commands.md) records arguments.
- `-m unittest discover -s tests -p 'test_*.py'`: **458 run: 456 passed, two optional solver skips, 1,139.453 seconds**. A preceding run had one `N.C.` lookahead error; it was repaired before the full rerun.
- With `PYTHONPATH=build/development_packages`, `-m unittest tests.test_joint_actions tests.test_structured_selection`: **35 passed in 24.256 seconds** (`successor-cache-tests.log`). This covers later exact shortcuts, solver changes, accumulated-round compatibility, strict diagnostic matching and incoming-state cache invalidation. The full suite was not rerun after every performance edit.
- `-m audition.dependency_check --output .../dependency-cached`: passed all six roles alone/cold, reversed/warm, two-process/cold, with invalid targets present/absent, and after an operational electric-only register change. All case hashes match the preceding `dependency-final` and `dependency` runs. Actual reads are recorded.
- Full `-m generator ... --check --composition-jobs 2 --export-solo-xml`: passed for the default stage. The separate learned-data run with `--composition-jobs 4` also passed. Actual read traces contain no reference or other part's concrete output.
- `-m audition.solo_compare ... --require-exact`: failed all four in **both** full builds, preventing a success claim.
- `structured/verify_completed.py`, through the Python wrapper: passed full selected-event, TAB, tie and canonical-score checks for both stages (64 completed candidates total).
- `git diff --check`: passed at the latest review.
The dependency exercise uses a synthetic one-bar source and real catalogs, not four complete-song cold/warm repetitions. [dependency_proof.md](dependency_proof.md) gives hashes and scope.
## Performance and cleanup
Hardware reports Intel Core i7-1355U, 12 logical CPUs, Linux 7.2.3 x86-64. The two-worker default stage took **4,832.443 seconds**: 4,817.592 in performance generation, 1.418 playback compilation and 12.655 engraving. Solo role times were 2,126.676 / 2,711.550 seconds for All of Me and 1,958.730 / 2,101.732 for Autumn Leaves. Other roles took about 0.2–0.5 seconds each. Tests/diagnostics ran concurrently; this is not an isolated benchmark.
This is a substantial regression from the prior one-action policy. Wider comparison is not a generation speedup. A 20-second live sample found retrieval, state advancement and frontier scoring dominant; engraving is not the bottleneck.
Pure-operation caching, incremental evaluation, short-circuit theme equality and direct retrieval costs remove repeated work. All eight four-bar candidates and every frontier were identical across 227.751-, 126.421- and 92.633-second runs with different concurrent loads; these do **not** establish a controlled whole-build speedup. A paired 9,853-action microbenchmark measured median 0.274 seconds with full feature reports versus 0.125 with direct costs, every cost equal. That claim is limited to warm-cache retrieval.
Successor-ranking memoization also preserves all eight candidates and every frontier. Its four-bar run took 154.177 seconds while the four learned workers ran, with 90 cache hits and 645 misses. The different load prohibits interpreting that elapsed time as a cache speedup or slowdown. The cache distinguishes catalog identity, the three incoming pitches, release time, sustained/rest context, pending obligations, weights and budget; complete home-aware evaluation still occurs per history. It removes repeated ranking work without changing search breadth.
The learned four-worker full build took **2,007.666 seconds**, including 1,994 seconds of performance generation and approximately 13 seconds of engraving. Solo role times were 1,708.084 / 1,920.536 seconds for All of Me and 1,989.394 / 1,945.428 for Autumn Leaves. Its music, weights, optimizations, worker count and concurrent load differ from the default run. The shorter wall time cannot be attributed to parallelism alone. Cold iteration remains expensive; no arbitrary performance target has been met or promised.
Default-build workers started before the final performance fixes. `frozen-inputs.json` and `post-freeze-repair.json` preserve that distinction. The no-chord guard does not apply to these sources; equality and export checks cover the shortcuts. The learned build has its own `learned-frozen-inputs.json` and predates only the final successor-cache optimization, recorded in `learned-post-freeze-repair.json`. Neither full run is presented as a cold benchmark of every final code byte.
Incomplete task-local stages and superseded caches totaling about 12.8 MB were removed; `cleanup.json` records paths. References, sources, baseline, completed comparisons and useful verified caches remain. Temporary work stays in the consolidated ignored build directory.
## Remaining boundary
The selector repair and learning mechanism are implemented. **The exact musical objective remains unmet.** No learned parameters or staged assets have been promoted. Independent parts, harmonic/physical safeguards and runtime representation remain intact.
The next bottleneck is specific: complete-phrase discrimination and early retrieval disagree, and the current event features permit degenerate rhythmic policies to beat the examples. More beam width, more unconstrained vocabulary, another prefix fit or hidden target retrieval would not establish the requested solution. Any next model change must face accumulated complete ordinary counterexamples and the four full-output target-free gate.
There is also a narrower theme-construction limitation: `related()` transposes or inverts the entire realized opening through strictly mapped pitches. It does not adapt the opening's expressive contracts. A legal chromatic/color-bearing home therefore need not have a legal generated return under this constructor. All four default outputs selected zero returns/answers. This is a construction/representation limitation, not evidence that the scorer has learned to avoid unmusical returns. Full operation counters and winner/runner-up contributions are in `structured/decisions.md`.
See [active_path_audit.md](active_path_audit.md), [data_migration.md](data_migration.md), [evaluation_contract.md](evaluation_contract.md), and [musical_capability_matrix.md](musical_capability_matrix.md).
+9 -4
View File
@@ -3,19 +3,24 @@
| Behavior | Current status |
|---|---|
| Ordinary scale motion, rests and held chord tones | Enumerable and validated |
| Complete scalar/third-stride groups | Reachable; exact scale indices, whole rhythm groups |
| Heard-theme return and inverted answer | Reachable from actual realization; no required count |
| Complete scalar/scale-third groups | Reachable; exact scale indices, whole rhythm groups |
| Actual chord arpeggios | Successive written basic chord members, distinct from scale thirds |
| Heard-theme return and inverted answer | Constructors and synthetic reachability retained; a long expressive home may have no legal literal adaptation. No required count |
| Approaches, enclosures, prepared chromatic connections | Reachable joint actions; target, time and register validated |
| Anticipation with hold or deliberate reattack | Reachable and separately validated |
| Stable/resolving colors and anticipated colors | Reachable; complete sounding exposure checked |
| Delayed semitone resolution | Pending across actions; exact target and bounded deadline required |
| Prepared suspension and named blues inflection | Validators retained; no dedicated new action constructor yet |
| Optional full head quotation | No new constructor; source influence currently uses ordered interval relationships and rhythm evidence |
| Two-sided revision / phrase-level destination lookahead | Not implemented by the local action policy |
| Multiple complete histories | Active bounded common-time search; controlled locally-cheaper-opening counterfactual passes |
| Home identity | Completed opening candidate retained separately from recent-note retrieval context |
| Two-sided join revision | No edit/rewrite pass in this selector repair |
| All six strings, frets 1–12, no opens | Shared physical domain and whole-line fingering |
| Source-only independent accompaniment | Existing canonical sparse comping retained |
| Exact construction coverage for all four targets | Demonstrated by constrained derivation plus final harmonic validation; not a production winner |
| Exact unrestricted reproduction of all four targets | Requires the separate full-export gate; a fit/derivation alone is insufficient |
| Exact unrestricted reproduction of all four targets | Not achieved; all four lose complete-duration-compatible openings at the first successor cutoff, and the full default exports fail exact comparison |
| Shared development learning | Complete-output constraints, bounded integer coefficients, accumulated compatible counterexamples and limited contextual-rule schema; a first four-example fit separated saved negatives but did not establish reproduction |
| Training-independent knowledge | Not claimed: approved fitting/induction depends explicitly on the examples |
A complete route is indivisible, but many independent single attacks remain available. This flexibility removes former coverage restrictions without proving melodic coherence. Source relationships are richer than an unordered interval set but remain a limited identity model. Register and fingering are separate; TAB legality is not biomechanical certification.
+2 -2
View File
@@ -24,9 +24,9 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
by_id={n.event_id:n for n in placed[1]}
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'joint_actions','version':policy.DEFAULT.composition_version,
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'complete_phrase_frontiers','version':policy.DEFAULT.composition_version,
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':dataclasses.asdict(s.evaluation),'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
validate(result)
return result
+181 -56
View File
@@ -1,13 +1,16 @@
"""Bounded search of joint musical actions; one normal production path.
Selection compares complete actions by cost per quarter. Actions are indivisible;
cross-bar paths and resolution contracts never restart at search boundaries.
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
import dataclasses
from fractions import Fraction as F
import time
import heapq
import itertools
import functools
from generator import vocabulary
from generator.solo import types, groups, operators, selection, sequences, scoring
@@ -26,6 +29,14 @@ class State:
tie: tuple = ()
pending: tuple = ()
alternatives: tuple = ()
actions: tuple = ()
notes: tuple = ()
full_evaluation: object = None
@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()):
@@ -33,32 +44,66 @@ class Builder:
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 candidates(self,at,state):
candidates=self.operators.at(at)
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')
evaluated=[]
for a in candidates:
if state.pending and self.advance(state,None,a) is None:continue
cost=self.selector.cost(a,state.recent,state.home,state.last_kind)
evaluated.append((cost,a))
# Rank complete actions by cost per quarter before bounded repair.
# Accumulated prefix totals are diagnostic, not a global search objective.
retained=sequences.diverse(evaluated,
lambda v:(v[0],v[1].key),
lambda v:(v[1].kind,v[1].end-v[1].start),self.rules['per_state'])
# 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 advance(self,state,evaluation,action):
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 None
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)
@@ -69,55 +114,139 @@ class Builder:
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:];home=state.home
if not home and len(recent)>=4:
proposed=recent[:4]
if proposed[-1].end-proposed[0].start<=8:home=proposed
return State(state.total+(evaluation.total if evaluation else 0),recent,home,action.kind,state,action,evaluation,(),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 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)))
def compose(self):
self.decisions=[];began=time.monotonic();backtracks=0
# Compare full actions by cost per exact quarter, with a bounded repair
# frontier. This is a local policy, not a claim of globally optimal melody.
stack=[(self.start,State(),None)];rankings={}
while stack:
at,state,iterator=stack[-1]
if at==self.end:final=state;break
if iterator is None:
options=sorted(self.candidates(at,state),key=lambda v:(v[0].total,v[1].key))
rankings[id(state)]=tuple({'kind':a.kind,'start':str(a.start),'end':str(a.end),
'notes':[(str(n.start),str(n.end),n.pitch.number()) for n in a.notes],
'evaluation':dataclasses.asdict(e)} for e,a in options[:4])
iterator=iter(options);stack[-1]=(at,state,iterator)
for evaluation,action in iterator:
next_state=self.advance(state,evaluation,action)
if next_state is not None:
next_state=dataclasses.replace(next_state,alternatives=rankings[id(state)])
stack.append((action.end,next_state,None));break
else:
stack.pop();backtracks+=1
if backtracks>self.rules['backtracks']:raise SearchExhausted(f'at {at}: action repair budget exhausted')
else:raise SearchExhausted('no complete action path')
self.timings['backtracks']=backtracks
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()
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
while state.action:path.append(state);state=state.parent
path.reverse();phrases=[];pending=[]
self.selected_actions=tuple(s.action for s in path)
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,'alternatives':state.alternatives})
# Reporting groups the selected thought; it does not insert events.
held=bool(action.notes and action.notes[-1].end-action.notes[-1].start>=2)
breath=not action.notes and action.end-action.start>=1
if held or breath or action.end==self.end:
'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):
@@ -132,11 +261,7 @@ class Builder:
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)
raw=Counter();contributions=Counter();weights={}
for s in states:
raw.update(s.evaluation.raw);contributions.update(s.evaluation.contributions);weights.update(s.evaluation.weights)
evaluation=scoring.Evaluation(selection.VERSION,True,dict(raw),weights,dict(contributions),sum(s.evaluation.total for s in states),
{'span':str(end-start),'attacks':len(ns),'ticks_per_quarter':12})
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,
+9 -5
View File
@@ -44,6 +44,10 @@ def chromatic(target,delta):
def eligible_color(rule,span,harmony):
# An explicit no-chord span (or missing harmony) has no optional colors.
# In particular, looking ahead for an anticipation must not try to parse
# the display label N.C. as a written chord.
if not span or not span.scale or not span.chord:return False
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span)
cache=harmony.color_eligibility
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
@@ -91,7 +95,7 @@ def validate(notes,contracts,harmony):
arrival=c.boundary if c.boundary is not None else target.start
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
planned_color=any(a.kind=='color' and a.target==target and a.events==(target,) for a in contracts)
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end)):return {},c.id+': invalid destination hold'
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in harmony.permitted(arrival,target.end)):return {},c.id+': invalid destination hold'
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1))))
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps'
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F())
@@ -101,7 +105,7 @@ def validate(notes,contracts,harmony):
cursor=n.end+(gaps[i] if i<len(gaps) else 0)
for index in rule.get('mapped_indices',[]):
n=c.events[index]
if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
elif c.kind=='delayed_approach':
@@ -110,7 +114,7 @@ def validate(notes,contracts,harmony):
n,=c.events
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach'
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register'
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid delayed destination'
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in harmony.permitted(target.start,target.end):return {},c.id+': invalid delayed destination'
elif c.kind=='color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule:return {},c.id+': unknown color policy'
@@ -140,7 +144,7 @@ def validate(notes,contracts,harmony):
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'
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside mapped collections'
elif c.kind=='anticipated_color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
@@ -158,7 +162,7 @@ def validate(notes,contracts,harmony):
if c.boundary is None or not n.start<c.boundary or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation'
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack'
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,target.end):return {},c.id+': invalid future destination'
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in harmony.permitted(c.boundary,target.end):return {},c.id+': invalid future destination'
elif c.kind=='blues':
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
+6
View File
@@ -57,6 +57,7 @@ class Harmony:
self._at = {}
self._segments = {}
self._members = {}
self._permitted = {}
self.evaluations = {}
self.color_eligibility = {}
def at(self, time):
@@ -74,6 +75,11 @@ class Harmony:
spans = [s for s in self.spans if s.start < end and s.end > start]
self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
return self._members[key]
def permitted(self,start,end):
key=(start,end)
if key not in self._permitted:
self._permitted[key]=generator.solo.types.permitted(self.choices,start,end)
return self._permitted[key]
def function(self, number, span):
pitch = number % 12
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
+51 -21
View File
@@ -33,8 +33,9 @@ class Operators:
def __init__(self,harmony,analysis,end,start=F(),enabled=True,sequences=None,rhythms=None):
self.harmony=harmony;self.analysis=analysis;self.start=start;self.end=end;self.enabled=enabled
self.rules=vocabulary.data('solo')['operators'];self.cache={};self.pitch_cache={};self.coverage=Counter()
self.ordinary_cache={}
self.ordinary_cache={};self.validity_cache={}
self.sequences=sequences
self.licensed=frozenset(s.steps for values in sequences.paths.values() for s in values) if sequences else None
self.patterns=[]
if rhythms is not None:
library=rhythm.Rhythms(rhythms,analysis.tempo)
@@ -57,6 +58,7 @@ class Operators:
if self.harmony.profile.permits((p:=types.scale_pitch(span.scale,i)).number()))
return self.pitch_cache[span.scale]
@functools.lru_cache(maxsize=32768)
def note(self,start,end,pitch,role):
# Identity follows exact musical content, not an output filename or hash.
return types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
@@ -68,7 +70,7 @@ class Operators:
return tuple(p for octave in range(1,6)
if self.harmony.profile.permits((p:=dataclasses.replace(spelling,octave=octave)).number()))
def _accepted(self,action):
def _accepted(self,action,validate=True):
self.coverage['proposed:'+action.kind]+=1
if action.end>self.end or action.end<=action.start:
self.coverage['rejected_by_allocation:'+action.kind]+=1;return False
@@ -76,40 +78,51 @@ class Operators:
self.coverage['rejected_by_physical_domain:'+action.kind]+=1;return False
if not groups.valid(tuple((n.start-action.start,n.end-action.start) for n in action.notes)):
self.coverage['rejected_by_rhythm:'+action.kind]+=1;return False
self.coverage['feasible:'+action.kind]+=1
return self.is_valid(action) if validate else True
def is_valid(self,action):
key=action.key
if key in self.validity_cache:return self.validity_cache[key]
if action.contracts:
valid=self.harmony.evaluate(action.notes,expressions=action.contracts,complete=False,until=action.end).valid
else:
valid=True
for n in action.notes:
key=(n.start,n.end,n.pitch.number())
if key not in self.ordinary_cache:self.ordinary_cache[key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
if not self.ordinary_cache[key]:valid=False;break
if not valid:
self.coverage['rejected_by_rule:'+action.kind]+=1
return False
self.coverage['valid:'+action.kind]+=1
return True
note_key=(n.start,n.end,n.pitch.number())
if note_key not in self.ordinary_cache:self.ordinary_cache[note_key]=self.harmony.evaluate((n,),complete=True,until=n.end).valid
if not self.ordinary_cache[note_key]:valid=False;break
self.coverage[('valid:' if valid else 'rejected_by_rule:')+action.kind]+=1
self.validity_cache[key]=valid
if len(self.validity_cache)>8192:self.validity_cache.pop(next(iter(self.validity_cache)))
return valid
@functools.lru_cache(maxsize=8192)
def _color(self,n,span):
result=[]
for rule in vocabulary.data('harmony')['colors']:
if rule.get('resolve') or not expressions.eligible_color(rule,span,self.harmony):continue
expected=expressions.degree_pitch(expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],n.pitch.number())
if expected.number()!=n.pitch.number():continue
c=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(n,),n,span.chord)
yield Action(n.start,n.end,(n,),'color',(c,),rule['id'])
result.append(Action(n.start,n.end,(n,),'color',(c,),rule['id']))
return tuple(result)
def at(self,time,eligible=None,first=None):
def proposals(self,time):
return self.at(time,validate=False)
def at(self,time,eligible=None,first=None,*,validate=True):
"""Unpruned legal actions. Incoming-state ranking is a separate operation."""
if eligible is None and first is None and time in self.cache:return self.cache[time]
if eligible is None and first is None and (time,validate) in self.cache:return self.cache[(time,validate)]
span=self.harmony.at(time);values=[]
if not span or not span.scale:
end=min(self.end,span.end if span else self.end)
result=(Action(time,end,(),'space'),)
if eligible is not None:return tuple(a for a in result if eligible(a))
self.cache[time]=result;return result
self.cache[(time,validate)]=result;return result
def add(action):
if eligible is not None and not eligible(action):return
if self._accepted(action):values.append(action)
if self._accepted(action,validate=validate):values.append(action)
def entry(a,b,pitch):
return first is None or first==(a,b,pitch.number())
durations=tuple(map(F,self.rules['durations']))
@@ -122,7 +135,7 @@ class Operators:
for pitch in self.pitches(time):
if not entry(time,time+duration,pitch):continue
n=self.note(time,time+duration,pitch,'motion')
if pitch.number()%12 in types.permitted(self.harmony.choices,time,n.end):
if pitch.number()%12 in self.harmony.permitted(time,n.end):
add(Action(time,n.end,(n,),'tone'))
if self.enabled:
delayed=vocabulary.data('lead_ins').get('delayed_approach')
@@ -139,7 +152,7 @@ class Operators:
for step in delayed['steps']:
n=self.note(time,time+F(delayed['max_note']),expressions.chromatic(p,-step),'delayed_approach')
if not entry(n.start,n.end,n.pitch):continue
if n.pitch.number()%12 in types.permitted(self.harmony.choices,n.start,n.end):continue
if n.pitch.number()%12 in self.harmony.permitted(n.start,n.end):continue
c=types.ExpressionContract(n.event_id+':delayed','delayed_approach',delayed['id'],(n,),target,region.chord)
add(Action(time,n.end,(n,),'delayed_approach',(c,),delayed['id']))
# Optional colors retain functional degree identity independently of
@@ -254,7 +267,7 @@ class Operators:
if first is not None and (time+pattern[0][0],time+pattern[0][1])!=first[:2]:continue
for stride in (-2,-1,1,2):
# Catalog licenses relative paths, but never sets family probability.
if self.sequences and not any(s.steps==(stride,)*(len(pattern)-1) for s in self.sequences.paths.get(len(pattern),())):continue
if self.licensed is not None and (stride,)*(len(pattern)-1) not in self.licensed:continue
for initial in self.pitches(time):
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
cursor=time;p=initial;notes=[]
@@ -265,8 +278,24 @@ class Operators:
if index:
try:p=types.scale_pitch(region.scale,types.scale_index(region.scale,p.number())+stride)
except ValueError:break
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'arpeggio'))
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'arpeggio',source=f'{name}:degree_stride:{stride}'))
notes.append(self.note(cursor,time+b,p,'scalar' if abs(stride)==1 else 'scale_thirds'))
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'scalar' if abs(stride)==1 else 'scale_thirds',source=f'{name}:degree_stride:{stride}'))
# A real arpeggio walks successive members of the WRITTEN chord, not
# alternate degrees of its selected seven-note collection.
for direction in (-1,1):
for initial in self.pitches(time):
if initial.number()%12 not in span.roles.basic:continue
if not entry(time+pattern[0][0],time+pattern[0][1],initial):continue
p=initial;notes=[]
for index,(a,b) in enumerate(pattern):
region=self.harmony.at(time+a)
if not region or not region.scale:break
if index:
choices=[q for q in self.pitches(time+a) if q.number()%12 in region.roles.basic and (q.number()-p.number())*direction>0]
if not choices:break
p=min(choices,key=lambda q:abs(q.number()-p.number()))
notes.append(self.note(time+a,time+b,p,'arpeggio'))
if len(notes)==len(pattern):add(Action(time,time+length,tuple(notes),'arpeggio',source=f'{name}:written_chord:{direction}'))
# Equivalent musical actions need one stable derivation, not extra votes.
unique={}
for a in values:unique.setdefault(a.key,a)
@@ -274,9 +303,10 @@ class Operators:
if eligible is not None or first is not None:return result
if len(self.cache)>=32:self.cache.pop(next(iter(self.cache)))
if len(self.ordinary_cache)>8192:self.ordinary_cache.clear()
self.cache[time]=result
self.cache[(time,validate)]=result
return result
@functools.lru_cache(maxsize=512)
def related(self,time,home,kind):
"""Construct a heard-theme return or answer as a whole group, never a prefix."""
if len(home)<3:return ()
+1 -1
View File
@@ -4,7 +4,7 @@ import dataclasses, hashlib
class Policy:
long_note: float = 2.
stopping_note: float = 1.
composition_version: str = 'joint-actions-1'
composition_version: str = 'phrase-frontiers-2'
DEFAULT=Policy()
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
+260 -48
View File
@@ -1,25 +1,42 @@
"""Interpretable shared action ranking; no song identity or absolute bar features.
"""Canonical sounded-event evaluation, shared by inference and training.
Complete actions compete by exact cost per quarter, with bounded backtracking.
The final report exposes the exact same contributions used by search.
Role-specific learned integer coefficients can augment these general priors.
Constructor names are not musical evidence. Complete equal-span candidates
are compared using this record; local retrieval is explicitly only a heuristic.
"""
from collections import Counter
from fractions import Fraction as F
import dataclasses
import functools
from types import MappingProxyType
from generator import vocabulary, harmony as chords, model
from generator.solo import expressions, scoring, types
VERSION='action-selection-1'
VERSION='phrase-selection-2'
DEFAULT={'excessive_leap':12,'register_distance':1,'oscillation':6,'repeated_attack':3,
'unprepared_space':12,'rest_after_rest':24,'source_relation':-2,'theme_relation':-4,
'unprepared_space':12,'source_relation':-2,'theme_relation':-4,
'sustained_arrival':-1,'directed_motion':-1}
CONDITIONS={
'quality':frozenset(('major','minor','dominant','half_diminished','other')),
'degree':frozenset(map(str,range(12))),
'motion':frozenset(map(str,range(-12,13))),
'metric':frozenset(('barline','beat','eighth','subdivision')),
'duration':frozenset(str(F(i,4)).replace('/','_') for i in range(1,17))|{'1_3','2_3'},
'source_pair':frozenset(('yes','no')),
}
def rule_conditions(key):
if not key.startswith('joint/'):return None
pairs=tuple(p.split('=',1) for p in key[6:].split('&'))
if not 2<=len(pairs)<=3 or any(len(p)!=2 for p in pairs):raise ValueError('Expected two or three contextual conditions')
if len({p[0] for p in pairs})!=len(pairs) or list(pairs)!=sorted(pairs):raise ValueError('Noncanonical contextual rule')
if any(k not in CONDITIONS or v not in CONDITIONS[k] for k,v in pairs):raise ValueError('Forbidden contextual rule condition')
return dict(pairs)
def validate_weights(weights):
durations={str(F(i,4)).replace('/','_') for i in range(1,17)}|{'1_3','2_3'}
kinds={'tone','color','color_resolution','anticipation','anticipated_color','approach','enclosure','connection','delayed_approach','space','rest','scalar','arpeggio','return','answer'}
allowed={f'kind/{k}' for k in kinds}|{f'duration/{d}' for d in durations}
allowed={f'duration/{d}' for d in durations}
allowed|={f'harmony/{q}/{p}/{d}' for q in ('major','minor','dominant','half_diminished','other') for p in range(12) for d in durations}
allowed|={f'motion/{p}/{d}' for p in range(-12,13) for d in durations}
allowed|={f'metric/{m}/{d}' for m in ('barline','beat','eighth','subdivision') for d in durations}
@@ -30,9 +47,10 @@ def validate_weights(weights):
allowed|={f'continuation/{a}/{b}' for a in ('up','down','repeat') for b in ('up','down','repeat')}
allowed|={'source/rhythm_match','theme/realized_identity',*(f'space/{s}' for s in ('unharmonized','breath','after_arrival','delayed_entrance'))}
for key,value in weights.items():
if key not in allowed or not isinstance(value,int) or isinstance(value,bool):
joint=rule_conditions(key)
if (key not in allowed and joint is None) or not isinstance(value,int) or isinstance(value,bool) or abs(value)>1024:
raise ValueError('Unsupported fitted feature (only shared musical categories): '+key)
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-' for c in key):
if any(c not in 'abcdefghijklmnopqrstuvwxyz_0123456789/-=&' for c in key):
raise ValueError('Noncanonical fitted feature: '+key)
return dict(weights)
@@ -43,7 +61,21 @@ def signature(notes):
return tuple((n.start-first.start,n.end-n.start,n.pitch.number()-first.pitch.number()) for n in notes)
def matches_signature(notes,expected):
"""Exact signature equality, stopping at the first differing feature."""
if len(notes)!=len(expected):return False
if not notes:return True
first=notes[0];pitch=first.pitch.number()
return all(n.pitch.number()-pitch==relative and n.end-n.start==duration and n.start-first.start==onset
for n,(onset,duration,relative) in zip(notes,expected))
class Selector:
@property
def weights(self):return self._weights
@weights.setter
def weights(self,value):self._weights=MappingProxyType(validate_weights(value))
def __init__(self,harmony,analysis,center,weights=None):
self.harmony=harmony;self.analysis=analysis;self.center=center
self.weights=validate_weights(weights if weights is not None else vocabulary.role().get('selection_weights',{}))
@@ -52,11 +84,29 @@ class Selector:
for a,b,c in zip(analysis.melody,analysis.melody[1:],analysis.melody[2:]))
self.source_rhythms=Counter(n.end-n.start for n in analysis.melody)
self.static={}
self.home_signatures={}
self.cost_static={};self.cost_weights=None
def contexts(self,notes,recent=()):
history=list(recent[-2:])
for n in notes:
region=self.harmony.at(n.start)
if region and region.chord:
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
delta=n.pitch.number()-history[-1].pitch.number() if history else None
previous=history[-1].pitch.number()-history[-2].pitch.number() if len(history)>1 else None
metric='barline' if n.start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if n.start%1==0 else 'eighth' if n.start%1==F(1,2) else 'subdivision'
context={'quality':expressions.quality(chord),'degree':str((n.pitch.number()-root)%12),
'metric':metric,'duration':str(n.end-n.start).replace('/','_'),
'source_pair':'yes' if (previous,delta) in self.source_pairs else 'no'}
if delta is not None:context['motion']=str(max(-12,min(12,delta)))
yield context,int((n.end-n.start)*12)
history.append(n)
def base(self,action):
if action.key in self.static:return self.static[action.key]
values=Counter();prior=Counter();ticks=int((action.end-action.start)*12)
values['kind/'+action.kind]=ticks
for contract in action.contracts:
if contract.target.start>=action.end and contract.events:
# Pending destinations must be visible to selection. Identical heard
@@ -65,41 +115,48 @@ class Selector:
values[f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}']+=ticks
values['obligation/delay/'+str(target.start-last.start).replace('/','_')]+=ticks
values['obligation/hold/'+str(target.end-target.start).replace('/','_')]+=ticks
if not action.notes:
values['space/'+('unharmonized' if action.kind=='space' else 'breath')]=ticks
for n in action.notes:
duration=n.end-n.start;length=int(duration*12)
label=str(duration).replace('/','_')
values['duration/'+label]+=length
beat=(n.start%1)
metric='barline' if n.start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if beat==0 else 'eighth' if beat==F(1,2) else 'subdivision'
values['metric/'+metric+'/'+label]+=length
band=max(-4,min(4,(n.pitch.number()-self.center+6)//12))
values[f'register/{band}']+=length
region=self.harmony.at(n.start)
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
degree=(n.pitch.number()-root)%12
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
prior['register_distance']+=length*max(0,abs(n.pitch.number()-self.center)-7)
if duration>=2 and n.pitch.number()%12 in self.harmony.members(n.start,n.end):
prior['sustained_arrival']+=length
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
f,p=self.note_base(n.start,n.end,n.pitch.number())
values.update(f);prior.update(p)
self.static[action.key]=(values,prior)
if len(self.static)>8192:self.static.pop(next(iter(self.static)))
return values,prior
@functools.lru_cache(maxsize=32768)
def note_base(self,start,end,number):
values=Counter();prior=Counter()
duration=end-start;length=int(duration*12)
label=str(duration).replace('/','_')
values['duration/'+label]+=length
beat=(start%1)
metric='barline' if start+self.analysis.body_offset in self.analysis.boundaries else 'beat' if beat==0 else 'eighth' if beat==F(1,2) else 'subdivision'
values['metric/'+metric+'/'+label]+=length
band=max(-4,min(4,(number-self.center+6)//12))
values[f'register/{band}']+=length
region=self.harmony.at(start)
if not region or not region.chord:return values,prior
chord=expressions.parsed_chord(region.chord)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))
degree=(number-root)%12
values[f'harmony/{expressions.quality(chord)}/{degree}/{label}']+=length
prior['register_distance']+=length*max(0,abs(number-self.center)-7)
if duration>=2 and number%12 in self.harmony.members(start,end):
prior['sustained_arrival']+=length
values['source/rhythm_match']+=length if duration in self.source_rhythms else 0
return values,prior
def features(self,action,recent=(),home=(),previous_kind=''):
if any(n.continuation for n in action.notes):action=dataclasses.replace(action,notes=types.sounding_notes(action.notes))
static,base=self.base(action);values=Counter(static);prior=Counter(base)
if not action.notes:
if action.kind=='space':return values,prior
ticks=int((action.end-action.start)*12)
if previous_kind in ('rest','space'):prior['rest_after_rest']+=ticks
elif recent and recent[-1].end-recent[-1].start>=F(3,2):values['space/after_arrival']+=ticks
elif recent:prior['unprepared_space']+=ticks
else:values['space/delayed_entrance']+=ticks
return values,prior
# Silence is measured as intervals in the sounded stream, independent of
# the number of rest actions used to encode it. Missing harmony is distinct.
cursor=action.start;before=recent[-1] if recent else None
for n in (*action.notes,None):
until=n.start if n else action.end
if until>cursor:
f,p=self.space_features(cursor,until,None if before is None else before.end-before.start>=F(3,2))
values.update(f);prior.update(p)
if n:cursor=n.end;before=n
previous=recent[-1] if recent else None
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(recent,recent[1:])]
for n in action.notes:
@@ -121,24 +178,72 @@ class Selector:
if 0<abs(delta)<=2 and intervals and delta*intervals[-1]>0:prior['directed_motion']+=length
intervals.append(delta)
previous=n
if action.kind=='return' and len(home)>=3:
if signature(action.notes)==signature(home):
recently_heard=any(signature(recent[i:i+len(home)])==signature(home)
for i in range(max(0,len(recent)-len(home)+1)))
if not recently_heard:prior['theme_relation']+=int((action.end-action.start)*12)
if recent and home:
if len(home)>=3:
key=id(home)
if key not in self.home_signatures:
self.home_signatures[key]=(home,signature(home))
if len(self.home_signatures)>32:self.home_signatures.pop(next(iter(self.home_signatures)))
expected=self.home_signatures[key][1]
joined=(*recent,*action.notes)
count=min(len(home),len(joined))
if count>=3 and signature(joined[-count:])==signature(home[:count]):
values['theme/realized_identity']+=12
for i in range(max(0,len(recent)-len(home)+1),len(joined)-len(home)+1):
part=joined[i:i+len(home)]
if part[0].start>home[-1].end and matches_signature(part,expected):
values['theme/realized_identity']+=12
prior['theme_relation']+=12
joint=[(key,rule_conditions(key)) for key in self.weights if key.startswith('joint/')]
if joint:
for context,ticks in self.contexts(action.notes,recent):
for key,conditions in joint:
if all(context.get(k)==v for k,v in conditions.items()):values[key]+=ticks
return values,prior
def complete(self,actions,home=(),*,start=None,end=None):
"""The sole authoritative score. Ties/rests/partitions are canonicalized."""
from generator.solo.operators import Action
actions=tuple(actions)
first=start if start is not None else actions[0].start
last=end if end is not None else actions[-1].end
notes=types.sounding_notes(tuple(n for a in actions for n in a.notes))
contracts=tuple(dict.fromkeys(c for a in actions for c in a.contracts))
return self.evaluate(Action(first,last,notes,'sounded_phrase',contracts),home=home)
def extend(self,previous,old_notes,action,home=(),*,old_home=(),start=F(),contracts=()):
"""Exact sufficient-statistic update, checked against complete() in tests.
Reevaluate the seam and the tail containing any possible new home match.
Previously completed events are not rescored on every search expansion.
"""
from generator.solo.operators import Action
if previous is None or home!=old_home or any(n.continuation for n in action.notes):
notes=types.sounding_notes((*old_notes,*action.notes))
return self.evaluate(Action(start,action.end,notes,'sounded_phrase',contracts),home=home)
span=F(previous.denominators['span'])
raw=Counter({k:v*span for k,v in previous.raw.items() if not k.startswith('obligation/')})
index=max(0,len(old_notes)-max(3,len(home)+2))
tail=old_notes[index:];context=old_notes[max(0,index-3):index]
begin=tail[0].start if index else start
before=Action(begin,action.start,tail,'sounded_phrase')
after=Action(begin,action.end,(*tail,*action.notes),'sounded_phrase')
for sign,part in ((-1,before),(1,after)):
f,p=self.features(part,context,home)
for k,v in f.items():
if not k.startswith('obligation/'):raw[k]+=sign*v
for k,v in p.items():raw['prior/'+k]+=sign*v
obligations,_=self.base(Action(start,action.end,(),'sounded_phrase',contracts))
raw.update({k:v for k,v in obligations.items() if k.startswith('obligation/')})
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
duration=action.end-start
raw={k:F(v)/duration for k,v in raw.items() if v}
contributions={k:v*weights.get(k,0) for k,v in raw.items()}
return scoring.Evaluation(VERSION,True,raw,weights,contributions,sum(contributions.values(),F()),
{'span':str(duration),'ticks_per_quarter':12,'attacks':len(old_notes)+len(action.notes)})
def evaluate(self,action,recent=(),home=(),previous_kind=''):
features,prior=self.features(action,recent,home,previous_kind)
contributions={'prior/'+k:v*DEFAULT[k] for k,v in prior.items() if v}
contributions.update({k:v*self.weights.get(k,0) for k,v in features.items() if v})
weights={'prior/'+k:v for k,v in DEFAULT.items()}|self.weights
raw={'prior/'+k:v for k,v in prior.items()}|dict(features)
raw={'prior/'+k:v for k,v in prior.items() if v}|{k:v for k,v in features.items() if v}
span=action.end-action.start
contributions={k:F(v)/span for k,v in contributions.items()}
raw={k:F(v)/span for k,v in raw.items()}
@@ -152,3 +257,110 @@ class Selector:
numerator=sum(v*DEFAULT[k] for k,v in prior.items())
numerator+=sum(v*self.weights.get(k,0) for k,v in features.items())
return F(numerator)/(action.end-action.start)
@functools.lru_cache(maxsize=32768)
def transition_cost(self,delta,preceding,earlier,length,label,contiguous):
token=max(-12,min(12,delta))
sign=lambda x:'up' if x>0 else 'down' if x<0 else 'repeat'
cost=length*self.weights.get(f'motion/{token}/{label}',0)
cost+=length*DEFAULT['excessive_leap']*max(0,abs(delta)-7)
if delta==0 and contiguous:cost+=length*DEFAULT['repeated_attack']
if preceding is not None:
cost+=length*self.weights.get(f'continuation/{sign(preceding)}/{sign(delta)}',0)
if (preceding,delta) in self.source_pairs:cost+=length*DEFAULT['source_relation']
if 0<abs(delta)<=2 and delta*preceding>0:cost+=length*DEFAULT['directed_motion']
if earlier is not None and earlier==-preceding==delta and delta:cost+=length*DEFAULT['oscillation']
return cost
@functools.lru_cache(maxsize=32768)
def seam_cost(self,prefix,context,contiguous):
def total(initial):
pitches=list(initial);value=0
for index,(number,length,label,touch) in enumerate(prefix):
if pitches:
value+=self.transition_cost(number-pitches[-1],pitches[-1]-pitches[-2] if len(pitches)>1 else None,
pitches[-2]-pitches[-3] if len(pitches)>2 else None,length,label,contiguous if index==0 and initial else touch)
pitches.append(number)
return value
return total(context)-total(())
@functools.lru_cache(maxsize=32768)
def unary_cost(self,start,end,number):
features,prior=self.note_base(start,end,number)
return sum(v*DEFAULT[k] for k,v in prior.items())+sum(v*self.weights.get(k,0) for k,v in features.items())
def intrinsic_cost(self,action):
"""Exact ordinary retrieval cost without constructing discarded reports.
Unary, transition, silence and pending-target contributions are identical
to features(action). Contextual learned conjunctions use the full path.
"""
notes=types.sounding_notes(action.notes) if any(n.continuation for n in action.notes) else action.notes
cost=0;history=[];cursor=action.start;settled=None;previous=None
for n in notes:
if n.start>cursor:cost+=self.gap_cost(cursor,n.start,settled)
number=n.pitch.number();length=int((n.end-n.start)*12);label=str(n.end-n.start).replace('/','_')
cost+=self.unary_cost(n.start,n.end,number)
if history:
cost+=self.transition_cost(number-history[-1],history[-1]-history[-2] if len(history)>1 else None,
history[-2]-history[-3] if len(history)>2 else None,length,label,n.start==previous.end)
history.append(number);previous=n;cursor=n.end;settled=n.end-n.start>=F(3,2)
if action.end>cursor:cost+=self.gap_cost(cursor,action.end,settled)
ticks=int((action.end-action.start)*12)
for contract in action.contracts:
if contract.target.start>=action.end and contract.events:
last=contract.events[-1];target=contract.target
cost+=ticks*self.weights.get(f'obligation/step/{max(-12,min(12,target.pitch.number()-last.pitch.number()))}',0)
cost+=ticks*self.weights.get('obligation/delay/'+str(target.start-last.start).replace('/','_'),0)
cost+=ticks*self.weights.get('obligation/hold/'+str(target.end-target.start).replace('/','_'),0)
return cost
def fast_cost(self,action,recent=(),context=None):
"""Unnormalized endpoint-bucket retrieval, with cached internal routes.
Only the first three transitions depend on incoming history. Final ranking
always uses complete(); this heuristic does not compare unequal spans.
"""
# Learned conjunctions include incoming context; do not use a shortcut
# that forgets it. This small fallback is explicit and profileable.
if any(n.continuation for n in action.notes) or any(k.startswith('joint/') for k in self.weights):
f,p=self.features(action,recent)
return sum(v*DEFAULT[k] for k,v in p.items())+sum(v*self.weights.get(k,0) for k,v in f.items())
if self.cost_weights is not self.weights:
self.cost_static.clear();self.transition_cost.cache_clear();self.seam_cost.cache_clear();self.gap_cost.cache_clear();self.unary_cost.cache_clear();self.cost_weights=self.weights
key=id(action)
if key not in self.cost_static:
value=self.intrinsic_cost(action)
prefix=tuple((n.pitch.number(),int((n.end-n.start)*12),str(n.end-n.start).replace('/','_'),
i>0 and action.notes[i-1].end==n.start) for i,n in enumerate(action.notes[:3]))
self.cost_static[key]=(action,value,prefix)
if len(self.cost_static)>32768:self.cost_static.pop(next(iter(self.cost_static)))
_,value,prefix=self.cost_static[key]
if context is None:context=(tuple(n.pitch.number() for n in recent[-3:]),recent[-1].end if recent else None)
if context[0] and prefix:value+=self.seam_cost(prefix,context[0],context[1]==action.notes[0].start)
gap_end=action.notes[0].start if action.notes else action.end
if recent and gap_end>action.start:
# Incoming sustained arrivals change the role of a leading rest, just
# as incoming pitches change the first intervals. Do not cache it as a
# fresh silent entrance in every action.
value+=self.gap_cost(action.start,gap_end,recent[-1].end-recent[-1].start>=F(3,2))-self.gap_cost(action.start,gap_end,None)
return value
@functools.lru_cache(maxsize=8192)
def space_features(self,start,end,settled):
values=Counter();prior=Counter();delayed=0
boundaries=sorted({start,end,*[s.start for s in self.harmony.spans if start<s.start<end],
*[s.end for s in self.harmony.spans if start<s.end<end]})
for left,right in zip(boundaries,boundaries[1:]):
ticks=int((right-left)*12);region=self.harmony.at(left)
label='unharmonized' if not region or not region.scale else 'delayed_entrance' if settled is None else 'after_arrival' if settled else 'breath'
values['space/'+label]+=ticks
if label=='breath':prior['unprepared_space']+=ticks
if label=='delayed_entrance':delayed+=ticks
prior['unprepared_space']+=max(0,delayed-12)
return values,prior
@functools.lru_cache(maxsize=8192)
def gap_cost(self,start,end,settled):
f,p=self.space_features(start,end,settled)
return sum(v*DEFAULT[k] for k,v in p.items())+sum(v*self.weights.get(k,0) for k,v in f.items())
+1 -1
View File
@@ -32,7 +32,7 @@ def _load(contents):
cells=result['rhythms']['cells']+result['solo']['cells']
if len({c['id'] for c in cells})!=len(cells):raise ValueError('Duplicate rhythm identity')
rules=result['solo']
if any(type(rules['operators'][k]) is not int or rules['operators'][k]<=0 for k in ('backtracks','per_state')):raise ValueError('Invalid deterministic search budget')
if any(type(rules['operators'][k]) is not int or rules['operators'][k]<=0 for k in ('histories','completed','per_state')):raise ValueError('Invalid deterministic search budget')
if any(F(d)<=0 for d in rules['operators']['durations']):raise ValueError('Invalid action duration')
for group in rules['groups']:
if not group['values'] or any(F(v.removeprefix('r:'))<=0 for v in group['values']):raise ValueError('Invalid rhythm group')
+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()