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