"""Offline study only. Completed Pure whitelist; never invokes devices/services.
Run PYTHONDONTWRITEBYTECODE=1 python analysis/analyze.py from any directory.
"""
from pathlib import Path
from collections import Counter, defaultdict
import csv, json, hashlib, math, sys, platform, time
import numpy as np
import scipy
import networkx as nx
from infomap import Infomap
import infomap
ROOT=Path(__file__).resolve().parents[1]
REPO=ROOT.parents[1]
OLD=ROOT.parent/'region-action-opportunity-study-20261107'
FIRST=ROOT.parent/'state-dominator-region-investigation-20261107'
PARAMS=dict(infomap_version=infomap.__version__,networkx_version=nx.__version__,numpy_version=np.__version__,scipy_version=scipy.__version__,python=platform.python_version(),infomap_seeds=[11,23,47],markov_times=[.5,1,2],num_trials=5,num_threads=1,two_level=True,directed=True,teleportation_probability=.15,recorded_teleportation=False,self_links='included; no_self_links=False',back_factors={'G1':1,'G2':.25,'G3':0},ppr_alpha=[.75,.85,.95],seed_weighting=['frequency','uniform_targets'],prefixes=[.25,.5,.75],windows=[20,50,100],conductance='stationary directed flow, uniform .15 teleport regularization; sweep ppr/pi, seed-containing, pi-volume<=.5; no feasible set => NO_LOCAL_SOLUTION',state_identity='raw Native APE graph State ID; version is metadata; no final-ID filtering',dominator='native definition with virtual multi-root; A-final exact final topology; A-flow observed segment-start roots proxy, not recovered runtime roots')
READ={}
def read(p):
 p=Path(p);b=p.read_bytes();READ[str(p.relative_to(REPO)) if p.is_relative_to(REPO) else str(p)]=hashlib.sha256(b).hexdigest();return json.loads(b)
def dump(p,obj):
 p=ROOT/p;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(json.dumps(obj,ensure_ascii=False,separators=(',',':'))+'\n')
def csvout(p,rows):
 rows=list(rows);p=ROOT/p;p.parent.mkdir(parents=True,exist_ok=True)
 if not rows:return
 fields=list(dict.fromkeys(k for r in rows for k in r))
 with p.open('w',newline='') as f:
  w=csv.DictWriter(f,fields);w.writeheader();w.writerows({k:json.dumps(v,ensure_ascii=False) if isinstance(v,(dict,list,tuple)) else v for k,v in r.items()} for r in rows)
def q(v,p):return round(float(np.quantile(v,p)),4) if v else None
def gap(a,b):
 out=[]
 for ok,why in [(b['native_transition_order']==a['native_transition_order']+1,'unconfirmed_native_gap'),(a['target_state']==b['state'],'state_gap'),(a['target_gui_id']==b['source_gui_id'] and a['target_gui_timestamp']==b['source_gui_timestamp'],'GUI_gap'),(a['version']==b['input_version'],'abstraction_gap'),(a['monotonic_ns']<b['monotonic_ns'],'time_gap')]:
  if not ok:out.append(why)
 return out

def graph(es,fac=1,versioned=False):
 g=nx.DiGraph()
 def node(e,t=False):return str(e['version'] if t else e['input_version'])+':'+e['target_state' if t else 'state'] if versioned else e['target_state' if t else 'state']
 for e in es:
  u,v=node(e),node(e,True);g.add_nodes_from([u,v]);w=fac if e['action_type']=='MODEL_BACK' else 1
  if w:g.add_edge(u,v,weight=g.get_edge_data(u,v,{}).get('weight',0)+w)
 return g

def partition(g,seed=11,markov=1):
 ids={s:i for i,s in enumerate(sorted(g))};rev={i:s for s,i in ids.items()}
 im=Infomap(directed=True,two_level=True,seed=seed,num_trials=5,num_threads=1,silent=True,markov_time=markov,teleportation_probability=.15,no_self_links=False)
 for s,i in ids.items():im.add_node(i)
 for u,v,d in g.edges(data=True):im.add_link(ids[u],ids[v],float(d['weight']))
 im.run();mods=im.get_modules();groups=defaultdict(list)
 for i,m in mods.items():groups[m].append(rev[i])
 return sorted([sorted(v) for v in groups.values()],key=lambda x:(-len(x),x)),float(im.codelength)

def dominators(g,roots):
 ng=g.copy();rr='__observed_super_root__';ng.add_node(rr);ng.add_edges_from((rr,r) for r in roots if r in ng)
 idom=nx.immediate_dominators(ng,rr);children=defaultdict(list)
 for v,p in idom.items():
  if v!=rr:children[p].append(v)
 def desc(v):
  out={v}
  for c in children[v]:out|=desc(c)
  return out
 return [(h,sorted(desc(h))) for h in sorted(set(idom)-set(roots)-{rr}) if children[h]]

def stationary(g):
 nodes=sorted(g);ix={s:i for i,s in enumerate(nodes)};n=len(nodes);P=np.zeros((n,n))
 for u,v,d in g.edges(data=True):P[ix[u],ix[v]]+=d['weight']
 sums=P.sum(axis=1)
 for i in range(n):P[i]=P[i]/sums[i] if sums[i] else np.ones(n)/n
 # Dense arrays only for small (<500 State) completed graphs; library PageRank solver.
 pi=nx.pagerank(g,alpha=.85,tol=1e-12,max_iter=5000)
 return nodes,ix,P,np.array([pi[s] for s in nodes])

def ppr(g,counts,alpha=.85,uniform=False,cached=None):
 nodes,ix,P,pi=cached or stationary(g);n=len(nodes)
 seed={s:(1 if uniform else c) for s,c in counts.items() if s in g};tot=sum(seed.values());pers={s:seed.get(s,0)/tot for s in g}
 pr=nx.pagerank(g,alpha=alpha,personalization=pers,dangling=pers,tol=1e-12,max_iter=5000)
 ranking=sorted(nodes,key=lambda s:(-pr[s]/pi[ix[s]],s));vol=0.;flowcut=0.;inset=np.zeros(n,dtype=bool);sweeps=[];best=None;allbest=None
 # Stationary regularized flow F_ij=pi_i*(.85 P_ij+.15/n). Increment cut without materializing new graphs.
 Q=.85*P+.15/n;F=pi[:,None]*Q
 seedset=set(seed);covered=set()
 for k,s in enumerate(ranking[:-1],1):
  i=ix[s];flowcut+=float(F[i,~inset].sum()-F[inset,i].sum()-F[i,i]);inset[i]=True;vol+=float(pi[i]);covered.add(s)
  phi=max(0.,flowcut)/min(vol,1-vol) if 0<vol<1 else None;contains=seedset<=covered
  row=dict(k=k,state_added=s,volume=round(vol,10),cut=round(max(0.,flowcut),10),conductance=round(phi,10) if phi is not None else None,all_seeds=contains,local_feasible=contains and vol<=.5)
  sweeps.append(row)
  if contains and (allbest is None or phi<allbest[0]):allbest=(phi,k)
  if contains and vol<=.5 and (best is None or phi<best[0]):best=(phi,k)
 members=ranking[:best[1]] if best else []
 scores=[dict(state=s,ppr=pr[s],stationary=float(pi[ix[s]]),ratio=float(pr[s]/pi[ix[s]]),seed_executions=seed.get(s,0)) for s in ranking]
 return members,dict(status='OK' if best else 'NO_LOCAL_SOLUTION',alpha=alpha,seed_weighting='uniform_targets' if uniform else 'frequency',seeds=seed,conductance=best[0] if best else None,unconstrained_best=allbest,unconstrained_members=ranking[:allbest[1]] if allbest else [],scores=scores,sweep=sweeps)

def episodes(es,members):
 R=set(members);eps=[];curr=None
 for e in es:
  seg=e['segment'];i=e['order'];u=e['state'] in R;v=e['target_state'] in R
  if curr and (curr['segment']!=seg or not u):
   curr['right_censored']=True;eps.append(curr);curr=None
  if u and curr is None:curr=dict(start=i,end=i,segment=seg,left_censored=True,entry_event=None,source_steps=0,internal=0,exit_event=None,new_state=0,new_transition=0,native_new_model=0)
  if u:
   curr['end']=i;curr['source_steps']+=1;curr['internal']+=int(v);curr['new_state']+=int(e['new_state'] and v);curr['new_transition']+=int(e['first_model_transition_flow'] and v);curr['native_new_model']+=int(e['new_model_action'])
  if not u and v:curr=dict(start=i,end=i,segment=seg,left_censored=False,entry_event=e['transition_id'],source_steps=0,internal=0,exit_event=None,new_state=int(e['new_state']),new_transition=int(e['first_model_transition_flow']),native_new_model=int(e['new_model_action']))
  if u and not v:
   curr['exit_event']=e['transition_id'];curr['exit_back']=e['action_type']=='MODEL_BACK';curr['right_censored']=False;eps.append(curr);curr=None
 if curr:curr['right_censored']=True;eps.append(curr)
 return eps

def metrics(es,R,details=False):
 R=set(R);offset=es[0]['order'] if es else 0;eps=episodes(es,R);inside=[e for e in es if e['state'] in R];internal=[e for e in inside if e['target_state'] in R];incoming=[e for e in es if e['state'] not in R and e['target_state'] in R];outs=[e for e in inside if e['target_state'] not in R]
 dwell=[p['source_steps'] for p in eps if not p['left_censored'] and not p['right_censored']];observed=[p['source_steps'] for p in eps];reentries=0;perseg=Counter()
 for p in eps:
  if p['left_censored']:perseg[p['segment']]+=1
  else:
   if perseg[p['segment']]:reentries+=1
   perseg[p['segment']]+=1
 late=[e for e in inside if e['order']>=offset+int(len(es)*.8)];sel=[e['latest_selection'] for e in inside if e.get('latest_selection') and e['latest_selection'].get('state_id')==e['state'] and e['latest_selection']['monotonic_ns']<=e['input_ns'] and e['latest_selection'].get('abstraction_version')==e['input_version']]
 def counts(evs):return dict(steps=len(evs),new_state=sum(e['new_state'] for e in evs),new_state_target_inside=sum(e['new_state'] for e in evs if e['target_state'] in R),new_state_target_outside=sum(e['new_state'] for e in evs if e['target_state'] not in R),new_transition=sum(e['first_model_transition_flow'] for e in evs),new_state_pair=sum(e['first_pair_flow'] for e in evs),native_new_model=sum(e['new_model_action'] for e in evs))
 patterns=Counter()
 for p in eps:
  seq=es[p['start']-offset:p['end']-offset+1]
  if not p['left_censored'] and not p['right_censored']:
   patterns[tuple((e['state'],e['model_action'],e['target_state']) for e in seq)]+=1
 r=dict(size=len(R),observed_members=len(R & {e['state'] for e in es}),nonself_internal_flow=sum(e['state']!=e['target_state'] for e in internal),nonself_outgoing_flow=sum(e['state']!=e['target_state'] for e in inside),source_steps=len(inside),budget_share=len(inside)/len(es) if es else 0,internal_flow=len(internal),outgoing_flow=len(inside),incoming_flow=len(incoming),boundary_flow=len(incoming)+len(outs),p_stay=len(internal)/len(inside) if inside else None,confirmed_entries=len(incoming),confirmed_exits=len(outs),confirmed_reentries_within_segment=reentries,observed_episodes=len(eps),left_censored=sum(p['left_censored'] for p in eps),right_censored=sum(p['right_censored'] for p in eps),complete_dwell_count=len(dwell),dwell_mean=float(np.mean(dwell)) if dwell else None,dwell_median=q(dwell,.5),dwell_p90=q(dwell,.9),dwell_max=max(dwell,default=None),observed_dwell_max=max(observed,default=0),back_exits=sum(e['action_type']=='MODEL_BACK' for e in outs),late=counts(late),all=counts(inside),native_snapshot_count=len(sel),native_saturated_snapshot_count=sum(s.get('native_state_saturated') is True for s in sel),native_unvisited_positive=sum(s.get('unvisited_model_action_count',0)>0 for s in sel),most_repeated_complete_path=max(patterns.values(),default=0),distinct_complete_paths=len(patterns))
 if details:r.update(episodes=eps,top_paths=[dict(count=n,path=list(p)) for p,n in patterns.most_common(3)],entry_edges=[dict(source=u,action=a,target=v,count=c) for (u,a,v),c in Counter((e['state'],e['model_action'],e['target_state']) for e in incoming).most_common(15)],exit_edges=[dict(source=u,action=a,target=v,count=c) for (u,a,v),c in Counter((e['state'],e['model_action'],e['target_state']) for e in outs).most_common(15)],bins=[counts([e for e in inside if offset+int(len(es)*j/10)<=e['order']<offset+int(len(es)*(j+1)/10)]) for j in range(10)])
 return r

def overlap(a,b):return len(set(a)&set(b))/len(set(a)|set(b)) if set(a)|set(b) else 1.
def splitgroups(frozen,es):
 byid={e['transition_id']:e for e in es};out=[]
 for group in frozen:
  acts=defaultdict(list)
  for t in group['transition_ids']:
   if t in byid:
    e=byid[t];acts[e['source_activity'] or 'UNKNOWN'].append(e)
  for act,evs in sorted(acts.items()):
   # Unknown Activity never links separate Source States into a functional group.
   sub=defaultdict(list)
   for e in evs:sub[e['state'] if act=='UNKNOWN' else act].append(e)
   for scope,ee in sub.items():out.append(dict(function=group['function'],source_activity=act,scope=scope,executions=len(ee),source_states=sorted({e['state'] for e in ee}),model_actions=sorted({e['model_action'] for e in ee}),concrete_identities=sorted({e['identity_digest'] for e in ee if e['identity_digest']}),targets=dict(Counter(e['target_state'] for e in ee)),events=[e['transition_id'] for e in ee],first_order=min(e['order'] for e in ee),label='inherited exploratory, not human gold'))
 return out

def cooccur(es,regions):
 nodes=sorted({e['state'] for e in es}|{e['target_state'] for e in es});ix={s:i for i,s in enumerate(nodes)};segments=defaultdict(list)
 for e in es:segments[e['segment']].append(e['state'])
 rows=[]
 for W in [20,50,100]:
  co=np.zeros((len(nodes),len(nodes)),dtype=np.int32);nw=0
  for seq in segments.values():
   for i in range(len(seq)-W+1):
    ids=[ix[s] for s in set(seq[i:i+W])];co[np.ix_(ids,ids)]+=1;nw+=1
  cnt=np.diag(co).copy();union=cnt[:,None]+cnt[None,:]-co;sim=np.divide(co,union,out=np.zeros(co.shape,dtype=float),where=union>0)
  for r in regions:
   ids=[ix[s] for s in r['members'] if s in ix and cnt[ix[s]]];other=[i for i in range(len(nodes)) if nodes[i] not in set(r['members']) and cnt[i]]
   vals=[sim[i,j] for z,i in enumerate(ids) for j in ids[z+1:]];cross=sim[np.ix_(ids,other)].ravel() if ids and other else []
   # Frequency-matched circular null preserves each node's marginal window presence.
   # Fixed random permutation of window membership not needed: independent expectation p_i*p_j.
   null=[(cnt[i]*cnt[j]/nw)/(cnt[i]+cnt[j]-cnt[i]*cnt[j]/nw) for z,i in enumerate(ids) for j in ids[z+1:]] if nw else []
   rows.append(dict(region=r['id'],window=W,complete_windows=nw,observed_members=len(ids),within_pair_count=len(vals),within_mean=float(np.mean(vals)) if vals else None,cross_mean=float(np.mean(cross)) if len(cross) else None,independent_marginal_jaccard=float(np.mean(null)) if null else None))
 return rows

def main():
 t0=time.time();dump('analysis/parameters.json',PARAMS)
 manifest=read(OLD/'evidence/archive-manifest.json');assert len(manifest)==10 and all(m['status']=='COMPLETED_VALID' for m in manifest)
 frozen=read(OLD/'analysis/frozen-entry-groups.json');p=OLD/'analysis/events.jsonl';raw=p.read_bytes();READ[str(p.relative_to(REPO))]=hashlib.sha256(raw).hexdigest();all_events=[json.loads(l) for l in raw.splitlines()];assert len(all_events)==23279
 grouped=defaultdict(list)
 for e in all_events:grouped[e['app']+'-'+e['rep']].append(e)
 summaries=[];allmetrics=[];sensitivity=[];prefixrows=[];ccrows=[];seedrows=[];auto=[]
 for meta in manifest:
  rid=meta['app']+'-'+meta['rep'];es=grouped[rid];assert len(es)==int(meta['confirmed_transitions']);assert all(e['run_id']==meta['run_id'] and e['raw_event']['input_confirmed'] for e in es)
  print('RUN',rid,len(es),flush=True);base=Path(meta['graph_path']).parent;native=read(meta['graph_path']);assert hashlib.sha256(Path(meta['graph_path']).read_bytes()).hexdigest()==meta['graph_sha256']
  # Cross-check every retained Native record against the completed run's original event line.
  rawlines=(base/'jev-events.jsonl').read_bytes();READ[str((base/'jev-events.jsonl').relative_to(REPO))]=hashlib.sha256(rawlines).hexdigest();rawlines=rawlines.splitlines()
  seenstates=set();seenpairs=set();seenmodel=set();seg=0;gaps=Counter();states=defaultdict(lambda:dict(activities=Counter(),versions=set(),visits=0,first_order=None));edges=Counter();actionedges=Counter();byid={e['transition_id']:e for e in es}
  for i,e in enumerate(es):
   assert e['order']==i;orig=json.loads(rawlines[e['event_line']-1]);assert orig==e['raw_event'],(rid,e['transition_id'])
   reasons=gap(es[i-1],e) if i else ['run_start'];seg+=bool(reasons and i);e['segment']=seg;e['gap_before']=reasons
   for reason in reasons:gaps[reason]+=1
   u,v=e['state'],e['target_state'];e['first_pair_flow']=(u,v) not in seenpairs;e['first_model_transition_flow']=(u,e['model_action'],v) not in seenmodel;e['first_observed_target_raw']=v not in seenstates;seenstates.update([u,v]);seenpairs.add((u,v));seenmodel.add((u,e['model_action'],v));edges[u,v]+=1;actionedges[u,e['model_action'],v,e['action_type']]+=1
   for s,activity,ver in [(u,e['source_activity'],e['input_version']),(v,e['target_activity'],e['version'])]:
    z=states[s];z['versions'].add(ver)
    if activity:z['activities'][activity]+=1
    if z['first_order'] is None:z['first_order']=i
   states[u]['visits']+=1
  # Confirmed GUI actions are atomic measured links. No artificial links between segments.
  g=graph(es);roots=sorted({e['state'] for e in es if e['gap_before']});gs={name:graph(es,f) for name,f in PARAMS['back_factors'].items()}
  regions=[];rnum=Counter()
  def add(method,members,**extra):
   rnum[method]+=1;id=f'{method}-{rnum[method]:03d}';m=metrics(es,members,True);r=dict(id=id,method=method,members=sorted(members),metrics=m,**extra);regions.append(r);return r
  prev=read(FIRST/f'analysis/{rid}.json')
  for r in prev['regions']:
   if r['region_size']>=2:add('A-final',r['members'],header=r['header'],root_provenance='exact final topology only',selected_projection=r['selected_projection'])
  for h,mm in dominators(g,roots):add('A-flow',mm,header=h,root_provenance='observed segment-start proxy; not runtime root history')
  parts={};codelengths={}
  for variant,gg in gs.items():
   pp,cl=partition(gg);parts[variant]=pp;codelengths[variant]=cl
   for j,mm in enumerate(pp):add('Infomap-'+variant,mm,module=j+1,graph_variant=variant,codelength=cl)
  # Partition sensitivity, with size reported and adjusted Rand (not raw label IDs).
  from sklearn.metrics import adjusted_rand_score
  nodes=sorted(g)
  def labels(pp):
   z={s:i for i,c in enumerate(pp) for s in c};return [z.get(s,-1) for s in nodes]
  base_labels=labels(parts['G1'])
  for variant,gg in gs.items():
   for sd in [11,23,47]:
    for mt in [.5,1,2]:
     pp,cl=(parts[variant],codelengths[variant]) if sd==11 and mt==1 else partition(gg,sd,mt)
     sensitivity.append(dict(run=rid,method='Infomap',graph=variant,seed=sd,markov_time=mt,modules=len(pp),largest=max(map(len,pp)),codelength=cl,ARI_vs_G1_default=float(adjusted_rand_score(base_labels,labels(pp)))))
  # Known functional groups split by source Activity, never new semantic classification.
  groups=splitgroups([f for f in frozen if f['run']==rid],es)
  for gi,group in enumerate(groups):group['id']=f'entry-{gi+1:03d}';seedrows.append(dict(run=rid,**group))
  PPR=[]
  for variant,gg in gs.items():
   cache=stationary(gg)
   for group in groups:
    for alpha in [.75,.85,.95]:
     for uniform in [False,True]:
      mm,info=ppr(gg,group['targets'],alpha,uniform,cache);pid=f'{group["id"]}-{variant}-{alpha}-'+('uniform' if uniform else 'freq')
      PPR.append(dict(id=pid,entry=group['id'],graph=variant,members=mm,**info))
      if alpha==.85 and not uniform:add('PPR-'+variant,mm,entry=group['id'],function=group['function'],source_activity=group['source_activity'],ppr_id=pid,status=info['status'],seeds=group['targets'],conductance=info['conductance'])
      sensitivity.append(dict(run=rid,method='PPR',entry=group['id'],function=group['function'],graph=variant,alpha=alpha,seed_weighting=info['seed_weighting'],size=len(mm),status=info['status'],conductance=info['conductance'],unconstrained_size=len(info['unconstrained_members']),internal_flow=sum(d['weight'] for u,v,d in gg.edges(data=True) if u in set(mm) and v in set(mm)),boundary_flow=sum(d['weight'] for u,v,d in gg.edges(data=True) if (u in set(mm))!=(v in set(mm)))))
  # Entry association is measured target containment and continuous next-5 behavior, not semantic purity.
  for r in regions:
   R=set(r['members']);associations=[]
   for group in groups:
    hit=sum(c for s,c in group['targets'].items() if s in R);nex=[]
    for t in group['events']:
     e=byid[t];nex.extend(x for x in es[e['order']+1:e['order']+6] if x['segment']==e['segment'])
    n=sum(e['state'] in R for e in nex)
    if hit or n:associations.append(dict(entry=group['id'],function=group['function'],executions=group['executions'],entry_targets_in_region=hit,target_fraction=hit/group['executions'],next5_source_steps=len(nex),next5_in_region=n,next5_fraction=n/len(nex) if nex else None))
   r['entry_associations']=sorted(associations,key=lambda x:(-x['entry_targets_in_region'],-x['next5_in_region']))
   allmetrics.append(dict(run=rid,region=r['id'],method=r['method'],header=r.get('header'),function=r.get('function'),members=r['members'],**r['metrics']))
  # Cooccurrence is additional time evidence; independent marginal expectation controls visit prevalence partially.
  cr=cooccur(es,[r for r in regions if r['method'] in ['A-final','A-flow','Infomap-G1','PPR-G1']]);ccrows.extend(dict(run=rid,**x) for x in cr)
  # True prefix training. Only prefix events/observed labels/roots; never final topology or future seeds.
  prefixdata=[]
  for fraction in [.25,.5,.75]:
   cutoff=math.floor(len(es)*fraction);train=es[:cutoff];future=es[cutoff:];proots={e['state'] for e in train if e['gap_before']};pgroups=splitgroups([f for f in frozen if f['run']==rid],train)
   for variant,fac in PARAMS['back_factors'].items():
    pg=graph(train,fac);candidates=[]
    if variant=='G1':candidates += [dict(method='A-flow',members=mm,header=h) for h,mm in dominators(pg,proots)]
    pp,cl=partition(pg);candidates += [dict(method='Infomap-'+variant,members=mm) for mm in pp]
    cache=stationary(pg)
    for group in pgroups:
     mm,info=ppr(pg,group['targets'],cached=cache);candidates.append(dict(method='PPR-'+variant,members=mm,function=group['function'],source_activity=group['source_activity'],status=info['status'],seed_executions=group['executions']))
    for c in candidates:
     R=set(c['members']);assert R<=set(pg);tm=metrics(train,R);fm=metrics(future,R);row=dict(run=rid,prefix=fraction,cutoff_order=cutoff,graph=variant,**c,training=tm,holdout=fm,holdout_unseen_target_states=sum(e['target_state'] not in pg for e in future),future_membership='frozen prefix members only; unseen States excluded, not retroactively assigned')
     prefixdata.append(row);prefixrows.append(row)
  # Version-scoped sensitivity quantifies artificial node splitting without assigning semantic aliases.
  vg=graph(es,versioned=True);vp,vcl=partition(vg)
  version_sensitivity=dict(raw_states=len(g),version_scoped_states=len(vg),raw_modules=len(parts['G1']),version_scoped_modules=len(vp),version_scoped_partition=vp,codelength=vcl)
  # Preserve per-action source/target frequency, not one topology edge = one execution.
  csvout(f'analysis/edges/{rid}.csv',[dict(source=u,target=v,executions=w,back_executions=sum(c for (s,a,t,k),c in actionedges.items() if s==u and t==v and k=='MODEL_BACK')) for (u,v),w in edges.items()])
  csvout(f'analysis/action-edges/{rid}.csv',[dict(source=u,model_action=a,target=v,action_type=k,executions=w) for (u,a,v,k),w in actionedges.items()])
  compact=[]
  keep=['app','rep','run_id','order','step','transition_id','event_line','monotonic_ns','wall_time_ms','input_ns','state','model_action','target_state','version','input_version','action_type','identity_digest','identity','resource_id','text','content_desc','class','bounds','node_path','source_activity','target_activity','source_gui_id','target_gui_id','source_gui_timestamp','target_gui_timestamp','input_path','receipt_path','new_state','new_model_action','latest_selection','input_model_action','label','raw_event','inventory_level','segment','gap_before','first_model_transition_flow','first_pair_flow','first_observed_target_raw']
  for e in es:compact.append({k:e.get(k) for k in keep})
  outpath=ROOT/f'analysis/traces/{rid}.jsonl';outpath.parent.mkdir(exist_ok=True)
  outpath.write_text(''.join(json.dumps(e,ensure_ascii=False,separators=(',',':'))+'\n' for e in compact))
  stateout={s:dict(activities=dict(v['activities']),versions=sorted(v['versions']),visits=v['visits'],first_order=v['first_order']) for s,v in states.items()}
  summary=dict(run=rid,app=meta['app'],rep=meta['rep'],run_id=meta['run_id'],executions=len(es),states=len(g),state_pairs=g.number_of_edges(),model_transition_triples=len(actionedges),self_loops=sum(e['state']==e['target_state'] for e in es),back_executions=sum(e['action_type']=='MODEL_BACK' for e in es),segments=seg+1,discontinuities=dict(gaps),unconfirmed_native=int(meta['native_transitions'])-len(es),restart_count=None,restart_status='not separately certified; all gaps segment, no inferred restart links',final_states=len(native['states']),final_id_events=sum(e['state'] in {s['id'] for s in native['states']} and e['target_state'] in {s['id'] for s in native['states']} for e in es),A_final_non_singleton=sum(r['method']=='A-final' for r in regions),A_flow_non_singleton=sum(r['method']=='A-flow' for r in regions),infomap_modules={k:len(v) for k,v in parts.items()},entry_groups=len(groups),ppr_local_solutions=sum(p['status']=='OK' and p['graph']=='G1' and p['alpha']==.85 and p['seed_weighting']=='frequency' for p in PPR),version_sensitivity=version_sensitivity)
  summaries.append(summary)
  dump(f'analysis/runs/{rid}.json',dict(summary=summary,states=stateout,edges=[dict(source=u,target=v,weight=w) for (u,v),w in edges.items()],entry_groups=groups,regions=regions,prefix=prefixdata,version_sensitivity=version_sensitivity))
  dump(f'analysis/ppr/{rid}.json',PPR)
  for r in regions:
   if r['method']=='Infomap-G1' and r['metrics']['size']>=2:auto.append(dict(run=rid,region=r['id'],members=r['members'],**r['metrics']))
  print('DONE',rid,'modules',summary['infomap_modules'],'elapsed',round(time.time()-t0),flush=True)
 dump('analysis/run-summary.json',summaries);csvout('analysis/run-summary.csv',[{k:v for k,v in r.items() if k!='version_sensitivity'} for r in summaries]);dump('analysis/region-metrics.json',allmetrics);csvout('analysis/region-metrics.csv',[{k:v for k,v in r.items() if k not in ['episodes','top_paths','entry_edges','exit_edges','bins']} for r in allmetrics]);dump('analysis/sensitivity.json',sensitivity);csvout('analysis/sensitivity.csv',sensitivity);dump('analysis/prefix-validation.json',prefixrows);csvout('analysis/prefix-validation.csv',prefixrows);csvout('analysis/cooccurrence.csv',ccrows);dump('analysis/entry-groups.json',seedrows);csvout('analysis/entry-groups.csv',seedrows)
 rankings={}
 for name,key in [('internal_flow',lambda r:r['internal_flow']),('complete_dwell_p90',lambda r:r['dwell_p90'] or -1),('reentries',lambda r:r['confirmed_reentries_within_segment']),('late_discovery_high_flow',lambda r:r['late']['new_transition'] if r['internal_flow']>=100 else -1)]:rankings[name]=[dict(rank=i+1,**r) for i,r in enumerate(sorted(auto,key=key,reverse=True)[:3])]
 dump('analysis/automatic-top3.json',rankings);dump('evidence/read-provenance.json',READ);dump('analysis/computation.json',dict(status='COMPLETE',seconds=time.time()-t0,confirmed_executions=sum(r['executions'] for r in summaries),runs=len(summaries),regions=len(allmetrics),parameters=PARAMS))
if __name__=='__main__':main()
