Archive CIDM-v2 reproducible b2_r13_same_state_vjp.py
Browse files
cidm_v2/source/B2_R13_P0/b2_r13_same_state_vjp.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""B2-R13/P0: frozen, SAME-STATE vs OWN-TRAJECTORY FP32/FP64 local VJPs.
|
| 2 |
+
|
| 3 |
+
Full frozen 12-step forward is used only to harvest step-local states and
|
| 4 |
+
loss-conditioned cotangents. All derivative probes below are independent,
|
| 5 |
+
zero-training, step-local Jacobian-vector products with original CIDM-v2 code.
|
| 6 |
+
No BPTT/training Gate, no causal operator ablation, no Jacobian spectral claim.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
import csv, copy, gc, json, math, zipfile, hashlib
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import torch
|
| 12 |
+
import b2_r11_backward_attribution as r11
|
| 13 |
+
import b2_r12_dual_precision_chain as r12
|
| 14 |
+
import b2_r10_gradient_credit_audit as credit
|
| 15 |
+
from b2_r1_flux_control import check_zip
|
| 16 |
+
|
| 17 |
+
R12_FIX1_SHA='267f8d574d98754f864227cc28e49c335035a0697ec5f0a112117218c4c5d28a'
|
| 18 |
+
LEADS=(1,3,6,9,12)
|
| 19 |
+
|
| 20 |
+
def vecnorm(x):
|
| 21 |
+
return float(torch.linalg.vector_norm(x.detach().reshape(-1).double()))
|
| 22 |
+
|
| 23 |
+
def unit_cot(x):
|
| 24 |
+
y=x.detach().double(); val=torch.linalg.vector_norm(y)
|
| 25 |
+
if not torch.isfinite(val) or val<=0:raise RuntimeError('INVALID_ADJOINT_FOR_UNIT_PROBE')
|
| 26 |
+
return (y/val).detach().cpu()
|
| 27 |
+
|
| 28 |
+
def finite(x,label):
|
| 29 |
+
if not bool(torch.isfinite(x).all()):raise RuntimeError('NONFINITE_'+label)
|
| 30 |
+
|
| 31 |
+
def angle(a,b):
|
| 32 |
+
v=(a.flatten().double()*b.flatten().double()).sum(); d=torch.linalg.vector_norm(a.double())*torch.linalg.vector_norm(b.double())
|
| 33 |
+
return float(v/d.clamp_min(1e-300)) if d>0 else 0.
|
| 34 |
+
|
| 35 |
+
def relative(a,b):
|
| 36 |
+
return vecnorm(a.double()-b.double())/max(vecnorm(b),1e-300)
|
| 37 |
+
|
| 38 |
+
def one_step(core,head,z,p,init,Q,lead):
|
| 39 |
+
"""Mathematically identical to R11.full_forward for one controlled step."""
|
| 40 |
+
mm=core.model; co=mm.coefficients();skew=mm.skew-mm.skew.T
|
| 41 |
+
f=co['flux']*mm.flux(z)
|
| 42 |
+
l=co['local']*torch.tanh(mm.local(z))
|
| 43 |
+
s=.035*torch.einsum('ij,bjhw->bihw',skew,z)
|
| 44 |
+
base=z+f+l+s
|
| 45 |
+
basis,amps=credit.stable_exact_effect_basis(f,l,s,Q)
|
| 46 |
+
gates=head(base,p,init,amps,lead)
|
| 47 |
+
raw=(gates.unsqueeze(2)*basis).sum(dim=1)/math.sqrt(3.)
|
| 48 |
+
rr=credit.stable_exact_rms(raw)
|
| 49 |
+
change=r11.BUDGET*credit.stable_exact_rms(base).clamp_min(1e-4)*raw/rr.clamp_min(1e-9)
|
| 50 |
+
change=torch.where(rr>=1e-9,change,torch.zeros_like(change))
|
| 51 |
+
zn=base+change
|
| 52 |
+
pn=p+co['out']*torch.tanh(mm.decode(zn))
|
| 53 |
+
return zn,pn,f,l,s
|
| 54 |
+
|
| 55 |
+
def evaluate_step(core,head,z,p,init,Q,lead,cz,cp):
|
| 56 |
+
"""Return both independent local VJPs wrt z_old; p held fixed."""
|
| 57 |
+
dt=next(core.parameters()).dtype
|
| 58 |
+
z=z.to(dtype=dt).detach().requires_grad_(True)
|
| 59 |
+
p=p.to(dtype=dt).detach();init=init.to(dtype=dt).detach()
|
| 60 |
+
cz=cz.to(dtype=dt).detach();cp=cp.to(dtype=dt).detach()
|
| 61 |
+
q=Q.to(dtype=dt).detach() if Q is not None else None
|
| 62 |
+
zn,pn,f,l,s=one_step(core,head,z,p,init,q,lead)
|
| 63 |
+
for k,val in [('zn',zn),('pn',pn),('f',f),('l',l),('s',s)]:finite(val,k)
|
| 64 |
+
grad_z=torch.autograd.grad(zn,z,grad_outputs=cz,retain_graph=True)[0]
|
| 65 |
+
grad_p=torch.autograd.grad(pn,z,grad_outputs=cp,retain_graph=False)[0]
|
| 66 |
+
for k,val in [('vjp_z',grad_z),('vjp_p',grad_p)]:finite(val,k)
|
| 67 |
+
return {'latent':grad_z.detach().cpu().double(),'decoder':grad_p.detach().cpu().double(),
|
| 68 |
+
'new_z':zn.detach().cpu().double(),'new_p':pn.detach().cpu().double()}
|
| 69 |
+
|
| 70 |
+
def states_and_adjoint(core,head,h,y,sd,Q,precision):
|
| 71 |
+
dt=torch.float32 if precision=='fp32' else torch.float64
|
| 72 |
+
h=h.to(dtype=dt);y=y.to(dtype=dt);sd=sd.to(dtype=dt)
|
| 73 |
+
q=Q.to(dtype=dt) if Q is not None else None
|
| 74 |
+
pred,loss,recs=r11.full_forward(core,head,h,y,sd,q)
|
| 75 |
+
finite(pred,'forward_'+precision)
|
| 76 |
+
if precision=='fp32':
|
| 77 |
+
# same real training objective, two independent VJP probes; no parameter update.
|
| 78 |
+
req=[r[k] for r in recs for k in ('z_new','prev_new')]
|
| 79 |
+
g=torch.autograd.grad(loss,req,allow_unused=True,retain_graph=False)
|
| 80 |
+
if any(v is None for v in g):raise RuntimeError('MISSING_FULL_CHAIN_ADJOINT')
|
| 81 |
+
else:g=None
|
| 82 |
+
out=[]
|
| 83 |
+
for t,rec in enumerate(recs):
|
| 84 |
+
d={k:rec[k].detach().cpu().clone() for k in ('z_old','prev_old','initial','z_new','prev_new')}
|
| 85 |
+
if g is not None:
|
| 86 |
+
d['cot_z']=unit_cot(g[t*2]);d['cot_p']=unit_cot(g[t*2+1])
|
| 87 |
+
out.append(d)
|
| 88 |
+
pred=pred.detach().cpu().double()
|
| 89 |
+
del recs,g,loss
|
| 90 |
+
gc.collect()
|
| 91 |
+
return pred,out
|
| 92 |
+
|
| 93 |
+
def case(task,core,cache,r6zip,kind,number_of_windows=2,leads=LEADS):
|
| 94 |
+
train=r11.Windows(cache['splits']['train']['h'],cache['splits']['train']['y'],cache['mean'],cache['std'])
|
| 95 |
+
ch=int(cache['splits']['train']['h'].shape[2]);d=int(core.model.skew.shape[0])
|
| 96 |
+
head_task= 'burgers_1d' if task=='smoke_1d' else ('swe_2d' if task=='smoke_2d' else task)
|
| 97 |
+
head=r11.load_heads(r6zip,head_task,ch,'cpu')[kind].eval().requires_grad_(False)
|
| 98 |
+
Q=r11.orthogonal_matrix(d,32207,'cpu',torch.float32) if kind=='rotated' else None
|
| 99 |
+
c32=copy.deepcopy(core).cpu().float().eval().requires_grad_(False)
|
| 100 |
+
c64=copy.deepcopy(core).cpu().double().eval().requires_grad_(False)
|
| 101 |
+
h32=copy.deepcopy(head).cpu().float().eval().requires_grad_(True)
|
| 102 |
+
h64=copy.deepcopy(head).cpu().double().eval().requires_grad_(True)
|
| 103 |
+
rows=[];windows=[]
|
| 104 |
+
for i in range(min(number_of_windows,len(train))):
|
| 105 |
+
h,y=train[i];h=h.unsqueeze(0).cpu();y=y.unsqueeze(0).cpu();sd=train.sd.cpu()
|
| 106 |
+
p32,s32=states_and_adjoint(c32,h32,h,y,sd,Q,'fp32')
|
| 107 |
+
p64,s64=states_and_adjoint(c64,h64,h,y,sd,Q,'fp64')
|
| 108 |
+
div=relative(p32,p64);maxdiv=float((p32-p64).abs().max())
|
| 109 |
+
windows.append({'task':task,'controller':kind,'window_index':i,'fp32_fp64_forecast_rel_l2':div,'fp32_fp64_forecast_max_abs':maxdiv})
|
| 110 |
+
for t in leads:
|
| 111 |
+
a=s32[t-1];b=s64[t-1]
|
| 112 |
+
# true FP32 full-chain cotangent: normalized and frozen before local VJP
|
| 113 |
+
# random control: independent of future labels, deterministic per window and lead
|
| 114 |
+
rr=torch.Generator(device='cpu').manual_seed(13013+i*101+t*19+(1 if kind=='rotated' else 0))
|
| 115 |
+
rv=torch.randn(a['cot_z'].shape,generator=rr,dtype=torch.float64)
|
| 116 |
+
rv/=torch.linalg.vector_norm(rv).clamp_min(1e-300)
|
| 117 |
+
rp=torch.randn(a['cot_p'].shape,generator=rr,dtype=torch.float64)
|
| 118 |
+
rp/=torch.linalg.vector_norm(rp).clamp_min(1e-300)
|
| 119 |
+
for probe,cz,cp in (('frozen_loss_adjoint',a['cot_z'],a['cot_p']),('random_isotropic',rv,rp)):
|
| 120 |
+
native32=evaluate_step(c32,h32,a['z_old'],a['prev_old'],a['initial'],Q,t,cz,cp)
|
| 121 |
+
shared64=evaluate_step(c64,h64,a['z_old'],a['prev_old'],a['initial'],Q,t,cz,cp)
|
| 122 |
+
own64=evaluate_step(c64,h64,b['z_old'],b['prev_old'],b['initial'],Q,t,cz,cp)
|
| 123 |
+
# Strict same-dtype independent reproduction of the *actual* archived
|
| 124 |
+
# 12-step rollout intermediates before interpreting any VJP.
|
| 125 |
+
parity32=max(float((native32['new_z']-a['z_new'].double()).abs().max()),
|
| 126 |
+
float((native32['new_p']-a['prev_new'].double()).abs().max()))
|
| 127 |
+
parity64=max(float((own64['new_z']-b['z_new'].double()).abs().max()),
|
| 128 |
+
float((own64['new_p']-b['prev_new'].double()).abs().max()))
|
| 129 |
+
if parity32>2e-6 or parity64>2e-11:
|
| 130 |
+
raise RuntimeError(f'STRICT_LOCAL_FORWARD_REPLAY_FAIL {task} {kind} window={i} lead={t} fp32={parity32} fp64={parity64}')
|
| 131 |
+
row={'local_forward_parity_fp32_max_abs':parity32,'local_forward_parity_fp64_max_abs':parity64,'task':task,'controller':kind,'train_window':i,'lead':t,'probe':probe,
|
| 132 |
+
'relative_state_deviation_fp32_vs_fp64':relative(a['z_old'],b['z_old']),
|
| 133 |
+
'relative_observable_deviation_fp32_vs_fp64':relative(a['prev_old'],b['prev_old']),
|
| 134 |
+
'fp32_fp64_forecast_rel_l2':div,
|
| 135 |
+
'shared_state_one_step_latent_rel_diff':relative(native32['new_z'],shared64['new_z']),
|
| 136 |
+
'shifted_state_one_step_latent_rel_diff':relative(shared64['new_z'],own64['new_z']),
|
| 137 |
+
'shared_state_one_step_observable_rel_diff':relative(native32['new_p'],shared64['new_p']),
|
| 138 |
+
'shifted_state_one_step_observable_rel_diff':relative(shared64['new_p'],own64['new_p'])}
|
| 139 |
+
for branch in ('latent','decoder'):
|
| 140 |
+
x0=native32[branch];xs=shared64[branch];xo=own64[branch]
|
| 141 |
+
row[branch+'_vjp_fp32_l2']=vecnorm(x0)
|
| 142 |
+
row[branch+'_vjp_shared_fp64_l2']=vecnorm(xs)
|
| 143 |
+
row[branch+'_vjp_native_fp64_l2']=vecnorm(xo)
|
| 144 |
+
row[branch+'_same_state_precision_rel_diff']=relative(x0,xs)
|
| 145 |
+
row[branch+'_same_state_precision_cosine']=angle(x0,xs)
|
| 146 |
+
row[branch+'_trajectory_shift_rel_diff']=relative(xs,xo)
|
| 147 |
+
row[branch+'_trajectory_shift_cosine']=angle(xs,xo)
|
| 148 |
+
rows.append(row)
|
| 149 |
+
return rows,windows
|
| 150 |
+
|
| 151 |
+
def write_csv(path,rows):
|
| 152 |
+
if not rows:return
|
| 153 |
+
with open(path,'w',newline='',encoding='utf-8') as fh:
|
| 154 |
+
w=csv.DictWriter(fh,fieldnames=list(rows[0]));w.writeheader();w.writerows(rows)
|
| 155 |
+
|
| 156 |
+
def write_json(path,obj):
|
| 157 |
+
Path(path).write_text(json.dumps(obj,indent=2,ensure_ascii=False,allow_nan=False,default=str),encoding='utf-8')
|
| 158 |
+
|
| 159 |
+
def run(parent,r6zip,r9zip,fix2zip,r11fix1zip,r12zip,work,output,smoke=False):
|
| 160 |
+
work=Path(work);output=Path(output);work.mkdir(parents=True,exist_ok=True);output.mkdir(parents=True,exist_ok=True)
|
| 161 |
+
if r11.sha(r12zip)!=R12_FIX1_SHA:raise RuntimeError('R12_FIXED_OFFICIAL_REPORT_SHA_MISMATCH')
|
| 162 |
+
with zipfile.ZipFile(r12zip) as z:
|
| 163 |
+
if z.testzip():raise RuntimeError('R12_REPORT_CRC_ERROR')
|
| 164 |
+
rep=json.loads(z.read('report.json'));gate=json.loads(z.read('precision_gate.json'))
|
| 165 |
+
if rep['status']!='FROZEN_DUAL_PRECISION_FIX1_COMPLETED_NO_TRAINING' or rep['case_rows']!=12 or rep['time_rows']!=144 or not gate['strict_partition_pass_all']:
|
| 166 |
+
raise RuntimeError('R12_PRECISION_EVIDENCE_CONTRACT_FAILED')
|
| 167 |
+
if r11.sha(r11fix1zip)!=r12.ARCHIVE_FIX1_SHA:raise RuntimeError('R11_EVIDENCE_SHA_FAILED')
|
| 168 |
+
if r11.sha(fix2zip)!=r11.PREV_FIX2_SHA:raise RuntimeError('R10_EVIDENCE_SHA_FAILED')
|
| 169 |
+
r11.credit.validate_r6(r6zip);r11.credit.validate_r9(r9zip)
|
| 170 |
+
rows=[];wrows=[];cases=[]
|
| 171 |
+
try:
|
| 172 |
+
if smoke:
|
| 173 |
+
cfg=r11.Config(profile='smoke')
|
| 174 |
+
for task,kind in [('smoke_1d','operator'),('smoke_2d','rotated')]:
|
| 175 |
+
cache=r11.make_synthetic_cache(cfg)
|
| 176 |
+
for split in ('train','val','test'):
|
| 177 |
+
if task=='smoke_1d':
|
| 178 |
+
cache['splits'][split]['h']=cache['splits'][split]['h'].mean(dim=-2)[...,:4]
|
| 179 |
+
cache['splits'][split]['y']=cache['splits'][split]['y'].mean(dim=-2)[...,:4]
|
| 180 |
+
else:
|
| 181 |
+
cache['splits'][split]['h']=cache['splits'][split]['h'][...,:2,:4]
|
| 182 |
+
cache['splits'][split]['y']=cache['splits'][split]['y'][...,:2,:4]
|
| 183 |
+
core=r11.UnifiedModel('V2_Ported_R5',1,8,12,4).cpu()
|
| 184 |
+
a,b=case(task,core,cache,r6zip,kind,1,(1,6,12));rows.extend(a);wrows.extend(b)
|
| 185 |
+
cases.append({'task':task,'controller':kind,'program_only':True})
|
| 186 |
+
status='PROGRAM_ONLY_SMOKE_NO_SCIENCE'
|
| 187 |
+
else:
|
| 188 |
+
if parent is None or r11.sha(parent)!=r11.PARENT_SHA:raise RuntimeError('ORIGINAL_PARENT_SHA_FAILED')
|
| 189 |
+
with zipfile.ZipFile(parent) as z:
|
| 190 |
+
manifest,_=check_zip(z);cfg=r11.Config(**manifest['config'])
|
| 191 |
+
for task in r11.TASK_LIST:
|
| 192 |
+
core,cache,replay=r11.make_cache_checked(task,z,cfg,work,'cpu')
|
| 193 |
+
for kind in ('operator','rotated'):
|
| 194 |
+
a,b=case(task,core,cache,r6zip,kind)
|
| 195 |
+
rows.extend(a);wrows.extend(b)
|
| 196 |
+
cases.append({'task':task,'controller':kind,'parent_replay':replay})
|
| 197 |
+
write_csv(output/'same_state_vjp.csv',rows)
|
| 198 |
+
write_csv(output/'window_forward_drift.csv',wrows)
|
| 199 |
+
write_json(output/'progress.json',{'completed':cases,'rows':len(rows)})
|
| 200 |
+
if len(rows)!=3*2*2*len(LEADS)*2 or len(cases)!=6:
|
| 201 |
+
raise RuntimeError('INCOMPLETE_OFFICIAL_R13_CASES')
|
| 202 |
+
status='FROZEN_SAME_STATE_VJP_COMPLETED_NO_TRAINING'
|
| 203 |
+
except Exception as e:
|
| 204 |
+
write_csv(output/'same_state_vjp.csv',rows);write_csv(output/'window_forward_drift.csv',wrows)
|
| 205 |
+
write_json(output/'failure_details.json',{'error':repr(e),'groups_completed':cases,'rows':len(rows)})
|
| 206 |
+
raise
|
| 207 |
+
write_csv(output/'same_state_vjp.csv',rows);write_csv(output/'window_forward_drift.csv',wrows)
|
| 208 |
+
summary=[]
|
| 209 |
+
for task in sorted({r['task'] for r in rows}):
|
| 210 |
+
for kind in sorted({r['controller'] for r in rows if r['task']==task}):
|
| 211 |
+
for probe in ('frozen_loss_adjoint','random_isotropic'):
|
| 212 |
+
ar=[r for r in rows if r['task']==task and r['controller']==kind and r['probe']==probe]
|
| 213 |
+
if not ar:continue
|
| 214 |
+
for branch in ('latent','decoder'):
|
| 215 |
+
f=lambda key:sum(float(x[key]) for x in ar)/len(ar)
|
| 216 |
+
summary.append({'task':task,'controller':kind,'probe':probe,'branch':branch,
|
| 217 |
+
'mean_same_state_precision_rel_diff':f(branch+'_same_state_precision_rel_diff'),
|
| 218 |
+
'mean_trajectory_shift_rel_diff':f(branch+'_trajectory_shift_rel_diff'),
|
| 219 |
+
'mean_same_state_precision_cosine':f(branch+'_same_state_precision_cosine'),
|
| 220 |
+
'mean_trajectory_shift_cosine':f(branch+'_trajectory_shift_cosine'),
|
| 221 |
+
'row_count':len(ar)})
|
| 222 |
+
write_csv(output/'vjp_summary.csv',summary)
|
| 223 |
+
gate={'status':'DIAGNOSTIC_ONLY_NO_TRAINING','completed_groups':len(cases),
|
| 224 |
+
'sample_probe_rows':len(rows),'train_allowed':False,
|
| 225 |
+
'interpretation':'same-state VJP error isolates local precision dependence at fixed FP32 state and cotangent; shifted-state VJP difference captures sensitivity to FP32-vs-FP64 trajectory changes; not causal proof or a Jacobian spectrum bound',
|
| 226 |
+
'source_report_SHA256':R12_FIX1_SHA}
|
| 227 |
+
write_json(output/'same_state_gate.json',gate)
|
| 228 |
+
report={'experiment':'CIDM_v2_B2_R13_P0_SameStateLocalJacobian','status':status,'cases':cases,
|
| 229 |
+
'rows':len(rows),'windows':len(wrows),'updates':0,'train_allowed':False,
|
| 230 |
+
'dataset_note':'official original TRAIN windows 0 and 1; previously used distribution, no fresh outer holdout'}
|
| 231 |
+
write_json(output/'report.json',report)
|
| 232 |
+
return report
|