"""Session 27: layout contexts of the 86 immediate-repeat events.
Python + NumPy; fixed feature families, conditional random-placement null.
No cipher search. Labels and punctuation refer to the retained transcription.
"""
import hashlib,json
from word_core import *

SEED=3301027
NSIM=9999
STRONG='.,&§'

def features(source,section,global_start,locations):
    chars=[i for i,ch in enumerate(source) if ch in R]
    c=vals(source);n=len(c)
    gaps=[source[:chars[0]]]+[source[a+1:b] for a,b in zip(chars,chars[1:])]+[source[chars[-1]+1:]]
    word=np.empty(n,dtype=int);pos=np.empty(n,dtype=int);length=np.empty(n,dtype=int)
    ws=tokenize(source)
    for j,w in enumerate(ws):
        a=w['rune_offset'];k=len(w['values'])
        word[a:a+k]=j;pos[a:a+k]=np.arange(k);length[a:a+k]=k
    names=['position:whole_two_rune_word','position:word_start','position:word_middle','position:word_end',
           'position:plain_word_boundary','position:punctuated_word_boundary',
           'length:2','length:3','length:4','length:5_to_7','length:8_plus','length:across_words',
           'punctuation:immediately_before_pair','punctuation:between_pair','punctuation:immediately_after_pair',
           'layout:crosses_line','layout:crosses_page',
           'section_quarter:1','section_quarter:2','section_quarter:3','section_quarter:4']
    f=np.zeros((n-1,len(names)),dtype=np.uint8);events=[]
    for i in range(n-1):
        sameword=word[i]==word[i+1];punct=any(x in gaps[i+1] for x in STRONG)
        if not sameword:k=5 if punct else 4
        elif length[i]==2:k=0
        elif pos[i]==0:k=1
        elif pos[i+1]==length[i]-1:k=3
        else:k=2
        f[i,k]=1
        if not sameword:k=11
        elif length[i]<=4:k=6+length[i]-2
        elif length[i]<=7:k=9
        else:k=10
        f[i,k]=1
        f[i,12]=any(x in gaps[i] for x in STRONG)
        f[i,13]=punct
        f[i,14]=any(x in gaps[i+2] for x in STRONG)
        f[i,15]='/' in gaps[i+1]
        f[i,16]='%' in gaps[i+1]
        f[i,17+min(3,4*i//(n-1))]=1
        if c[i]==c[i+1]:
            left=locations[global_start+i];right=locations[global_start+i+1]
            events.append(dict(section=section,left_section_offset=i,rune=R[c[i]],label=LET[c[i]],
                left_lp2_page=left[0],left_page_offset=left[1],right_lp2_page=right[0],right_page_offset=right[1],
                left_word_length=int(length[i]),left_position_in_word=int(pos[i]),
                right_word_length=int(length[i+1]),right_position_in_word=int(pos[i+1]),
                gap_before=gaps[i],gap_between=gaps[i+1],gap_after=gaps[i+2],
                features=[names[j] for j in np.flatnonzero(f[i])],
                context_runes=''.join(R[v] for v in c[max(0,i-5):min(n,i+7)])))
    assert np.all(f[:,:6].sum(axis=1)==1) and np.all(f[:,6:12].sum(axis=1)==1)
    return c,f,events,names

def main():
    source='%'.join(lp[:56]);locations=[]
    for page,s in enumerate(lp[:56]):locations.extend((page,i) for i in range(len(vals(s))))
    data=[];cursor=0
    for section,s in enumerate(source.split('$')):
        n=len(vals(s))
        if n>100:data.append((section,*features(s,section,cursor,locations)))
        cursor+=n
    names=data[0][-1];observed=np.zeros(len(names),dtype=int)
    expected=np.zeros(len(names));variance=np.zeros(len(names));opportunities=np.zeros(len(names),dtype=int)
    samples=np.zeros((NSIM,len(names)),dtype=int);rng=np.random.default_rng(SEED)
    events=[];section_rows=[]
    for section,c,f,ev,_ in data:
        n=len(c)-1;k=len(ev);a=f.sum(axis=0);o=f[c[:-1]==c[1:]].sum(axis=0)
        observed+=o.astype(int);opportunities+=a.astype(int);expected+=k*a/n
        variance+=k*(a/n)*(1-a/n)*(n-k)/(n-1)
        # Conditional on k repeat events, every k-subset of this section's edges is equally likely.
        picks=rng.integers(n,size=(NSIM,k))
        if k>1:
            duplicate=(np.diff(np.sort(picks,axis=1),axis=1)==0).any(axis=1)
            while duplicate.any():
                picks[duplicate]=rng.integers(n,size=(int(duplicate.sum()),k))
                duplicate=(np.diff(np.sort(picks,axis=1),axis=1)==0).any(axis=1)
        samples+=f[picks].sum(axis=1).astype(int)
        events.extend(ev)
        section_rows.append(dict(section=section,runes=len(c),eligible_edges=n,repeat_events=k,
            feature_opportunities=a.astype(int).tolist(),feature_events=o.astype(int).tolist()))
    assert len(events)==86 and sum(x['eligible_edges'] for x in section_rows)==12939
    sd=np.sqrt(variance);active=sd>0
    z=(observed[active]-expected[active])/sd[active]
    nullz=(samples[:,active]-expected[active])/sd[active]
    maximum=np.max(np.abs(nullz),axis=1)
    rows=[]
    for j,name in enumerate(names):
        if sd[j]:
            zz=float((observed[j]-expected[j])/sd[j])
            raw=(1+int((np.abs((samples[:,j]-expected[j])/sd[j])>=abs(zz)-1e-12).sum()))/(NSIM+1)
            adjusted=(1+int((maximum>=abs(zz)-1e-12).sum()))/(NSIM+1)
        else:zz=0.0;raw=adjusted=1.0
        rows.append(dict(feature=name,events=int(observed[j]),eligible_edges=int(opportunities[j]),
            rate=float(observed[j]/opportunities[j]) if opportunities[j] else None,
            expected=float(expected[j]),standard_deviation=float(sd[j]),standardized_difference=zz,
            random_central_95_percent=[float(x) for x in np.quantile(samples[:,j],[.025,.975])],
            two_sided_comparison=raw,max_statistic_family_comparison=adjusted))
    result=dict(seed=SEED,simulations=NSIM,features=names,sections=section_rows,
        scope='Eight substantial LP2 sections; exclude nine-rune segment and edges crossing section boundaries. 12,939 eligible edges, 86 events.',
        null='Uniform placement of the observed number of repeat events among eligible edges, independently within each section, without replacement. Preserves all eight section event counts and fixed layout.',
        comparison='Two-sided deviations standardized by exact conditional hypergeometric mean/variance; maximum absolute statistic over all 21 predeclared features adjusts the feature family.',
        feature_definitions={'position':'Mutually exclusive within-word start/end/middle/whole-two-rune word, or plain/punctuated cross-word boundary. Start and end exclude length 2.',
            'length':'Within-word length 2,3,4,5–7,8+; separate across-word category.',
            'punctuation':'Any . , & or § in immediate gap before left rune, between runes, or after right rune. Three overlapping binary features.',
            'layout':'Slash between pair marks a transcribed line wrap; percent marks a page boundary. Two overlapping binary features.',
            'section_quarter':'Left edge index assigned to one of four approximately equal edge-count quarters.'},
        measurements=rows,event_inventory=events,
        global_max_standardized=float(np.max(np.abs(z))),
        global_family_comparison=(1+int((maximum>=np.max(np.abs(z))-1e-12).sum()))/(NSIM+1),
        transcription_sha256=hashlib.sha256((ROOT/'liber-primus__transcription--master.txt').read_bytes()).hexdigest(),
        limits=['Exploratory retrospective analysis on previously inspected text; no decryption or causal inference.',
            'Null assumes repeat-event locations are exchangeable within each section; context-dependent mechanisms violate that assumption.',
            'Random placement generates event labels, not a shuffle of the actual rune sequence. It does not preserve rune-specific diagonal counts.',
            'Punctuation uses transcription classes; red color and dot counts are not encoded and are not tested.',
            'Features overlap; maximum-statistic comparison handles the declared family, not all hypotheses explored across prior sessions.',
            'Limited counts, especially at punctuation and page boundaries, reduce power. A non-detection does not show absence of an effect.',
            'No fresh full-page image transcription or signature authentication.'])
    (ROOT/'double_context_results.json').write_text(json.dumps(result,indent=2,ensure_ascii=False)+'\n')
    print(json.dumps(dict(global_family_comparison=result['global_family_comparison'],measurements=rows),indent=2))

if __name__=='__main__':main()
