"""Session 58: compare frozen image-first readings, without changing them.
Run beside the six readings, freeze/plan manifests, JPEGs and master text.
Manual readings were completed and hashed before this script was written.
"""
from pathlib import Path
from collections import Counter
from difflib import SequenceMatcher
import hashlib,json
ROOT=Path(__file__).resolve().parent
R='ᚠᚢᚦᚩᚱᚳᚷᚹᚻᚾᛁᛄᛇᛈᛉᛋᛏᛒᛖᛗᛚᛝᛟᛞᚪᚫᚣᛡᛠ'
A='F U TH O R K G W H N I J EO P X S T B E M L ING OE D A AE Y IO EA'.split()
def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest()
def read_manual(text):
 lines=[];events=[];v=[];unc=[]
 for ln,line in enumerate(text.splitlines(),1):
  start=len(v)
  for t in line.split():
   if t.rstrip('?') in A:
    if '?' in t:unc.append({'offset':len(v),'line':ln,'provisional':t.rstrip('?')})
    v.append(t.rstrip('?'))
   else:events.append({'offset':len(v),'line':ln,'mark':t})
  lines.append(v[start:])
 return {'values':v,'lines':lines,'events':events,'uncertainties':unc}
def read_master(text):
 lines=[];events=[];v=[]
 for line in text.split('/'):
  if not any(c in R for c in line):continue
  start=len(v);ln=len(lines)+1
  for c in line:
   if c in R:v.append(A[R.index(c)])
   elif c in '-.;\'':events.append({'offset':len(v),'line':ln,'mark':{'-':'|','.':':',"'":"'",';':';'}[c]})
   elif c.strip() and c not in '&$§':raise ValueError(('unexpected master token',c))
  lines.append(v[start:])
 return {'values':v,'lines':lines,'events':events}
def repeats(v):return [i for i in range(len(v)-1) if v[i]==v[i+1]]
def stats(v):
 n=len(v);counts=Counter(v)
 return {'runes':n,'pairs':n-1,'repeats':len(repeats(v)),'repeat_offsets':repeats(v),'shuffle_expectation':sum(c*(c-1) for c in counts.values())/n}
def main():
 plan=json.loads((ROOT/'image_first_plan.json').read_text());freeze=json.loads((ROOT/'image_first_freeze.json').read_text())
 assert sha(ROOT/'image_first_plan.json')==freeze['plan_sha256']
 assert sha(ROOT/freeze['chart']['file'])==freeze['chart']['sha256']
 manifest=json.loads((ROOT/'jpeg_source_manifest.json').read_text());source_hashes={x['file']:x['sha256']for x in manifest['files']}
 assert all(source_hashes[x['file']]==x['sha256']for x in plan['sources'].values())
 raw=(ROOT/'liber-primus__transcription--master.txt').read_text();masterhash=sha(ROOT/'liber-primus__transcription--master.txt');assert masterhash=='b0b2e13c3cd35512ee5955a2d9a56c22d7410e283d35dd9c90abbaa7b99279eb'
 lp=raw[raw.index('ᚱ-'):].split('%')[:73][15:];records=[]
 for p in plan['pages_lp2_zero_based']:
  f=ROOT/f'image_first_reading_p{p:02d}.txt';assert sha(f)==freeze['files'][str(p)]['sha256'];assert sha(ROOT/plan['sources'][str(p)]['file'])==plan['sources'][str(p)]['sha256']
  im=read_manual(f.read_text());m=read_master(lp[p]);diff=[]
  for ln,(x,y) in enumerate(zip(im['lines'],m['lines']),1):
   for tag,i,j,k,l in SequenceMatcher(None,x,y,autojunk=False).get_opcodes():
    if tag!='equal':diff.append({'line':ln,'type':tag,'manual_line_range':[i,j],'master_line_range':[k,l],'manual':x[i:j],'master':y[k:l]})
  # Logical boundary identity ignores the line carrying a split mark.
  ime=[]
  for e in im['events']:
   if e['mark']=='@3':ime.append((e['offset'],':'))
   elif e['mark']=='@1':continue
   else:ime.append((e['offset'],e['mark']))
  me=[(e['offset'],e['mark']) for e in m['events']]
  records.append({'page':p,'initial':stats(im['values']),'master':stats(m['values']),'initial_uncertainties':im['uncertainties'],'line_count_match':len(im['lines'])==len(m['lines']),'line_counts_manual':list(map(len,im['lines'])),'line_counts_master':list(map(len,m['lines'])),'rune_disagreements':diff,'boundary_disagreements':{'initial_only':list((Counter(ime)-Counter(me)).elements()),'master_only':list((Counter(me)-Counter(ime)).elements())},'initial_logical_boundary_count':len(ime),'master_logical_boundary_count':len(me),'initial_repeat_pairs':[{'offset':i,'rune':im['values'][i]} for i in repeats(im['values'])]})
 out={'freeze_sha256':sha(ROOT/'image_first_freeze.json'),'master_sha256':masterhash,'records':records}
 (ROOT/'image_first_initial_comparison.json').write_text(json.dumps(out,indent=2)+'\n')
 finalize(out,lp)

def finalize(initial,lp):
 review=json.loads((ROOT/'image_first_review_decisions.json').read_text())
 assert review['freeze_sha256']==initial['freeze_sha256']
 records=[]
 for rec in initial['records']:
  p=rec['page'];text=(ROOT/f'image_first_reading_p{p:02d}.txt').read_text();lines=text.splitlines()
  # Apply only explicit, retained post-comparison decisions, in reverse order.
  for ln,line in enumerate(lines,1):
   ts=line.split();ix=[j for j,t in enumerate(ts)if t.rstrip('?')in A]
   decisions=[d for d in review['rune_edits']if d['page']==p and d['line']==ln]
   for d in sorted(decisions,key=lambda d:d['manual_line_range'][0],reverse=True):
    i,j=d['manual_line_range'];assert [ts[ix[k]].rstrip('?')for k in range(i,j)]==d['manual']
    b=ix[i]if i<len(ix)else len(ts);e=ix[j-1]+1 if j>i else b;ts[b:e]=d['master']
   lines[ln-1]=' '.join(t.rstrip('?')if t.rstrip('?')in A else t for t in ts)
  if p==46:
   assert lines[4].count(' : ')==1
   lines[4]=lines[4].replace(' : ',' @M3 ')
  reviewed='\n'.join(lines)+'\n';target=ROOT/f'image_first_reviewed_p{p:02d}.txt'
  assert target.read_text()==reviewed,'Reviewed file does not reproduce from frozen reading and decision log'
  im=read_manual(reviewed);m=read_master(lp[p]);assert im['values']==m['values'];assert im['lines']==m['lines']
  boundary=lambda es:sorted(set(e['offset']for e in es if e['mark']!="'"))
  ib=boundary(im['events']);mb=boundary(m['events']);assert ib==mb
  apost=lambda es:[e['offset']for e in es if e['mark']=="'"]
  assert apost(im['events'])==apost(m['events'])
  # A fragment pair is kept physically; only its shared gap is compared.
  ie=[(e['offset'],e['mark'])for e in im['events']if e['mark']not in ('@3','@1')]
  me=[(e['offset'],e['mark'])for e in m['events']if not(p==11 and e['offset']==250)]
  if p==46:
   assert (96,'@M3')in ie and (96,'|')in me
   ie=[z for z in ie if z[0]!=96];me=[z for z in me if z[0]!=96]
  assert ie==me
  lbs=[];k=0
  for line in im['lines'][:-1]:k+=len(line);lbs.append(k)
  pairs=[{'first_rune_offset':i,'token':im['values'][i],'crosses_printed_boundary':i+1 in ib,'crosses_line':i+1 in lbs}for i in repeats(im['values'])]
  records.append({'page':p,'reviewed':stats(im['values']),'reviewed_sha256':sha(target),'initial_rune_edit_operations':len(rec['rune_disagreements']),'rune_stream_matches_master_after_review':True,'line_streams_match':True,'boundary_gap_count':len(ib),'boundary_gaps_match_master':True,'apostrophe_offsets':apost(im['events']),'repeat_pairs':pairs})
 sums=lambda key:{'runes':sum(x[key]['runes']for x in initial['records']),'pairs':sum(x[key]['pairs']for x in initial['records']),'repeats':sum(x[key]['repeats']for x in initial['records']),'shuffle_expectation':sum(x[key]['shuffle_expectation']for x in initial['records'])}
 before=sums('initial');after=sums('master');after['deficit_fraction']=1-after['repeats']/after['shuffle_expectation'];after['repeat_fraction']=after['repeats']/after['pairs']
 # Sensitivity to every possible identity of the one initially uncertain glyph.
 u=read_manual((ROOT/'image_first_reading_p11.txt').read_text());off=u['uncertainties'][0]['offset'];counts=[]
 for a in A:
  v=u['values'].copy();v[off]=a;counts.append(before['repeats']-len(repeats(u['values']))+len(repeats(v)))
 types=Counter(d['type']for d in review['rune_edits'])
 result={'plan_sha256':sha(ROOT/'image_first_plan.json'),'freeze_sha256':initial['freeze_sha256'],'master_sha256':initial['master_sha256'],'review_decisions_sha256':sha(ROOT/'image_first_review_decisions.json'),'scope':'Six fixed pages; within-page rune adjacencies across line breaks and punctuation; no pairs across sampled page boundaries.','source_limit':'Image-first by the same assistant, not fully blind or independent; post-comparison adjudication visible to master.','total_initial':before,'total_reviewed':after,'initial_uncertain_glyph_repeat_range':[min(counts),max(counts)],'edit_operations':dict(types),'boundary_gap_count':sum(x['boundary_gap_count']for x in records),'apostrophe_count':sum(len(x['apostrophe_offsets'])for x in records),'reviewed_repeat_categories':dict(Counter('across_boundary'if z['crosses_printed_boundary']else'within_word'for x in records for z in x['repeat_pairs'])),'expectation_formula':'For each page with n symbols and counts c_r, E(adjacent equal pairs) = sum_r c_r(c_r-1)/n; sum across pages.','records':records,'separator_discrepancy':review['additional_separator_discrepancy'],'conclusion':'The shortage of adjacent repeats survives source inspection in this sample. No verified master rune correction; one source separator class discrepancy documented. No new plaintext. Not a full-corpus audit or new formal significance test.'}
 (ROOT/'image_first_results.json').write_text(json.dumps(result,indent=2)+'\n')
 print(json.dumps({k:v for k,v in result.items()if k!='records'},indent=2))

if __name__=='__main__':main()
