"""Session 25: structural discrimination, not decryption or cipher attribution.
Python + NumPy; frozen distances 1..12, parity and boundary probes.
Uniform baselines and a fitted one-step anti-repeat process are toy models.
"""
import hashlib,json
from word_core import *

SEED=3301025
NSIM=999

def describe(source, initial_page_offset=0):
    c=vals(source); n=len(c)
    words=tokenize(source)
    word=np.zeros(n,dtype=int)
    for j,w in enumerate(words):word[w['rune_offset']:w['rune_offset']+len(w['values'])]=j
    page_ix=positions(source,reset='%')[0]
    # A marked section can begin partway through an original page.
    page_ix[:len(vals(source.split('%',1)[0]))]+=initial_page_offset
    section_ix=np.arange(n)
    same=c[1:]==c[:-1]
    within=word[1:]==word[:-1]
    def count(mask):
        return dict(equal=int(same[mask].sum()),pairs=int(mask.sum()))
    lag=[dict(distance=d,equal=int((c[d:]==c[:-d]).sum()),pairs=n-d) for d in range(1,13)]
    parity={policy:[count(ix[:-1]%2==p) for p in range(2)]
            for policy,ix in [('section',section_ix),('page',page_ix)]}
    return dict(runes=n,initial_page_offset=initial_page_offset,lag=lag,within_word=count(within),across_word=count(~within),
                parity=parity,diagonal=np.bincount(c[:-1][same],minlength=29).tolist(),
                parity_inventory={policy:[sorted(set(map(int,c[ix%2==p]))) for p in range(2)]
                                  for policy,ix in [('section',section_ix),('page',page_ix)]}),within,page_ix

def simulate(lengths,p,rng):
    # 999 independent trajectories, with an independent uniform section start.
    output=[]
    for n in lengths:
        a=np.empty((NSIM,n),dtype=np.uint8);a[:,0]=rng.integers(29,size=NSIM)
        for i in range(1,n):
            step=rng.integers(1,29,size=NSIM)
            step[rng.random(NSIM)<p]=0
            a[:,i]=(a[:,i-1]+step)%29
        output.append(a)
    return output

def envelope(a,obs):
    return dict(observed=int(obs),mean=float(a.mean()),
        central_95_percent=[float(x) for x in np.quantile(a,[.025,.975])],
        minimum=int(a.min()),maximum=int(a.max()),
        simulations_at_or_below=int((a<=obs).sum()),
        simulations_at_or_above=int((a>=obs).sum()))

def main():
    original='%'.join(lp[:56])
    pieces=[];initial=[];offset=0
    for i,s in enumerate(original.split('$')):
        if len(vals(s))>100:
            pieces.append((i,s))
            initial.append(len(vals(original[:offset].rsplit('%',1)[-1])))
        offset+=len(s)+1
    items=[describe(s,start) for (_,s),start in zip(pieces,initial)]
    # Cross-check fragment counters against positions built directly page by page.
    direct=np.concatenate([np.arange(len(vals(page))) for page in lp[:56]])
    cursor=0;checked=0
    for s in original.split('$'):
        n=len(vals(s))
        if n>100:
            assert np.array_equal(items[checked][2],direct[cursor:cursor+n])
            checked+=1
        cursor+=n
    rows=[dict(section=i,**item[0]) for (i,s),item in zip(pieces,items)]
    assert [r['section'] for r in rows]==[0,1,2,4,5,6,7,8]
    train=rows[:4];test=rows[4:]
    train_eq=sum(r['lag'][0]['equal'] for r in train)
    train_n=sum(r['lag'][0]['pairs'] for r in train)
    p=train_eq/train_n
    rng=np.random.default_rng(SEED)
    sims=simulate([r['runes'] for r in test],p,rng)
    lag=[]
    lam=(29*p-1)/28
    for d in range(1,13):
        samples=sum((a[:,d:]==a[:,:-d]).sum(axis=1) for a in sims)
        obs=sum(r['lag'][d-1]['equal'] for r in test)
        pairs=sum(r['lag'][d-1]['pairs'] for r in test)
        lag.append(dict(distance=d,pairs=pairs,
            analytic_expected=pairs*(1/29+28/29*lam**d),**envelope(samples,obs)))
    boundaries={}
    for name in ['within_word','across_word']:
        samples=np.zeros(NSIM,dtype=int)
        for a,item in zip(sims,items[4:]):
            mask=item[1] if name=='within_word' else ~item[1]
            samples+=(a[:,1:]==a[:,:-1])[:,mask].sum(axis=1)
        obs=sum(r[name]['equal'] for r in test)
        pairs=sum(r[name]['pairs'] for r in test)
        boundaries[name]=dict(pairs=pairs,independent_uniform_expected=pairs/29,
                             **envelope(samples,obs))
    parity={}
    for policy in ['section','page']:
        parity[policy]=[]
        for phase in range(2):
            samples=np.zeros(NSIM,dtype=int)
            for a,item in zip(sims,items[4:]):
                ix=np.arange(a.shape[1]) if policy=='section' else item[2]
                samples+=(a[:,1:]==a[:,:-1])[:,ix[:-1]%2==phase].sum(axis=1)
            obs=sum(r['parity'][policy][phase]['equal'] for r in test)
            pairs=sum(r['parity'][policy][phase]['pairs'] for r in test)
            parity[policy].append(dict(left_rune_phase=phase,pairs=pairs,
                independent_uniform_expected=pairs/29,**envelope(samples,obs)))
    # Simultaneous, descriptive envelope across 12 fixed distances.
    # No claim of calibrated significance: this ignores training-parameter uncertainty.
    sample_lags=np.array([sum((a[:,d:]==a[:,:-d]).sum(axis=1) for a in sims) for d in range(1,13)])
    mu=sample_lags.mean(axis=1);sd=sample_lags.std(axis=1,ddof=1)
    maximum=np.max(np.abs((sample_lags-mu[:,None])/sd[:,None]),axis=0)
    observed=np.array([r['observed'] for r in lag])
    maxobs=float(np.max(np.abs((observed-mu)/sd)))
    # Direct measurements on independent known plaintext groups; no language ranking.
    solved=[]
    for name,s in GROUPS.items():
        v=vals(s)
        solved.append(dict(group=name,runes=len(v),equal=int((v[1:]==v[:-1]).sum()),pairs=len(v)-1))
    result=dict(seed=SEED,simulations=NSIM,distances=list(range(1,13)),
        split='First four substantial sections fit repeat probability; last four evaluate it. All sections have appeared in earlier work, so this is retrospective validation, not untouched data.',
        model='Uniform first rune. Repeat previous rune with probability p; otherwise choose uniformly from the other 28. Independent section starts. No word or page resets.',
        training=dict(sections=[r['section'] for r in train],equal=train_eq,pairs=train_n,p=p),
        evaluation_sections=[r['section'] for r in test],sections=rows,
        evaluation_lag=lag,evaluation_boundaries=boundaries,evaluation_parity=parity,
        simultaneous_lag_check=dict(observed_max_abs_standardized=maxobs,
            simulated_95_percent_threshold=float(np.quantile(maximum,.95)),
            simulations_at_or_above=int((maximum>=maxobs).sum()),
            caveat='Descriptive model check with fitted p treated as fixed; not a formal cipher probability.'),
        known_plaintext_adjacent_counts=solved,
        controls=dict(analytic_lag_one_equals_p=bool(abs((1/29+28/29*lam)-p)<1e-12),
            analytic_iid_limit=bool(abs((1/29+28/29*((29*(1/29)-1)/28)**3)-1/29)<1e-12)),
        transcription_sha256=hashlib.sha256((ROOT/'liber-primus__transcription--master.txt').read_bytes()).hexdigest(),
        limits=['No decryption, plaintext ranking or key search.',
            'Toy model assumes uniform rune marginals and symmetric transitions; not an identified cipher.',
            'Simulation bands exclude uncertainty in the fitted probability and are not posterior probabilities.',
            'Does not model the known repeated six-rune phrase or exact bigram counts.',
            'Independent-uniform restart and pair-boundary predictions apply only to those explicitly restricted models.',
            'Source relies on retained transcription and prior cross-checks; not a fresh full image transcription.'])
    assert all(result['controls'].values())
    (ROOT/'repetition_mechanism_results.json').write_text(json.dumps(result,indent=2)+'\n')
    print(json.dumps({k:v for k,v in result.items() if k not in ['sections','limits']},indent=2))

if __name__=='__main__':main()
