"""Fictional parcel experiment. Transparent teaching models, not production ML.
Python 3.12 + numpy 2.2.6. No training on validation/test; no internet or database.
Run python ml_reference.py --source . --output experiment.json [--final-test]
Without --final-test, held-out test targets are not scored. Fresh output required.
"""
import argparse,csv,json,hashlib
from pathlib import Path
import numpy as np

FEATURES=['distance_km','load_ratio','zone']
FORBIDDEN=['actual_hours','late','delivered_at','post_delivery_rating']

def read(path):
    with Path(path).open(encoding='utf-8',newline='') as f:return list(csv.DictReader(f))

def fit_preprocessor(rows):
    medians=[];means=[];scales=[]
    for k in FEATURES[:2]:
        vals=np.array([float(r[k]) for r in rows if r[k]!=''])
        if not len(vals): raise ValueError('Entirely missing training feature: '+k)
        if not np.isfinite(vals).all(): raise ValueError('Non-finite training feature: '+k)
        med=float(np.median(vals));a=np.array([float(r[k]) if r[k]!='' else med for r in rows])
        medians.append(med);means.append(float(a.mean()));scales.append(float(a.std()) or 1.)
    return {'medians':medians,'means':means,'scales':scales,'zones':sorted({r['zone'] for r in rows})}

def transform(rows,p):
    # Unknown zones use all-zero indicators; report their count separately.
    return np.array([[( (float(r[k]) if r[k]!='' else p['medians'][i])-p['means'][i])/p['scales'][i] for i,k in enumerate(FEATURES[:2])]+[float(r['zone']==z) for z in p['zones'][1:]] for r in rows])

def fit_stump(x,y):
    # Depth-one classification tree. Enumerate midpoints, minimise weighted Gini.
    candidates=[]
    for j in range(x.shape[1]):
        vals=np.unique(x[:,j])
        for t in (vals[:-1]+vals[1:])/2:
            mask=x[:,j]<=t
            left=float(y[mask].mean());right=float(y[~mask].mean())
            loss=float((mask.sum()*2*left*(1-left)+(~mask).sum()*2*right*(1-right))/len(y))
            candidates.append((loss,j,float(t),left,right))
    if not candidates:return (0.,0,float('inf'),float(y.mean()),float(y.mean()))
    return min(candidates)

def probabilities(x,tree):return np.where(x[:,tree[1]]<=tree[2],tree[3],tree[4])

def metrics(y,p):
    y=np.array(y,dtype=int);p=np.array(p,dtype=int)
    tp=int(((y==1)&(p==1)).sum());fp=int(((y==0)&(p==1)).sum());fn=int(((y==1)&(p==0)).sum());tn=int(((y==0)&(p==0)).sum())
    return {'n':len(y),'positives':int(y.sum()),'tp':tp,'fp':fp,'fn':fn,'tn':tn,'accuracy':float((y==p).mean()),'precision':tp/(tp+fp) if tp+fp else None,'recall':tp/(tp+fn) if tp+fn else None}

def train(rows):
    p=fit_preprocessor(rows);x=transform(rows,p);h=np.array([float(r['actual_hours']) for r in rows]);y=np.array([int(r['late']) for r in rows]);coef=np.linalg.lstsq(np.c_[np.ones(len(x)),x],h,rcond=None)[0]
    return p,coef,fit_stump(x,y),float(h.mean()),int(y.mean()>=.5)

def evaluate(rows,fit,threshold=.5):
    p,coef,tree,mean,majority=fit;x=transform(rows,p);h=np.array([float(r['actual_hours']) for r in rows]);y=np.array([int(r['late']) for r in rows]);pred=np.c_[np.ones(len(x)),x]@coef
    return {'regression_mae_hours':float(np.abs(h-pred).mean()),'baseline_mae_hours':float(np.abs(h-mean).mean()),'classification':metrics(y,probabilities(x,tree)>=threshold),'baseline_classification':metrics(y,np.full(len(y),majority)),'unknown_zone_rows':sum(r['zone'] not in p['zones'] for r in rows)}


if __name__ == '__main__':
    pilot=read('pilot_parcels.csv')
    print(json.dumps(evaluate(pilot[40:],train(pilot[:40])),indent=2))
