"""Session 26: can successor preferences predict other sections?
Frozen models and split conventions; no key search or plaintext inference.
Run with Python + NumPy beside lp_core.py. No network.
"""
import hashlib,json
from lp_core import ROOT,R,LET,lp,vals,np

SEED=3301026
SHRINK=280.0
NSIM=999
OFF=~np.eye(29,dtype=bool)
DELTA=(np.arange(29)[None,:]-np.arange(29)[:,None])%29

def matrix(c):
    return np.bincount(29*c[:-1]+c[1:],minlength=841).reshape(29,29)

def off(t):
    a=t.copy();np.fill_diagonal(a,0);return a

def models(t):
    t=off(t)
    u=OFF.astype(float)/28
    # Baseline for all learned models remains uniform over nonidentical successors.
    dest=t.sum(axis=0)+.5
    dest=np.tile(dest,(29,1))*OFF;dest/=dest.sum(axis=1,keepdims=True)
    step=np.bincount(DELTA[OFF],weights=t[OFF],minlength=29)+.5
    step[0]=0;step/=step.sum()
    row=(t+SHRINK*u)/(t.sum(axis=1,keepdims=True)+SHRINK)
    return dict(uniform=u,destination=dest,modular_step=step[DELTA],full_transition=row)

def score(t,p):
    t=off(t)
    return float(np.sum(t[OFF]*np.log2(p[OFF])))

def evaluate(training,testing):
    ms=models(training);base=score(testing,ms['uniform']);n=int(off(testing).sum())
    return {k:dict(total_bits_gain=score(testing,p)-base,
                   bits_gain_per_transition=(score(testing,p)-base)/n,
                   average_probability_ratio=2**((score(testing,p)-base)/n),
                   cross_entropy_bits=-score(testing,p)/n) for k,p in ms.items()}

def generated(lengths,rng,kind):
    # Controls have the same section lengths and a fixed low repeat probability.
    order=rng.permutation(29);succ=np.empty(29,dtype=int)
    succ[order]=np.roll(order,-1)
    output=[]
    for n in lengths:
        c=np.empty(n,dtype=int);c[0]=rng.integers(29)
        for i in range(1,n):
            previous=c[i-1]
            if rng.random()<.00665:c[i]=previous
            elif kind=='cyclic' and rng.random()<.20:c[i]=(previous+1)%29
            elif kind=='permuted_successor' and rng.random()<.20:c[i]=succ[previous]
            else:c[i]=(previous+rng.integers(1,29))%29
        output.append(c)
    return output

def main():
    pieces=[(i,vals(s)) for i,s in enumerate('%'.join(lp[:56]).split('$')) if len(vals(s))>100]
    mats=[matrix(c) for _,c in pieces]
    train=sum(mats[:4]);test=sum(mats[4:]);total=train+test
    ms=models(train);scores=evaluate(train,test)
    rng=np.random.default_rng(SEED)
    # Keep evaluation sequences intact; change only which rune labels they use.
    random_scores={k:[] for k in ms if k!='uniform'}
    base=score(test,ms['uniform'])
    for _ in range(NSIM):
        perms=[rng.permutation(29) for t in mats[4:]]
        for k,p in ms.items():
            if k=='uniform':continue
            shuffled=sum(score(t,p[np.ix_(perm,perm)]) for t,perm in zip(mats[4:],perms))
            random_scores[k].append(shuffled-base)
    null={}
    for k,samples in random_scores.items():
        a=np.array(samples);obs=scores[k]['total_bits_gain'];ge=int((a>=obs).sum())
        null[k]=dict(observed_gain_bits=obs,mean_gain_bits=float(a.mean()),
            central_95_percent=[float(x) for x in np.quantile(a,[.025,.975])],
            at_or_above=ge,upper_tail_comparison=(1+ge)/(NSIM+1),
            three_model_bonferroni=min(1,3*(1+ge)/(NSIM+1)))
    # Separate check for preferences that exist within individual sections.
    local=[];aggregate={k:0.0 for k in ms};local_n=0
    for i,c in pieces:
        cut=int(.6*len(c));a=matrix(c[:cut]);b=matrix(c[cut:])
        e=evaluate(a,b);n=int(off(b).sum());local_n+=n
        for k in aggregate:aggregate[k]+=e[k]['total_bits_gain']
        local.append(dict(section=i,cut_after_runes=cut,evaluation_pairs=n,scores=e))
    # Training-chosen large cells; none selected using evaluation counts.
    candidates=[]
    for a in range(29):
        expected=off(train)[a].sum()/28
        for b in range(29):
            if a!=b:candidates.append(((train[a,b]-expected)/np.sqrt(expected),a,b))
    candidates.sort(reverse=True)
    leaders=[dict(previous=LET[a],next=LET[b],previous_index=a,next_index=b,
        training_count=int(train[a,b]),training_uniform_expected=float(off(train)[a].sum()/28),
        evaluation_count=int(test[a,b]),evaluation_uniform_expected=float(off(test)[a].sum()/28))
        for _,a,b in candidates[:10]]
    controls={}
    for j,kind in enumerate(['uniform','cyclic','permuted_successor']):
        sequences=generated([len(c) for _,c in pieces],np.random.default_rng(SEED+10+j),kind)
        ts=[matrix(c) for c in sequences]
        controls[kind]=evaluate(sum(ts[:4]),sum(ts[4:]))
    assert controls['cyclic']['modular_step']['total_bits_gain']>0
    assert controls['permuted_successor']['full_transition']['total_bits_gain']>0
    assert int(total.sum())==12939 and int(np.trace(total))==86
    for p in ms.values():
        assert np.allclose(p.sum(axis=1),1) and np.allclose(np.diag(p),0)
    result=dict(seed=SEED,simulations=NSIM,labels=LET,
        scope='Eight substantial sections, section boundaries excluded. Prediction conditioned on next rune differing from previous; all 86 self-transitions excluded from model fitting/scoring.',
        model_definitions={'uniform':'Equal probability 1/28 for every different successor.',
            'destination':'Pooled off-diagonal destination counts + 0.5 each, renormalized excluding the current rune.',
            'modular_step':'Counts of (next-previous) mod29, nonzero steps only, +0.5 each.',
            'full_transition':'Each row shrunk toward uniform-other-28 with fixed concentration 280; no tuning.'},
        training_sections=[i for i,_ in pieces[:4]],evaluation_sections=[i for i,_ in pieces[4:]],
        training_offdiagonal=int(off(train).sum()),evaluation_offdiagonal=int(off(test).sum()),
        training_matrix=train.tolist(),evaluation_matrix=test.tolist(),total_matrix=total.tolist(),
        shared_model_scores=scores,label_permutation_comparison=null,
        local_continuation=dict(method='Fit each section first 60 percent; score remaining 40 percent. One edge across cut excluded. Fixed models, no tuning or decoding.',
            evaluation_pairs=local_n,total_bits_gain=aggregate,
            bits_gain_per_transition={k:v/local_n for k,v in aggregate.items()},sections=local),
        training_selected_pairs=leaders,constructed_controls=controls,
        transcription_sha256=hashlib.sha256((ROOT/'liber-primus__transcription--master.txt').read_bytes()).hexdigest(),
        limits=['Retrospective: all sections were seen in previous sessions; evaluation scores do not select or alter these models.',
            'No broad cryptographic family is ruled out; keys, mappings or rules can differ across sections.',
            'Label permutations preserve within-section patterns up to relabeling, not exact named-rune frequencies.',
            'Label-permutation comparisons test alignment to learned identities, not cipher probabilities.',
            'Cross entropy is a prediction score, not plaintext quality.',
            'Only first-order preferences tested; positional and longer-context effects remain open.',
            'Synthetic controls are conspicuously biased examples, not a measured detection limit.'])
    (ROOT/'transition_prediction_results.json').write_text(json.dumps(result,indent=2)+'\n')
    print(json.dumps({k:v for k,v in result.items() if k in ['training_offdiagonal','evaluation_offdiagonal','shared_model_scores','label_permutation_comparison','constructed_controls','training_selected_pairs']},indent=2))
    print('Local aggregate',result['local_continuation']['total_bits_gain'])

if __name__=='__main__':main()
