"""Finite fixtures, local freeze, 192-case matrix and author rerun."""
import argparse, copy, csv, datetime, hashlib, json, pathlib, random, sys, tempfile, time
from common import CAP, DEADLINE, GAP, BudgetError, IntegrityError, canonical, digest, seed
from host import generate, neighbors, admissible
from construct import develop, build
from runtime import Runtime, validate
from task import events, oracle, score
ROOT=pathlib.Path(__file__).resolve().parent
FROZEN=['common.py','host.py','program.py','construct.py','runtime.py','task.py','experiment.py','run_job.py','config.json','protocol.md','development-history.md','fixture-results.json']
def now(): return datetime.datetime.now(datetime.timezone.utc).isoformat()
def dump(p,x): p.write_text(json.dumps(x,indent=2,sort_keys=True)+'\n')
def load(p): return json.loads(p.read_text())
def sha(p): return hashlib.sha256(p.read_bytes()).hexdigest()
def checked(arm,h):
    a,c,tr=(develop if arm=='D' else build)(h); start=time.process_time(); validate(a,h)
    c['validation_cpu_seconds']=time.process_time()-start
    c['persistent_logical_bytes']=len(a['cells'])*2+3*c['routing_bytes']+h['K']+4+24
    c['reserved_task_buffer_bytes']=CAP['task_queue']*16+4+CAP['output_records']*8
    return a,c,tr
def execute(a,stream):
    start=time.perf_counter(); cpu=time.process_time(); r=Runtime(a); result=r.run(stream)
    result['wall_seconds']=time.perf_counter()-start; result['cpu_seconds']=time.process_time()-cpu
    return result
def replay(a,stream,original):
    # Actual JSON round trip, execution from only compiled structure and initial
    # state. No growth object, constructor closure, or history enters replay.
    compiled=json.loads(canonical(a)); other=execute(compiled,stream)
    for key in ('responses','trajectory','boundary','final'):
        if other[key]!=original[key]: raise IntegrityError('compiled replay mismatch '+key)
    return dict(pass_replay=True,steps=len(other['trajectory']),trajectory_hash=digest(other['trajectory']),
                final_hash=digest(other['final']),cpu_seconds=other['cpu_seconds'],wall_seconds=other['wall_seconds'])
def challenge(a,namespace,group,pair):
    K=a['K']; signs=[1,-1]*(K//2); random.Random(seed(namespace,'paired',group,pair,K)).shuffle(signs)
    r=Runtime(a)
    for address,value in enumerate(signs):
        r.inject(dict(id=address,t=address*GAP,kind='W',a=address,v=value)); r.settle((address+1)*GAP-1)
    assert not r.s['queue'] and r.s['pending'] is None
    settled=digest(r.s); positives=signs.index(1); negatives=signs.index(-1); out=[]
    for address in (positives,negatives):
        clone=r.clone(); assert digest(clone.s)==settled
        e=dict(id=K+pair,t=K*GAP,kind='Q',a=address,v=0)
        clone.inject(e); responses=clone.settle(e['t']+GAP-1)
        out.append(dict(event=e,target=signs[address],score=score(e,signs[address],responses),
                        final_hash=digest(clone.s),messages=clone.s['sent']-r.s['sent']))
    return dict(pair=pair,bank=signs,settled_hash=settled,queries=out,both=int(all(x['score']['success'] for x in out)))
def evaluate(h,group,setting,arm,cfg):
    record=dict(group=group,setting=setting,arm=arm,valid=False,case_success=0,episodes=[],pairs=[],failure=None)
    rows=[]; a=None; trace=[]
    construction_start=time.perf_counter(); construction_cpu=time.process_time()
    try:
        a,c,trace=checked(arm,h); record.update(valid=True,cost=c)
    except (BudgetError,IntegrityError) as error:
        record['failure']=dict(stage='construction',category=type(error).__name__,reason=str(error),
                              wall_seconds=time.perf_counter()-construction_start,cpu_seconds=time.process_time()-construction_cpu)
    for ep in range(4):
        stream=events(cfg['namespace'],group,h['K'],ep); targets=oracle(stream,h['K'])
        output=None; rep=None
        if a is not None:
            try:
                output=execute(a,stream); rep=replay(a,stream,output)
            except BudgetError as error:
                record['failure']=dict(stage='execution',category='BudgetError',reason=str(error)); a=None
            # Integrity/replay defects propagate and terminate, preserving partial.
        erows=[]
        for e in stream:
            if e['kind']!='Q': continue
            sc=score(e,targets[e['id']]['value'],[] if output is None else output['responses'])
            if output is None: sc['success']=0
            row=dict(group=group,setting=setting,arm=arm,episode=ep,event=e['id'],t=e['t'],address=e['a'],target=targets[e['id']]['value'],retention=targets[e['id']]['retention'],**sc,
                     model_failure=int(output is None))
            rows.append(row); erows.append(row)
        record['episodes'].append(dict(episode=ep,queries=len(erows),correct=sum(x['success'] for x in erows),
                messages=output['final']['sent'] if output else None,payload_bytes=4*output['final']['sent'] if output else None,
                peak_queue=output['final']['peak_queue'] if output else None,peak_buffered_logical_bytes=16*output['final']['peak_queue']+4+8 if output else None,
                peak_serialized_state_bytes=output['final']['peak_state_bytes'] if output else None,
                execution_cpu_seconds=output['cpu_seconds'] if output else None,execution_wall_seconds=output['wall_seconds'] if output else None,
                replay=rep))
    if a is not None:
        for pair in range(4): record['pairs'].append(challenge(a,cfg['namespace'],group,pair))
    else:
        for pair in range(4):
            K=h['K']; signs=[1,-1]*(K//2); random.Random(seed(cfg['namespace'],'paired',group,pair,K)).shuffle(signs)
            qs=[]
            for address in (signs.index(1),signs.index(-1)):
                e=dict(id=K+pair,t=K*GAP,kind='Q',a=address,v=0); qs.append(dict(event=e,target=signs[address],score=score(e,signs[address],[])))
            record['pairs'].append(dict(pair=pair,bank=signs,queries=qs,both=0))
    if record['failure'] is not None:
        # Preserve observed responses but charge every scheduled query zero for
        # any per-model resource/construction failure, including earlier ones.
        for row in rows: row.update(success=0,model_failure=1)
        for episode in record['episodes']: episode['correct']=0
    record['queries']=len(rows); record['correct']=sum(x['success'] for x in rows)
    record['pair_individual_correct']=sum(q['score']['success'] for p in record['pairs'] for q in p['queries'])
    record['pairs_both_correct']=sum(p['both'] for p in record['pairs'])
    record['case_success']=int(record['valid'] and record['failure'] is None and record['correct']==record['queries'] and record['pairs_both_correct']==4 and all(e['replay'] and e['replay']['pass_replay'] for e in record['episodes']))
    return record,rows,a,trace
def aggregate(records,cfg,complete):
    settings=cfg['settings']; reference=settings[0]['id']; groups=[]
    for g in cfg['groups']:
        values={arm:[next((r['case_success'] for r in records if r['group']==g and r['setting']==s['id'] and r['arm']==arm),None) for s in settings if s['id']!=reference] for arm in ('D','B')}
        d=sum(values['D'])/7 if None not in values['D'] else None; b=sum(values['B'])/7 if None not in values['B'] else None
        groups.append(dict(group=g,D=d,B=b,difference=d-b if d is not None and b is not None else None))
    primary={arm:sum(g[arm] for g in groups)/12 for arm in ('D','B')} if complete else None
    if primary is not None: primary['difference']=primary['D']-primary['B']
    table=[]
    for s in settings:
        row=dict(setting=s['id'],L=s['L'],K=s['K'],p=s['p'])
        for arm in ('D','B'):
            rr=[r for r in records if r['setting']==s['id'] and r['arm']==arm]
            row[arm]=dict(observed_cases=len(rr),successful_cases=sum(r['case_success'] for r in rr),queries=sum(r['queries'] for r in rr),correct=sum(r['correct'] for r in rr),
                          pairs_both_correct=sum(r['pairs_both_correct'] for r in rr),pair_individual_correct=sum(r['pair_individual_correct'] for r in rr))
        table.append(row)
    return dict(complete=complete,observed=len(records),scheduled=192,primary=primary,groups=groups,settings=table)
def check_freeze():
    f=load(ROOT/'freeze.json')
    for name,value in f['files'].items():
        if sha(ROOT/name)!=value: raise IntegrityError('frozen source changed '+name)
    return f
def matrix(out,cfg,only=None):
    out.mkdir(); records=[]; all_rows=[]; hosts=[]; representatives=[]; best=None; start=now()
    settings=cfg['settings']; complete=False
    try:
        for g in cfg['groups'] if only is None else [only[0]]:
            order=settings[g%8:]+settings[:g%8] if only is None else [s for s in settings if s['id']==only[1]]
            for s in order:
                h,rejected=generate(cfg['namespace'],g,s['L'],s['K'],s['p'])
                hosts.append(dict(group=g,setting=s['id'],rejections=rejected,host=h,hash=digest(h)))
                if h is None: raise IntegrityError('bounded host admissibility exhausted; cases unobserved')
                for arm in (('D','B') if (g+settings.index(s))%2==0 else ('B','D')):
                    r,rows,a,tr=evaluate(h,g,s['id'],arm,cfg); records.append(r); all_rows.extend(rows)
                    if g==0 and s['id'] in (settings[0]['id'],settings[-1]['id']): representatives.append(dict(group=g,setting=s['id'],arm=arm,circuit=a,construction_trace=tr))
                    if arm=='D' and r.get('cost') and (best is None or r['cost']['development_messages']>best['cost']):
                        best=dict(group=g,setting=s['id'],cost=r['cost']['development_messages'],circuit=a,construction_trace=tr)
                    dump(out/'progress.json',dict(started_utc=start,updated_utc=now(),observed=len(records),scheduled=192,active=True))
        complete=only is None and len(records)==192
    except BaseException as error:
        dump(out/'stop.json',dict(ended_utc=now(),type=type(error).__name__,reason=str(error),observed=len(records)))
        raise
    finally:
        dump(out/'cases.json',records); dump(out/'hosts.json',hosts); dump(out/'representatives.json',representatives)
        dump(out/'difficult-trace.json',best); dump(out/'summary.json',aggregate(records,cfg,complete))
        if all_rows:
            with (out/'queries.csv').open('w',newline='') as f:
                w=csv.DictWriter(f,fieldnames=list(all_rows[0])); w.writeheader()
                for r in all_rows: w.writerow(dict(r,responses=canonical(r['responses'])))
        dump(out/'events.json',[dict(group=g,K=K,episode=e,events=events(cfg['namespace'],g,K,e)) for g in (cfg['groups'] if only is None else [only[0]]) for K in (4,8) for e in range(4)])
        dump(out/'progress.json',dict(started_utc=start,ended_utc=now(),observed=len(records),scheduled=192,active=False,complete=complete))
    return records
def tiny(detour=False):
    positions={f'c{x}{y}{z}':[x,y,z] for x in range(3) for y in range(3) for z in range(2)}
    if detour: del positions['c100']
    bonds=[]
    for u,p in positions.items():
        for v,q in positions.items():
            if u<v and sum(abs(x-y) for x,y in zip(p,q))==1: bonds.append(dict(u=u,v=v,delay=1))
    terms=[dict(kind='query',site='c000'),dict(kind='response',site='c221')]
    terms += [dict(kind='write',a=a,site=u) for a,u in enumerate(['c200','c020','c201','c021'])]
    return dict(L=3,K=4,p='manual-detour',positions=positions,bonds=bonds,unavailable=[],terminals=terms,terminal_delay=1)
def fixtures():
    cfg=load(ROOT/'config.json'); checks=[]; namespace='fixtures-only-de-scaffolded-v1'
    for name,h in [('branching',tiny()),('detour',tiny(True)),('in-range',generate(namespace,99,4,4,0)[0])]:
        assert admissible(h) and len(h['bonds'])>=len(h['positions'])
        stream=events(namespace,99,4,0); target=oracle(stream,4)
        for arm in ('D','B'):
            a,c,tr=checked(arm,h); original=execute(a,stream); rp=replay(a,stream,original)
            assert all(score(e,target[e['id']]['value'],original['responses'])['success'] for e in stream if e['kind']=='Q')
            assert all(challenge(a,namespace,99,i)['both'] for i in range(4))
            assert len(original['final']['stored'])==4
            r=Runtime(a); r.run(stream); r.reset(); assert r.s==a['initial']
            # Clone a live buffer/clock and continue; unequal value arrival is
            # accommodated by the same one-slot interface in both constructors.
            init=stream[:4]; r.run(init); q=next(e for e in stream if e['kind']=='Q'); r.inject(q); r.settle(q['t']+1)
            assert r.s['pending'] is not None and r.s['queue']; clone=r.clone()
            assert r.settle(q['t']+GAP-1)==clone.settle(q['t']+GAP-1) and r.s==clone.s
            # Opaque relabel plus record reordering: outputs remain correct.
            mapping={u:f'opaque-{i*37+11}' for i,u in enumerate(reversed(list(h['positions'])))}
            hh=copy.deepcopy(h); hh['positions']={mapping[u]:p for u,p in reversed(list(h['positions'].items()))}
            hh['bonds']=[dict(e,u=mapping[e['u']],v=mapping[e['v']]) for e in reversed(h['bonds'])]
            hh['terminals']=[dict(t,site=mapping[t['site']]) for t in reversed(h['terminals'])]
            aa,_,_=checked(arm,hh); transformed=execute(aa,stream)
            assert [(r['id'],r['v']) for r in original['responses']]==[(r['id'],r['v']) for r in transformed['responses']]
            defects={}
            for fault in ('erase','query_blind','disconnect','duplicate','late'):
                r=Runtime(a); r.fault=fault
                try: output=r.run(stream)['responses']
                except IntegrityError: output=[]
                scores=[score(e,target[e['id']]['value'],output) for e in stream if e['kind']=='Q']
                assert not all(s['success'] for s in scores); defects[fault]=sum(s['success'] for s in scores)
            if arm=='D' and name=='detour':
                # Direct geometric gap is 2, executable query path to W0 is 4.
                u='c000'; steps=0
                while u!='c200': u=a['cells'][u]['routes']['Q0']; steps+=1
                assert steps==4
            checks.append(dict(host=name,arm=arm,cells=len(a['cells']),cost=c,replay=rp,defects=defects,
                               reset=True,live_buffer_clone=True,opaque_relabel=True,pairs_both=4))
    # Scorer's overlapping categories are explicitly inspected.
    e=dict(id=7,t=10,kind='Q',a=0,v=0)
    assert score(e,1,[])['missing']==1
    assert score(e,1,[dict(id=7,t=11,v=-1)])['incorrect']==1
    assert score(e,1,[dict(id=7,t=11,v=1)]*2)['duplicate']==1
    assert score(e,1,[dict(id=7,t=11+DEADLINE,v=1)])['late']==1
    dump(ROOT/'fixture-results.json',dict(passed=True,scored_seeds_used=False,checks=checks,finished_utc=now()))
    print('All semantic, branching/detour, baseline, relabel, scoring and replay fixtures passed.')
def freeze():
    if (ROOT/'freeze.json').exists() or (ROOT/'results').exists(): raise IntegrityError('duplicate freeze forbidden')
    assert load(ROOT/'fixture-results.json')['passed']
    f=dict(sealed_utc=now(),label='local exploratory freeze; not public preregistration',files={n:sha(ROOT/n) for n in FROZEN})
    dump(ROOT/'freeze.json',f); dump(ROOT/'frozen-status.json',dict(active_run=False,execution_status='frozen',publication_status='unprepared'))
    dump(ROOT/'status.json',load(ROOT/'frozen-status.json')); print('Freeze SHA256',sha(ROOT/'freeze.json'))
def main():
    p=argparse.ArgumentParser(); p.add_argument('mode',choices=['fixtures','freeze','smoke','full','rerun']); args=p.parse_args(); cfg=load(ROOT/'config.json')
    if args.mode=='fixtures':
        if (ROOT/'freeze.json').exists(): raise IntegrityError('fixtures must not alter frozen source/evidence')
        fixtures()
    elif args.mode=='freeze': freeze()
    elif args.mode=='smoke':
        check_freeze()
        with tempfile.TemporaryDirectory(prefix='de-scaffolded-smoke-') as d:
            rr=matrix(pathlib.Path(d)/'scores',cfg,(0,cfg['settings'][-1]['id']))
            print(canonical([dict(arm=r['arm'],success=r['case_success']) for r in rr]))
    elif args.mode=='full':
        check_freeze(); status=load(ROOT/'status.json')
        if status['active_run'] or status['execution_status'] not in ('frozen',): raise IntegrityError('active/completed/stopped study forbids duplicate execution')
        status.update(active_run=True,execution_status='running',started_utc=now()); dump(ROOT/'status.json',status)
        try:
            matrix(ROOT/'results',cfg); status.update(execution_status='completed')
        except BaseException:
            status.update(execution_status='stopped'); raise
        finally:
            status.update(active_run=False,ended_utc=now()); dump(ROOT/'status.json',status)
    elif args.mode=='rerun':
        check_freeze()
        # Prespecified group 0, L6 K8 p.15, paired D/B; disposable complete source
        # copy, a fresh interpreter, and no original results at execution time.
        import subprocess
        with tempfile.TemporaryDirectory(prefix='de-scaffolded-author-') as d:
            dest=pathlib.Path(d)
            for n in FROZEN+['freeze.json']: (dest/n).write_bytes((ROOT/n).read_bytes())
            script="import experiment, pathlib; c=experiment.load(experiment.ROOT/'config.json'); experiment.matrix(experiment.ROOT/'rerun-scores',c,(0,c['settings'][-1]['id']))"
            subprocess.run([sys.executable,'-c',script],cwd=dest,check=True)
            rr=load(dest/'rerun-scores/cases.json'); original=load(ROOT/'results/cases.json')
            expected=[r for r in original if r['group']==0 and r['setting']==cfg['settings'][-1]['id']]
            for r in rr:
                q=next(x for x in expected if x['arm']==r['arm'])
                assert r['case_success']==q['case_success'] and r['cost']['circuit_hash']==q['cost']['circuit_hash'] and r['pairs']==q['pairs']
                for e,f in zip(r['episodes'],q['episodes']):
                    for key in ('queries','correct','messages','peak_queue'): assert e[key]==f[key]
                    for key in ('trajectory_hash','final_hash','steps'): assert e['replay'][key]==f['replay'][key]
            dump(ROOT/'author-rerun.json',dict(passed=True,group=0,setting=cfg['settings'][-1]['id'],arms=['D','B'],episodes=8,dependent_pairs=8,
                   matches='circuit hashes, outputs via trajectory hashes, full mutable-state trajectories, final state, pair records and scores',label='author check; not independent reproduction',finished_utc=now()))
            print('Disposable paired held-out author rerun matched.')
if __name__=='__main__': main()
