#!/usr/bin/env python3
"""PyTorch parity check for the xtrain tiny transformer (Phase T5).

Loads the weights/ids dumped by tests/parity_dump.rs, rebuilds the IDENTICAL
model in PyTorch (same x@W convention, same RoPE rotate_half + position=row,
same RMSNorm, SwiGLU, causal mask, per-head SDPA), runs forward + one backward,
and compares the forward logits and every parameter's gradient against the Rust
values within a relative tolerance.

Usage:  python3 parity.py /tmp/xtrain_parity
"""
import sys
import os
import math
import torch

DIR = sys.argv[1] if len(sys.argv) > 1 else "/tmp/xtrain_parity"


def read_vec(name):
    path = os.path.join(DIR, name)
    shape = None
    vals = []
    with open(path) as f:
        for line in f:
            line = line.strip()
            if line.startswith("# shape"):
                shape = [int(x) for x in line.split()[2].split(",") if x]
            elif line:
                vals.append(float(line))
    t = torch.tensor(vals, dtype=torch.float64)
    if shape:
        t = t.reshape(shape)
    return t


def read_cfg():
    cfg = {}
    with open(os.path.join(DIR, "config.txt")) as f:
        for line in f:
            k, v = line.split()
            cfg[k] = v
    return cfg


def read_ids(name):
    with open(os.path.join(DIR, name)) as f:
        return [int(x) for x in f.read().split()]


cfg = read_cfg()
DIM = int(cfg["dim"])
NL = int(cfg["n_layers"])
NH = int(cfg["n_heads"])
HD = int(cfg["head_dim"])
EPS = float(cfg["eps"])
THETA = float(cfg["rope_theta"])
# Batched: B sequences of length SEQ, flattened sequence-major to [B*SEQ] ids.
B = int(cfg.get("batch", "1"))
SEQ = int(cfg["seq"])

ids = read_ids("ids.txt")
targets = read_ids("targets.txt")
assert len(ids) == B * SEQ, f"ids {len(ids)} != B*SEQ {B*SEQ}"

# Load params as leaf tensors requiring grad (float64 for a clean reference).
P = {}


def load(name):
    t = read_vec(f"w_{name}.txt").clone().requires_grad_(True)
    P[name] = t
    return t


def rms_norm(x, gamma):
    # y = x / sqrt(mean(x^2)+eps) * gamma   (no mean subtraction)
    ms = x.pow(2).mean(dim=-1, keepdim=True)
    return x * torch.rsqrt(ms + EPS) * gamma


def rope(x):  # x: [B*SEQ, nh, hd], position = (row % SEQ) — resets per sequence
    half = HD // 2
    out = torch.empty_like(x)
    i = torch.arange(half, dtype=torch.float64)
    freq = THETA ** (-(2.0 * i) / HD)  # [half]
    # Position within each sequence: rows 0..SEQ for seq 0, 0..SEQ for seq 1, ...
    pos = (torch.arange(B * SEQ, dtype=torch.float64) % SEQ).reshape(B * SEQ, 1)
    ang = pos * freq  # [B*SEQ, half]
    c = torch.cos(ang).reshape(B * SEQ, 1, half)
    s = torch.sin(ang).reshape(B * SEQ, 1, half)
    x0 = x[..., :half]
    x1 = x[..., half:]
    out[..., :half] = x0 * c - x1 * s
    out[..., half:] = x1 * c + x0 * s
    return out


emb = load("embed")
final_norm = load("final_norm")
lm_head = load("lm_head")
layers = []
for l in range(NL):
    layers.append({p: load(f"l{l}_{p}") for p in
                   ["attn_norm", "wq", "wk", "wv", "q_norm", "k_norm", "wo",
                    "ffn_norm", "w_gate", "w_up", "w_down"]})

idx = torch.tensor(ids, dtype=torch.long)
# Per-sequence causal mask (broadcast over the batch); NO cross-sequence attention.
mask = torch.triu(torch.full((SEQ, SEQ), -1.0e9, dtype=torch.float64), diagonal=1)

h = emb[idx]  # [B*SEQ, dim]  (everything stays flattened, matching the Rust path)
for L in layers:
    # Attention
    x = rms_norm(h, L["attn_norm"])
    q = (x @ L["wq"]).reshape(B * SEQ, NH, HD)
    k = (x @ L["wk"]).reshape(B * SEQ, NH, HD)
    v = (x @ L["wv"]).reshape(B * SEQ, NH, HD)
    # Per-head QK-norm (Qwen3-style), before RoPE.
    q = rms_norm(q, L["q_norm"])
    k = rms_norm(k, L["k_norm"])
    q = rope(q)  # [B*SEQ, nh, hd]
    k = rope(k)
    # Reshape to [B, NH, SEQ, HD] so attention runs within each sequence.
    q = q.reshape(B, SEQ, NH, HD).transpose(1, 2)  # [B, nh, seq, hd]
    k = k.reshape(B, SEQ, NH, HD).transpose(1, 2)
    v = v.reshape(B, SEQ, NH, HD).transpose(1, 2)
    scale = 1.0 / math.sqrt(HD)
    scores = (q @ k.transpose(-1, -2)) * scale + mask  # [B, nh, seq, seq]
    probs = torch.softmax(scores, dim=-1)
    out = probs @ v  # [B, nh, seq, hd]
    out = out.transpose(1, 2).reshape(B * SEQ, DIM)  # [B*SEQ, dim]
    attn = out @ L["wo"]
    h = h + attn
    # MLP
    x = rms_norm(h, L["ffn_norm"])
    gate = x @ L["w_gate"]
    up = x @ L["w_up"]
    act = torch.nn.functional.silu(gate) * up
    mlp = act @ L["w_down"]
    h = h + mlp

h = rms_norm(h, final_norm)
logits = h @ lm_head  # [B*SEQ, vocab]

loss = torch.nn.functional.cross_entropy(
    logits, torch.tensor(targets, dtype=torch.long), reduction="mean")
loss_val = loss.item()
loss.backward()

# ---- Compare ----
def relerr(a, b):
    a = a.double()
    b = b.double()
    denom = b.abs().clamp(min=1e-6)
    return ((a - b).abs() / denom).max().item()


ref_logits = read_vec("logits.txt")
ref_loss = read_vec("loss.txt").item()

print(f"loss: rust={ref_loss:.6e} torch={loss_val:.6e} "
      f"relerr={abs(loss_val-ref_loss)/max(abs(ref_loss),1e-6):.2e}")
le = relerr(logits.detach(), ref_logits)
print(f"logits: max relerr = {le:.2e}")

RTOL = 2e-2
worst = le
worst_name = "logits"
fails = []
if le > RTOL:
    fails.append(("logits", le))

for name, t in P.items():
    ref_g = read_vec(f"g_{name}.txt")
    ge = relerr(t.grad, ref_g)
    if ge > worst:
        worst, worst_name = ge, f"grad[{name}]"
    if ge > RTOL:
        fails.append((f"grad[{name}]", ge))

print(f"params checked: {len(P)}  worst = {worst_name} @ {worst:.2e}  (rtol={RTOL})")
if fails:
    print("FAIL:")
    for n, e in fails:
        print(f"  {n}: relerr={e:.3e}")
    sys.exit(1)
print("PARITY OK: forward logits + all param grads within rtol")
