"""Authored immutable fit/transform/predict contract using only Python standard library."""
import json,math,hashlib,dataclasses
from collections import Counter
@dataclasses.dataclass(frozen=True)
class Row:
    id:str;source_id:str;split:str;text:str;label:str
@dataclasses.dataclass(frozen=True)
class Model:
    vocabulary:tuple;labels:tuple;counts:tuple;document_counts:tuple;train_ids:tuple
TRAIN_IDS=frozenset(['R1','R2','R3','R4'])
TRAIN=[Row('R1','S1','train','بسته دیر','delivery'),Row('R2','S2','train','بسته مسیر','delivery'),Row('R3','S3','train','مبلغ رسید','payment'),Row('R4','S4','train','مبلغ پرداخت','payment')]
TEST=[Row('T1','S5','test','بسته دیر آزمایشی','delivery'),Row('T2','S6','test','مبلغ رسید','payment'),Row('T3','S7','test','مبلغ','delivery'),Row('T4','S8','test','فقط‌ناشناخته','payment')]
def fingerprint(model):
    raw=json.dumps(dataclasses.asdict(model),ensure_ascii=False,sort_keys=True,separators=(',',':')).encode('utf-8')
    return hashlib.sha256(raw).hexdigest()
def fit(rows,heldout_source_ids):
    if len(rows)!=4 or {r.id for r in rows}!=TRAIN_IDS or any(r.split!='train' for r in rows):raise ValueError('only four allowlisted training rows may fit')
    if len({r.source_id for r in rows})!=len(rows) or set(r.source_id for r in rows)&set(heldout_source_ids):raise ValueError('source-id split overlap')
    labels=tuple(sorted({r.label for r in rows}));vocab=tuple(sorted({w for r in rows for w in r.text.split()}))
    counts=[];ndocs=[]
    for label in labels:
        selected=[r for r in rows if r.label==label];c=Counter(w for r in selected for w in r.text.split());counts.append(tuple(c[w] for w in vocab));ndocs.append(len(selected))
    return Model(vocab,labels,tuple(counts),tuple(ndocs),tuple(sorted(r.id for r in rows)))
def transform(model,text):
    c=Counter(text.split());return tuple(c[w] for w in model.vocabulary),tuple(w for w in text.split() if w not in model.vocabulary)
def predict(model,text):
    vector,unknown=transform(model,text)
    if not any(vector):return {'prediction':None,'vector':vector,'unknown_tokens':unknown,'reason':'no known token'}
    scores=[]
    for counts,n in zip(model.counts,model.document_counts):
        denominator=sum(counts)+len(model.vocabulary)
        scores.append(math.log(n/sum(model.document_counts))+sum(v*math.log((c+1)/denominator) for v,c in zip(vector,counts)))
    order=sorted(range(len(scores)),key=lambda i:(-scores[i],model.labels[i]))
    return {'prediction':model.labels[order[0]],'vector':vector,'unknown_tokens':unknown,'log_scores':dict(zip(model.labels,scores))}
def demo():
    heldout={r.source_id for r in TEST};model=fit(TRAIN,heldout);before=fingerprint(model)
    results=[];matrix={label:{'delivery':0,'payment':0,'abstain':0} for label in model.labels}
    for row in TEST:
        result=predict(model,row.text);prediction=result['prediction'];matrix[row.label][prediction or 'abstain']+=1
        results.append(dict(dataclasses.asdict(row),**result))
        assert fingerprint(model)==before
    assert 'آزمایشی' not in model.vocabulary and results[0]['unknown_tokens']==('آزمایشی',)
    assert [r['prediction'] for r in results]==['delivery','payment','payment',None]
    rejections=[]
    for name,rows,held in [('test rows passed to fit',TRAIN+TEST,heldout),('test row relabeled train',TRAIN[:-1]+[dataclasses.replace(TEST[0],split='train')],heldout),('source group overlaps',TRAIN,{TRAIN[0].source_id})]:
        try:fit(rows,held)
        except ValueError:rejections.append(name)
        else:raise AssertionError(name)
    try:model.vocabulary=model.vocabulary+('آزمایشی',)
    except dataclasses.FrozenInstanceError:rejections.append('state assignment')
    else:raise AssertionError('mutable state')
    return {'training_rows':[dataclasses.asdict(r) for r in TRAIN],'test_rows':[dataclasses.asdict(r) for r in TEST],'state':dataclasses.asdict(model),'state_sha256_before':before,'state_sha256_after':fingerprint(model),'test_results':results,'confusion_reference_rows_prediction_columns':matrix,'errors':[r['id'] for r in results if r['prediction'] is not None and r['prediction']!=r['label']],'abstentions':[r['id'] for r in results if r['prediction'] is None],'rejected_mutations':rejections,'test_only_token_in_vocabulary':False,'scikit_learn_executed':False,'all_labels_authored_manually':True}
if __name__=='__main__':print(json.dumps(demo(),ensure_ascii=False,indent=2))
