Files

256 lines
20 KiB
Python

"""Phase 4S: independently calibrated timing, fixed-event credit, isolated transfer."""
from pathlib import Path
import argparse
import hashlib
import json
import resource
import time
import numpy as np
import pandas as pd
import torch
import yaml
import phase4p
import phase4p_finalize
from phase4p_revalidate import DeterministicAPLSim, IsolatedReplay, door_inputs, make_drive
from phase4h_audit import verify_checksum
from phase4q import last_spike_trace, edge_elig, update_E, group_metrics, v2_trace, replay_metrics
from phase4r import save, digest, arrhash, curves, quick_integrity
def checks(cfg):
out=quick_integrity(cfg)
for name,key in [('R','phase4r_root'),('R_model','phase4r_model')]:
root=Path(cfg[key]);out[name]=verify_checksum(root,root/'checksums.sha256')
return out
def timing_test(rows,cfg,w,u):
x=pd.DataFrame(rows);x=x[(x.window_ms==w)&(x.US_ms==u)];details=[]
gate=cfg['timing_gate']
for label,g in x.groupby('label'):
m=float(g.matched.mean());r=float(g.random_expectation.mean())
ratio=m/r if r else None
n=int((g.ratio>=gate['per_seed_ratio_at_least']).sum())
details.append({'label':label,'mean_matched':m,'mean_random':r,'ratio':ratio,'qualifying_seeds':n,
'pass':bool(ratio is not None and ratio>=gate['mean_matched_random_ratio_each_odor'] and n>=gate['qualifying_seeds_each_odor'])})
return {'window_ms':w,'US_ms':u,'tau_ms':w/3,'odors':details,'pass':len(details)==2 and all(g['pass'] for g in details)}
def persist_run(exp,name,run):
frame=pd.DataFrame({'step':run['kc_event_step'],'neuron_idx':run['kc_event_node']})
frame.to_parquet(exp/'events'/f'{name}.parquet',index=False)
return arrhash(run['kc_event_step'],run['kc_event_node'])
def evidence_regression(cfg,exp,rt,sim):
rt.weights.copy_(sim.base);rows=[]
for spec in rt.cfg['reference_trials']:
ref=Path(cfg['phase4p_root'])/'phase4c-v2/reference'/spec['name'];ev=pd.read_parquet(ref/'spike-events.parquet')
expect={'step':ev.step.to_numpy(),'neuron_idx':ev.neuron_idx.to_numpy(),'counts':np.load(ref/'per-neuron-spike-counts.int32.npy'),
'v':np.load(ref/'final-voltage.float32.npy'),'g':np.load(ref/'final-g.float32.npy')}
run=rt.run(np.flatnonzero(rt.pops[spec['population']]),spec['rate_hz'],spec['seed'],spec['duration_ms'])
exact={k:arrhash(run[k])==arrhash(expect[k].astype(run[k].dtype)) for k in expect}
rows.append({'trial':spec['name'],'exact':exact,'pass':all(exact.values())})
save(exp/'regression/frozen-trials.json',{'rows':rows,'pass':all(x['pass'] for x in rows)})
return all(x['pass'] for x in rows)
def run(cfg,exp,model):
for d in ('protocol','calibration','heldout','formal','isolated','whole-brain','events','audit','regression','performance'):(exp/d).mkdir(parents=True,exist_ok=True)
if (exp/'protocol/preregistered.json').exists():raise RuntimeError('Run already exists: refusing overwrite')
pre=checks(cfg);save(exp/'audit/preflight.json',pre)
assert all(v['passed'] for v in pre.values())
ocfg,panel,vectors,selection,mapping=door_inputs();cal=panel[panel.split=='calibration'].iloc[:2]
assert set(cal.odor_id).isdisjoint({selection['ODOR_A']['odor_id'],selection['ODOR_B']['odor_id']})
prereg={'config':cfg,'calibration_odors':cal[['odor_id','odor_name']].to_dict('records'),
'created_before_simulation':True,'source_hashes':{p.name:digest(p) for p in model.glob('phase4s.*')},
'protocol_basis':'Phase4R onset response identified at 15-25ms; this informs predeclared finite grid, not formal A/B outcomes'}
save(exp/'protocol/preregistered.json',prereg)
pcfg=yaml.safe_load((Path(cfg['runtime_root'])/'phase4p.yaml').read_text());rt=phase4p.CanonicalRuntime(pcfg,Path(cfg['runtime_root']),exp)
sim=DeterministicAPLSim(rt);identity=sim.identity;assert identity.identity_group.value_counts().to_dict()==cfg['formal']['fixed_groups']
assert len(identity)==14232
lookup=np.full(sim.n,-1,np.int32);lookup[sim.kc]=np.arange(len(sim.kc),dtype=np.int32)
loc=lookup[identity.source_neuron_idx.to_numpy(np.int32)];assert np.all(loc>=0)
comp=identity.compartment.astype(str).to_numpy()==cfg['target_compartment'];multi=np.bincount(loc[comp],minlength=len(sim.kc)).astype(np.float32)
dan=sim.dan_targets(cfg['matched_DAN'],cfg['target_compartment']);duration=cfg['duration_ms'];nsteps=round(duration/sim.dt)
identity.to_parquet(exp/'protocol/frozen-edge-identity.parquet',index=False)
calrows=[];cached={};wall0=time.perf_counter();cpu0=resource.getrusage(resource.RUSAGE_SELF);torch.cuda.reset_peak_memory_stats()
for oi,(_,odor) in enumerate(cal.iterrows()):
label='CAL_'+chr(65+oi);targets,rates=make_drive(ocfg,panel,vectors,selection,mapping,odor.odor_id,'physiological')
pd.DataFrame({'neuron_idx':targets,'rate_hz':rates}).to_parquet(exp/'protocol'/f'{label}-drive.parquet',index=False)
for seed in cfg['calibration_seeds']:
actual=seed+oi*cfg['odor_seed_offset'];r=sim.run(targets,rates,actual,duration,collect_events=True);assert r['finite']
cached[(label,seed)]=(targets,rates,actual,r);persist_run(exp,f'cal-{label}-{seed}',r)
cs=curves(r,lookup,multi,cfg['windows_ms'],sim.dt,nsteps)
for i,w in enumerate(cfg['windows_ms']):
den=float(cs[i].mean())
for u in cfg['candidate_US_ms']:
m=float(cs[i][round(u/sim.dt)])
calrows.append({'label':label,'seed':seed,'window_ms':w,'US_ms':u,'matched':m,'random_expectation':den,'ratio':m/den if den else None})
pd.DataFrame(calrows).to_parquet(exp/'calibration/grid.parquet',index=False)
grid=[timing_test(calrows,cfg,w,u) for w in cfg['windows_ms'] for u in cfg['candidate_US_ms']]
good=[x for x in grid if x['pass']];chosen=min(good,key=lambda x:(x['window_ms'],x['US_ms'])) if good else None
# Persist the selection BEFORE accessing heldout data or formal odor responses.
save(exp/'calibration/selection.json',{'grid':grid,'chosen':chosen,'pass':chosen is not None,'formal_AB_used':False})
summary={'phase':'4S','calibration':chosen,'heldout':None,'formal':None,'isolated':None,'whole':None,
'timing_candidate':False,'credit_candidate':False,'associative_memory_claim':False}
if chosen:
w=chosen['window_ms'];u=chosen['US_ms'];us=round(u/sim.dt);tau=w/3
held=[];gate_exact=[]
for oi,(_,odor) in enumerate(cal.iterrows()):
label='CAL_'+chr(65+oi);targets,rates=make_drive(ocfg,panel,vectors,selection,mapping,odor.odor_id,'physiological')
for seed in cfg['heldout_seeds']:
actual=seed+oi*cfg['odor_seed_offset'];r=sim.run(targets,rates,actual,duration,collect_events=True);assert r['finite']
cs=curves(r,lookup,multi,[w],sim.dt,nsteps)[0];den=float(cs.mean());m=float(cs[us])
held.append({'label':label,'seed':seed,'window_ms':w,'US_ms':u,'matched':m,'random_expectation':den,'ratio':m/den if den else None})
persist_run(exp,f'held-{label}-{seed}',r)
for pulse in (us,int(np.random.default_rng(actual+975000).integers(nsteps))):
forced=sim.run(targets,rates,actual,duration,dan_targets=dan,dan_pulses=[pulse*sim.dt],collect_events=True)
t,_=last_spike_trace(r['kc_event_step'],r['kc_event_node'],lookup,pulse,w,tau,sim.dt)
tt,_=last_spike_trace(forced['kc_event_step'],forced['kc_event_node'],lookup,pulse,w,tau,sim.dt)
v2check=v2_trace(forced['kc_event_step'],forced['kc_event_node'],lookup,pulse,sim.dt)
gate_exact.append({'label':label,'seed':seed,'US_step':pulse,'credit_exact':bool(np.array_equal(t,tt)),
'v2_CPU_trace_exact_to_runtime':bool(np.array_equal(v2check,forced['trace']))})
pd.DataFrame(held).to_parquet(exp/'heldout/results.parquet',index=False)
h=timing_test(held,cfg,w,u);h['same_event_vs_live_US_credit_exact']=all(x['credit_exact'] and x['v2_CPU_trace_exact_to_runtime'] for x in gate_exact)
h['pass']=h['pass'] and h['same_event_vs_live_US_credit_exact'];save(exp/'heldout/gate.json',h);save(exp/'regression/live-US-gates.json',gate_exact)
summary['heldout']=h;print(json.dumps({'selection':chosen,'heldout':h}),flush=True)
if h['pass']:
summary['timing_candidate']=True
formal(cfg,exp,sim,identity,lookup,loc,comp,w,u,ocfg,panel,vectors,selection,mapping,summary)
summary['regression_pass']=evidence_regression(cfg,exp,rt,sim)
summary['status']='TIMING-PASS-CREDIT-PASS' if summary['credit_candidate'] else ('TIMING-PASS-CREDIT-NOT-ESTABLISHED' if summary['timing_candidate'] else 'FAIL-TIMING-VALIDATION')
summary['timing_candidate'] &= summary['regression_pass'];summary['credit_candidate'] &= summary['regression_pass']
save(exp/'audit/results.json',summary)
cpu1=resource.getrusage(resource.RUSAGE_SELF);wall=time.perf_counter()-wall0
save(exp/'performance/summary.json',{'wall_seconds':wall,'peak_VRAM_bytes':int(torch.cuda.max_memory_allocated()),
'peak_RAM_bytes':int(cpu1.ru_maxrss*1024),'CPU_percent_one_core':100*(cpu1.ru_utime+cpu1.ru_stime-cpu0.ru_utime-cpu0.ru_stime)/wall,
'GPU':'RTX 4090','GPU_utilization':'not sampled','runtime':'deterministic-runtime-v2'})
print(json.dumps(summary),flush=True)
def formal(cfg,exp,sim,identity,lookup,loc,comp,w,u,ocfg,panel,vectors,selection,mapping,summary):
spec=cfg['formal'];us=round(u/sim.dt);nsteps=round(cfg['duration_ms']/sim.dt);names=spec['arms'];points=spec['checkpoints']
targets,rates=make_drive(ocfg,panel,vectors,selection,mapping,selection['ODOR_A']['odor_id'],'physiological')
states={};rows=[];audit=[];contam=[];repeat=[];groups=identity.identity_group.astype(str).to_numpy()
for seed in spec['train_seed_bases']:
E={name:np.ones(len(identity),np.float32) for name in names}
for name in names:
states[f'{seed}_{name}_0']=E[name].copy()
for x in group_metrics(E[name],sim.base_candidate,identity):rows.append({'seed':seed,'pairing':0,'arm':name,**x})
for pairing in range(1,max(points)+1):
actual=seed+pairing;r=sim.run(targets,rates,actual,cfg['duration_ms'],collect_events=True);assert r['finite']
s=r['kc_event_step'];n=r['kc_event_node'];rand=int(np.random.default_rng(actual+spec['random_seed_offset']).integers(nsteps))
e3,last=last_spike_trace(s,n,lookup,us,w,w/3,sim.dt);er,lastr=last_spike_trace(s,n,lookup,rand,w,w/3,sim.dt)
v2=v2_trace(s,n,lookup,us,sim.dt);v90=v2_trace(s,n,lookup,900,sim.dt)
elig={'v2_same_US':edge_elig(v2,loc,comp),'v2_90ms':edge_elig(v90,loc,comp),
'v3_matched':edge_elig(e3,loc,comp),'v3_random':edge_elig(er,loc,comp),'D0':np.zeros(len(identity),np.float32)}
event_hash=persist_run(exp,f'formal-{seed}-{pairing}',r)
audit.append({'seed':seed,'pairing':pairing,'actual_seed':actual,'random_US_step':rand,'matched_US_step':us,'KC_hash_all_arms':event_hash,
'no_weight_overlay_during_event_collection':True,'same_KC_events_all_arms':True})
for name in names:
E[name],delta=update_E(E[name],elig[name])
for j in np.flatnonzero((groups=='B-specific')&(delta!=0)):
gate=rand if name=='v3_random' else (900 if name=='v2_90ms' else us)
ts=s[n==int(identity.source_neuron_idx.iloc[j])];ts=ts[ts<=gate];ls=int(ts[-1]) if len(ts) else None
contam.append({'seed':seed,'pairing':pairing,'arm':name,'edge_id':int(identity.edge_id.iloc[j]),
'KC_body_id':int(identity.source_body_id.iloc[j]),'last_KC_step':ls,'US_step':gate,'eligibility':float(elig[name][j]),'delta_E':float(delta[j])})
if pairing in points:
for name in names:
states[f'{seed}_{name}_{pairing}']=E[name].copy()
for x in group_metrics(E[name],sim.base_candidate,identity):rows.append({'seed':seed,'pairing':pairing,'arm':name,**x})
# Checkpoint each independent trajectory for recoverability.
np.savez_compressed(exp/'formal'/f'states-{seed}.npz',**{k:v for k,v in states.items() if k.startswith(str(seed)+'_')})
if pairing%20==0:print(json.dumps({'formal_seed':seed,'pairings':pairing}),flush=True)
if seed==spec['train_seed_bases'][0]:
rr=sim.run(targets,rates,seed+100,cfg['duration_ms'],collect_events=True)
repeat.append({'seed':seed+100,'events_exact':arrhash(rr['kc_event_step'],rr['kc_event_node'])==event_hash,
'states_exact':arrhash(rr['final_v'],rr['final_g'])==arrhash(r['final_v'],r['final_g'])})
f=pd.DataFrame(rows);f.to_parquet(exp/'formal/group-trajectory.parquet',index=False)
pd.DataFrame(audit).to_parquet(exp/'formal/event-audit.parquet',index=False);pd.DataFrame(contam).to_parquet(exp/'formal/B-contamination.parquet',index=False)
save(exp/'regression/formal-repeat.json',repeat)
endpoints=f[f.pairing==spec['primary_endpoint']];gates=[]
for seed in spec['train_seed_bases']:
z=endpoints[endpoints.seed==seed]
def val(arm,group):return float(z[(z.arm==arm)&(z.group==group)].total_abs_delta_E.iloc[0])
ma=val('v3_matched','A-specific');ra=val('v3_random','A-specific');b2=val('v2_same_US','B-specific');b3=val('v3_matched','B-specific')
ratio=ma/ra if ra else None;red=(1-b3/b2) if b2 else None
ag=ma>0 and (ra==0 or ma>=spec['minimum_A_credit_matched_random_ratio']*ra)
bg=(red is not None and red>=spec['minimum_B_reduction_vs_v2_same_US']) or (b2==0 and b3==0)
gates.append({'seed':seed,'A_credit':ma,'random_A_credit':ra,'A_ratio':ratio,'B_v2':b2,'B_v3':b3,'B_reduction':red,'pass':ag and bg})
fp=sum(x['pass'] for x in gates)>=spec['minimum_seed_majority']
summary['formal']={'gate_pass':fp,'primary_endpoint':spec['primary_endpoint'],'seeds':gates,'open_loop_shadow_only':True}
save(exp/'formal/gate.json',summary['formal'])
# Always measure isolated transfer after independently validated timing; its own gate controls whole-brain overlays.
replay=IsolatedReplay(sim,cfg['target_compartment']);evals={};results=[]
for odor in ('ODOR_A','ODOR_B'):
t,rates=make_drive(ocfg,panel,vectors,selection,mapping,selection[odor]['odor_id'],'physiological')
for es in spec['evaluation_seeds']:
er=sim.run(t,rates,es,cfg['duration_ms'],collect_events=True);evals[(odor,es)]=er;persist_run(exp,f'eval-{odor}-{es}',er)
for pairing in spec['isolated_checkpoints']:
for odor in ('ODOR_A','ODOR_B'):
for seed,es in zip(spec['train_seed_bases'],spec['evaluation_seeds']):
er=evals[(odor,es)];base=replay.run(er['kc_event_step'],er['kc_event_node'],None,cfg['duration_ms'])
for name in names:
post=replay.run(er['kc_event_step'],er['kc_event_node'],states[f'{seed}_{name}_{pairing}'],cfg['duration_ms'])
results.append({'pairing':pairing,'odor':odor,'seed':seed,'evaluation_seed':es,'arm':name,**replay_metrics(base,post,sim.dt)})
z=pd.DataFrame(results);z.to_parquet(exp/'isolated/results.parquet',index=False);ig=[];trajectory=[]
for pairing in spec['isolated_checkpoints']:
for seed in spec['train_seed_bases']:
def L(arm,odor):return int(z[(z.pairing==pairing)&(z.seed==seed)&(z.arm==arm)&(z.odor==odor)].spike_L1.iloc[0])
a,b,ra,rb=L('v3_matched','ODOR_A'),L('v3_matched','ODOR_B'),L('v3_random','ODOR_A'),L('v3_random','ODOR_B')
trajectory.append({'pairing':pairing,'seed':seed,'A':a,'B':b,'random_A':ra,'random_B':rb,'A_specific':a>0 and a>b,'random_specific':ra>0 and ra>rb,'matched_beats_random':a>ra})
endpoint=[x for x in trajectory if x['pairing']==spec['primary_endpoint']];major=spec['minimum_seed_majority']
ip=sum(x['A_specific'] for x in endpoint)>=major and sum(x['random_specific'] for x in endpoint)<major and sum(x['matched_beats_random'] for x in endpoint)>=major and bool((z[z.arm=='D0'].spike_L1==0).all())
summary['isolated']={'gate_pass':ip,'endpoint':endpoint,'trajectory':trajectory,'D0_zero':bool((z[z.arm=='D0'].spike_L1==0).all()),'no_checkpoint_selected_posthoc':True}
save(exp/'isolated/gate.json',summary['isolated'])
if fp and ip:
whole=[]
for odor in ('ODOR_A','ODOR_B'):
t,rates=make_drive(ocfg,panel,vectors,selection,mapping,selection[odor]['odor_id'],'physiological')
for seed,es in zip(spec['train_seed_bases'],spec['evaluation_seeds']):
base=evals[(odor,es)]
for name in ('v3_matched','v3_random','v2_same_US','D0'):
post=sim.run(t,rates,es,cfg['duration_ms'],E=states[f'{seed}_{name}_100'])
whole.append({'seed':seed,'odor':odor,'arm':name,'MBON_L1':int(np.abs(post['mbon_counts']-base['mbon_counts']).sum()),
'downstream_L1':int(np.abs(post['downstream_counts']-base['downstream_counts']).sum()),'finite':post['finite']})
wf=pd.DataFrame(whole);wf.to_parquet(exp/'whole-brain/results.parquet',index=False);wg=[]
for seed in spec['train_seed_bases']:
def v(arm,odor):return int(wf[(wf.seed==seed)&(wf.arm==arm)&(wf.odor==odor)].MBON_L1.iloc[0])
a,b,ra,rb=v('v3_matched','ODOR_A'),v('v3_matched','ODOR_B'),v('v3_random','ODOR_A'),v('v3_random','ODOR_B')
wg.append({'seed':seed,'A':a,'B':b,'random_A':ra,'random_B':rb,'specific':a>b and a>ra,'random_specific':ra>rb})
wp=sum(x['specific'] for x in wg)>=major and sum(x['random_specific'] for x in wg)<major and bool(wf.finite.all()) and bool((wf[wf.arm=='D0'].MBON_L1==0).all())
summary['whole']={'run':True,'pass':wp,'seeds':wg};summary['credit_candidate']=bool(wp)
else:summary['whole']={'run':False,'reason':'formal credit or isolated specificity gate failed'}
save(exp/'whole-brain/gate.json',summary['whole'])
def finalize(cfg,exp,model):
old=phase4p_finalize.integrity();now=checks(cfg);before=json.loads((exp/'audit/preflight.json').read_text())
pre=json.loads((exp/'protocol/preregistered.json').read_text());same=all(digest(model/n)==h for n,h in pre['source_hashes'].items())
integrity={'Phase1_4O':old,'Phase4P_4R':now,'before_after_equal':now==before,'preregistered_source_hashes_unchanged':same,
'pass':old['all_static_checks_passed'] and all(x['passed'] for x in now.values()) and now==before and same}
save(exp/'audit/frozen-integrity.json',integrity);summary=json.loads((exp/'audit/results.json').read_text())
summary['integrity_pass']=integrity['pass'];summary['timing_candidate'] &= integrity['pass'];summary['credit_candidate'] &= integrity['pass']
if summary['timing_candidate']:
save(model/'MaleCNS-CSUS-Timing-v1-candidate.json',{'scope':'independent calibration and seed-heldout matched/random credit timing only; no memory claim',
'selection':summary['calibration'],'heldout':summary['heldout'],'brain_unchanged':True})
if summary['credit_candidate']:
save(model/'MaleCNS-CreditAssignment-v1-candidate.json',{'scope':'open-loop shadow credit and replay; no online learning claim','source':str(exp/'audit/results.json')})
save(exp/'audit/results.json',summary)
for root in (model,exp):
ps=[p for p in sorted(root.rglob('*')) if p.is_file() and p.name not in ('checksums.sha256','manifest.json')]
rows=[{'path':p.relative_to(root).as_posix(),'sha256':digest(p),'bytes':p.stat().st_size} for p in ps]
save(root/'manifest.json',{'files':rows});rows.append({'path':'manifest.json','sha256':digest(root/'manifest.json')})
(root/'checksums.sha256').write_text(''.join(x['sha256']+' '+x['path']+'\n' for x in rows))
test=verify_checksum(root,root/'checksums.sha256');assert test['passed'];print(json.dumps(test),flush=True)
print(json.dumps({'status':summary['status'],'timing_candidate':summary['timing_candidate'],'credit_candidate':summary['credit_candidate'],'integrity_pass':integrity['pass']}),flush=True)
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('task',choices=['run','finalize']);p.add_argument('--config',required=True);a=p.parse_args()
cfg=yaml.safe_load(Path(a.config).read_text());exp=Path(cfg['experiment_root']);model=Path(cfg['model_root'])
(run if a.task=='run' else finalize)(cfg,exp,model)