"""Session62: faithful derived boundary ledger and fixed known-answer controls.
Keeps master immutable. Source observations are separate from interpreted roles.
Run beside retained JSON ledgers, lp_core.py, master and original JPEGs.
"""
from pathlib import Path
from collections import Counter,defaultdict
import json,hashlib,re
import numpy as np
from lp_core import R,LET,pages,vals,SEQS
ROOT=Path(__file__).resolve().parent
sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
jread=lambda n:json.loads((ROOT/n).read_text())
def archive(i):return '01.jpg'if i==0 else f'{i+2:02d}.jpg'

def ledger():
 refs=['boundary_model_plan.json','boundary_control_alignments.json','source_event_ledger.json','separator_pixel_results.json','three_dot_review_results.json','typography_review_results.json','typography_screen_results.json']
 source_hashes={x['file']:x['sha256']for x in jread('jpeg_source_manifest.json')['files']};pmap={archive(i):i for i in range(len(pages))};phys={};byimage=defaultdict(list)
 def add(image,gap,color,dots,bbox,provenance,line=None,interpretation=None):
  key=(image,color,tuple(bbox));assert 0<=gap<=len(vals(pages[pmap[image]]))
  if key in phys:
   e=phys[key];assert e['rune_gap']==gap and e['dot_count']==dots,(e,gap,dots)
  else:
   e={'event_id':f"{image}:{color}:"+','.join(map(str,bbox)),'image':image,'image_sha256':source_hashes[image],'rune_gap':gap,'color':color,'dot_count':dots,'bbox':bbox,'evidence':[],'interpretations':[]};phys[key]=e;byimage[image].append(e)
  e['evidence'].append(provenance)
  if line is not None:
   if 'line'in e:assert e['line']==line
   e['line']=line
  if interpretation and interpretation not in e['interpretations']:e['interpretations'].append(interpretation)
 for e in jread('source_event_ledger.json')['events']:add(e['image'],e['page_rune_offset'],e['color'],e['dot_count'],e['bbox'],{'file':'source_event_ledger.json','event_id':e['event_id']},interpretation=e['role_interpretation'])
 for r in jread('separator_pixel_results.json')['records']:
  for e in r['events']:add(r['source'],e['page_rune_gap'],'black',e['dot_count'],e['bbox'],{'file':'separator_pixel_results.json','event_id':e['event_id']},line=e['line'])
 for c in jread('three_dot_review_results.json')['candidate_records']:
  if c['review_class']!='text':continue
  add(c['source'],c['page_rune_gap'],'black',3,c['bbox'],{'file':'three_dot_review_results.json','candidate_id':c['id']},line=c['line'],interpretation=c['physical_class'])
  if 'paired_fragment'in c:
   f=c['paired_fragment'];add(c['source'],f['same_rune_gap'],'black',1,f['bbox'],{'file':'three_dot_review_results.json','paired_with':c['id']},line=f['line'],interpretation='Separate physical fragment at the same gap; split-symbol semantics unresolved.')
 for c in jread('typography_review_results.json')['candidate_records']:
  if 'page_rune_gap'in c:add(c['source'],c['page_rune_gap'],'black',c['detections'][0]['dot_count'],c['bbox'],{'file':'typography_review_results.json','candidate_id':c['id']},line=c['line'])
 screen={c['id']:c for c in jread('typography_screen_results.json')['candidates']}
 for a in jread('boundary_control_alignments.json')['records']:
  c=screen[a['candidate_id']];assert c['source']==a['source']and all(d['dot_count']==a['dot_count']for d in c['detections'])
  add(a['source'],a['page_rune_gap'],'black',a['dot_count'],c['bbox'],{'file':'boundary_control_alignments.json','candidate_id':a['candidate_id']},line=a['line'])
 for image in byimage:assert sha(ROOT/image)==source_hashes[image]
 page_records=[];overlays=[];splits=[]
 for i,s in enumerate(pages):
  image=archive(i);v=vals(s);gaps={};off=0;line=1
  def gap(k):
   return gaps.setdefault(k,{'rune_gap':k,'literal_events':[],'physical_events':[]})
  gap(0)['page_start']=True
  for pos,ch in enumerate(s):
   if ch in R:off+=1
   elif not ch.isspace():
    kind='line_wrap'if ch=='/'else'master_punctuation'if ch in '-.,;\'"'else'master_structure'if ch in '&$§'else'nonrunic_data'
    gap(off)['literal_events'].append({'character':ch,'line':line,'raw_character_index':pos,'kind':kind})
   if ch=='/':line+=1
  gap(off)['page_end']=True
  for e in byimage[image]:gap(e['rune_gap'])['physical_events'].append(e)
  for k,g in sorted(gaps.items()):
   es=sorted(g['physical_events'],key=lambda e:(e['bbox'][1],e['bbox'][0]));g['physical_events']=es
   black=[e for e in es if e['color']=='black'];counts=[e['dot_count']for e in black];literal=''.join(e['character']for e in g['literal_events']if e['kind']=='master_punctuation');g['literal_punctuation']=literal
   if counts in ([3,1],[1,3]):
    label='split_'+('_'.join(map(str,counts)));g['observed_black_class']=label;g['semantic_status']='Unresolved split fragment pair; do not force to period.';splits.append({'image':image,'rune_gap':k,'dot_counts':counts,'event_ids':[e['event_id']for e in black]})
   elif len(black)==1:g['observed_black_class']={1:'single_dot',3:'three_dot',4:'four_dot'}.get(counts[0],f'{counts[0]}_dots')
   elif black:g['observed_black_class']='multiple_physical_events'
   else:g['observed_black_class']='not_audited_here'
   if counts==[3]and literal=='-':
    g['overlay']='master_word_separator_has_observed_three_dot_shape';overlays.append({'image':image,'parsed_page_index':i,'lp2_page':i-15 if i>=15 else None,'rune_gap':k,'literal':'-','observed':'three_dot','event_ids':[e['event_id']for e in black]})
   g['role_limit']='Physical class does not specify a key operation.'
  page_records.append({'image':image,'parsed_page_index':i,'lp2_page':i-15 if i>=15 else None,'runes':len(v),'rune_stream_sha256':hashlib.sha256(bytes(map(int,v))).hexdigest(),'boundaries':[gaps[k]for k in sorted(gaps)]})
 assert {(x['image'],x['rune_gap'])for x in overlays}=={('15.jpg',60),('55.jpg',28),('63.jpg',96)}
 assert {(x['image'],x['rune_gap'])for x in splits}=={('22.jpg',110),('28.jpg',250),('37.jpg',134)}
 out={'master_sha256':sha(ROOT/'liber-primus__transcription--master.txt'),'input_hashes':{n:sha(ROOT/n)for n in refs},'scope':'All73 parsed master text/data pages; partial physical-source coverage. Whitespace omitted from literal-event lists; master retained for exact source formatting.','source_policy':'Unique physical events are deduplicated only by image,color,bbox with identical rune gap and dot count. Evidence references accumulate; distinct co-located marks remain distinct.','master_unchanged':True,'page_count':len(page_records),'rune_count':sum(p['runes']for p in page_records),'boundary_count':sum(len(p['boundaries'])for p in page_records),'physical_event_count':len(phys),'physical_events_by_color':dict(Counter(e['color']for e in phys.values())),'images_with_physical_evidence':len({e['image']for e in phys.values()}),'overlay_count':len(overlays),'overlays':overlays,'split_pairs':splits,'pages':page_records}
 (ROOT/'boundary_model.json').write_text(json.dumps(out,ensure_ascii=False,indent=2)+'\n');return out

def decode(c,key,skips,actions,operation):
 k=0;indices=[];d=[]
 for i,ch in enumerate(c):
  if i in actions:
   if operation=='reset':k=0
   elif operation=='advance':k+=1
  indices.append(k)
  if i in skips:d.append(int(ch))
  else:d.append(int((ch-key(k))%29));k+=1
 return np.array(d),np.array(indices)

def controls(book):
 byimage={p['image']:p for p in book['pages']};key=vals('ᚠᛁᚱᚠᚢᛗᚠᛖᚱᛖᚾᚠᛖ');known=jread('solved_punctuation_results.json');known_text={r['file']:r['plaintext_with_source_delimiters']for r in known['records']}
 policies=[('continuous',None,None),('reset_at_observed_three_dot','three_dot','reset'),('advance_one_at_observed_three_dot','three_dot','advance'),('reset_at_observed_four_dot','four_dot','reset'),('reset_at_audited_red_mark','red','reset'),('reset_at_page_boundary','page','reset')]
 specs=[('second_koan',['14.jpg','15.jpg'],lambda k:int(key[k%len(key)]),{49,58}),('LP2_page56',['73.jpg'],lambda k:int(SEQS['prime_minus_one'][k]),{56})];results=[]
 for name,images,keyfn,skips in specs:
  texts=[pages[byimage[im]['parsed_page_index']]for im in images];s='%'.join(texts);c=vals(s);base,bi=decode(c,keyfn,skips,set(),None)
  actual_letters=''.join(LET[int(x)]for x in base)
  if name=='second_koan':
   ref=' '.join(l.split('\t')[-1].strip()for l in(ROOT/'liber-primus__translation.txt').read_text().splitlines()if l.startswith('0.4.0.'))
   ref=''.join(ch for ch in ref.upper().translate(str.maketrans({'C':'K','Q':'K','V':'U','Z':'S'}))if ch.isalpha());assert actual_letters==ref
   spaced=re.sub(r'\s+',' ',re.sub(r'[-.,;]+',' ',s.replace('/','').replace('\n','').replace('&','').replace('$','').replace('"','').replace("'",'').replace('%',''))).strip();off=0;conversion=[]
   for j,ch in enumerate(spaced):
    if ch in R:
     if off in skips:conversion.append({'one_based_spaced_input_character':j+1,'zero_based_rune_offset':off})
     off+=1
   assert conversion==[{'one_based_spaced_input_character':65,'zero_based_rune_offset':49},{'one_based_spaced_input_character':76,'zero_based_rune_offset':58}]
   reference={'file':'liber-primus__translation.txt','sha256':sha(ROOT/'liber-primus__translation.txt'),'comparison':'Entire second-koan alphabetic reading, normalized C/Q->K,V->U,Z->S and ignoring word/punctuation layout.','skip_position_conversion':conversion}
  else:
   it=iter(base);rendered=''.join(LET[int(next(it))]if ch in R else ch for ch in s);assert rendered==known_text['73.jpg'];reference={'file':'solved_punctuation_results.json','sha256':sha(ROOT/'solved_punctuation_results.json'),'comparison':'Exact retained known plaintext with original master delimiters and non-runic data.'}
  roundtrip=np.array([base[i]if i in skips else(base[i]+keyfn(int(bi[i])))%29 for i in range(len(c))]);assert np.array_equal(roundtrip,c)
  selected=defaultdict(list);off=0
  for j,im in enumerate(images):
   p=byimage[im]
   if j>0:selected['page'].append({'gap':off,'event_id':im+':page_start'})
   for g in p['boundaries']:
    for e in g['physical_events']:
     if e['color']=='red':label='red'
     elif g['observed_black_class']=='three_dot':label='three_dot'
     elif g['observed_black_class']=='four_dot':label='four_dot'
     else:continue
     selected[label].append({'gap':off+g['rune_gap'],'event_id':e['event_id']})
   off+=p['runes']
  rows=[]
  for pname,label,op in policies:
   es=selected[label]if label else[];active=[e for e in es if 0<e['gap']<len(c)];gaps=sorted({e['gap']for e in active});d,ix=decode(c,keyfn,skips,set(gaps),op);mis=np.flatnonzero(d!=base);ci=np.flatnonzero(ix!=bi)
   rows.append({'policy':pname,'active_physical_events':len(active),'active_distinct_gaps':gaps,'end_or_initial_events_excluded':len(es)-len(active),'counter_positions_changed':len(ci),'mismatched_runes':len(mis),'first_mismatch':int(mis[0])if len(mis)else None,'mismatch_offsets':list(map(int,mis)),'verdict':'known_baseline_reproduced'if pname=='continuous'else'uninformative_no_audited_active_event'if not gaps else'fails_known_plaintext'if len(mis)else'no_decoded_change_for_this_control'})
  results.append({'name':name,'images':images,'runes':len(c),'key_runes':'ᚠᛁᚱᚠᚢᛗᚠᛖᚱᛖᚾᚠᛖ'if name=='second_koan'else None,'unenciphered_F_offsets':sorted(skips),'full_baseline_matches_reference':True,'reference':reference,'exact_reencryption':True,'baseline_rune_values_sha256':hashlib.sha256(bytes(map(int,base))).hexdigest(),'selected_event_evidence':dict(selected),'policies':rows})
 direct=vals(pages[72]);rendered=''.join(LET[int(x)]for x in direct);old=known_text['74.jpg'];assert rendered==''.join(ch for ch in old if ch.isalpha())
 out={'plan_sha256':sha(ROOT/'boundary_model_plan.json'),'boundary_model_sha256':sha(ROOT/'boundary_model.json'),'position_dependent_controls':results,'direct_control':{'name':'LP2_page57_direct','runes':len(direct),'known_reading_matches_retained_record':True,'verdict':'Uninformative about key counters: this known method has no advancing key state.'},'policy_scope':'All keys, starting phases and documented F exceptions fixed. Only audited physical marks are eligible. No inferred event where audit evidence is absent.','control_gate':'Any proposed universal policy must match applicable known controls. Failures here do not rule out section-specific behavior on unsolved text.','no_unsolved_decryption_search':True}
 (ROOT/'boundary_control_results.json').write_text(json.dumps(out,ensure_ascii=False,indent=2)+'\n');return out

def main():
 book=ledger();checks=controls(book)
 print(json.dumps({k:v for k,v in book.items()if k not in ['pages','input_hashes']},indent=2))
 for r in checks['position_dependent_controls']:
  print(r['name'],r['runes'],r['full_baseline_matches_reference'])
  for p in r['policies']:print({k:v for k,v in p.items()if k!='mismatch_offsets'})
 print(checks['direct_control'])
if __name__=='__main__':main()
