#!/usr/bin/env python3
"""Cossim debug : C++ omnivoice-tts vs Python OmniVoice on voice cloning.

Inputs (relative to CWD = tests/) :
    ../examples/prompt.txt       target text fed to both pipelines
    ../examples/freeman.txt      transcript of the cloning reference
    ../examples/freeman.wav      cloning reference audio, any rate, any layout

Both sides run with seed=42, F32 weights, language=English, no pre or post
process. The reference audio is resampled to 24 kHz mono inside both
pipelines.

Dumps land in cpp/ (C++) and python/ (Python) and are compared pair by
pair. All paths are relative.
"""

import argparse
import os
import struct
import subprocess
import sys

# Strict F32 matmul on both sides. NVIDIA_TF32_OVERRIDE=0 forces full FP32
# mantissa in cuBLAS for both PyTorch and the C++ child via inheritance.
# Must be set BEFORE torch imports so the cuBLAS handle reads it on init.
os.environ["NVIDIA_TF32_OVERRIDE"] = "0"

import numpy as np
import soundfile as sf
import torch
import torch.nn.functional as F
import torchaudio

# Belt and suspenders : disable PyTorch's own TF32 toggles too. Some code
# paths bypass NVIDIA_TF32_OVERRIDE through cudnn or torch internal flags.
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32       = False

from omnivoice import OmniVoice
from omnivoice.utils.common import fix_random_seed

BIN        = "../build/omnivoice-tts"
MODEL_LM_T = "../models/omnivoice-base-{q}.gguf"
MODEL_CDC_T = "../models/omnivoice-tokenizer-{q}.gguf"
CKPT       = "../checkpoints/OmniVoice"
DUMP_CPP   = "cpp"
DUMP_PT    = "python"

def ensure_dir(path):
    os.makedirs(path, exist_ok=True)

def save_dump(path, data):
    if isinstance(data, torch.Tensor):
        data = data.detach().to(torch.float32).cpu().numpy()
    data  = np.ascontiguousarray(data.astype(np.float32))
    shape = data.shape
    with open(path, "wb") as f:
        f.write(struct.pack("i", len(shape)))
        for s in shape:
            f.write(struct.pack("i", s))
        f.write(data.tobytes())

def load_dump(path):
    raw   = np.fromfile(path, dtype=np.uint8)
    ndim  = int(np.frombuffer(raw[0:4], dtype=np.int32)[0])
    shape = tuple(int(x) for x in np.frombuffer(raw[4:4 + 4 * ndim], dtype=np.int32))
    body  = np.frombuffer(raw[4 + 4 * ndim:], dtype=np.float32)
    return body.reshape(shape), shape

def cos(a, b):
    a = a.astype(np.float64).ravel()
    b = b.astype(np.float64).ravel()
    n = min(len(a), len(b))
    a, b = a[:n], b[:n]
    # log_probs contains -inf at the audio_mask_id slot. Mask these symmetric
    # positions to 0 on both sides so they cancel out of the inner product.
    bad = ~(np.isfinite(a) & np.isfinite(b))
    if bad.any():
        a = np.where(bad, 0.0, a)
        b = np.where(bad, 0.0, b)
    d = float(np.linalg.norm(a) * np.linalg.norm(b))
    return float(np.dot(a, b) / d) if d > 1e-10 else 0.0

def stft_cos(a, b, win=2048, hop=512):
    # STFT magnitude cosine. Drops phase, so a constant time shift between
    # the two waveforms does not collapse the score. The plain cos() on
    # raw samples falls to ~0 the moment chunks land a few samples apart.
    a = a.astype(np.float64).ravel()
    b = b.astype(np.float64).ravel()
    n = min(len(a), len(b))
    a, b = a[:n], b[:n]
    window = np.hanning(win)
    frames = (n - win) // hop + 1
    if frames <= 0:
        return 0.0
    sa = np.zeros((frames, win // 2 + 1))
    sb = np.zeros((frames, win // 2 + 1))
    for i in range(frames):
        s = i * hop
        sa[i] = np.abs(np.fft.rfft(a[s:s + win] * window))
        sb[i] = np.abs(np.fft.rfft(b[s:s + win] * window))
    return cos(sa.ravel(), sb.ravel())

def install_hooks(model, dump_dir):
    # Capture the raw input_ids row k=0 for cond and uncond at the first
    # forward pass. Lets a divergence in prompt construction (style, text,
    # ref_audio tokens) localize before the LM runs.
    seen_embed = {"done": False}
    orig_prepare = model._prepare_embed_inputs
    def hooked_prepare(input_ids, audio_mask):
        out = orig_prepare(input_ids, audio_mask)
        if not seen_embed["done"] and input_ids.dim() == 3 and input_ids.shape[0] >= 2:
            cond_ids   = input_ids[0, 0, :].detach().to(torch.float32).cpu().numpy()
            uncond_ids = input_ids[1, 0, :].detach().to(torch.float32).cpu().numpy()
            save_dump(os.path.join(dump_dir, "prompt-cond-ids.bin"),   cond_ids)
            save_dump(os.path.join(dump_dir, "prompt-uncond-ids.bin"), uncond_ids)
            seen_embed["done"] = True
        return out
    model._prepare_embed_inputs = hooked_prepare

    seen = {"step0": False, "mg_tokens": False, "audio": False}
    orig_pred = model._predict_tokens_with_scoring
    def hooked_pred(c_logits, u_logits, gen_config):
        is_first = not seen["step0"]
        if is_first:
            c = c_logits.detach().to(torch.float32).cpu().numpy()
            u = u_logits.detach().to(torch.float32).cpu().numpy()
            if c.ndim == 4:
                c = c[0]
            if u.ndim == 4:
                u = u[0]
            save_dump(os.path.join(dump_dir, "lm-logits-step0-cond.bin"),   c)
            save_dump(os.path.join(dump_dir, "lm-logits-step0-uncond.bin"), u)
        pred_tokens, scores = orig_pred(c_logits, u_logits, gen_config)
        if is_first:
            # Reconstruct log_probs with the same op sequence as the Python
            # function so the dump is the actual log_probs the model used.
            with torch.no_grad():
                if gen_config.guidance_scale != 0:
                    cl = F.log_softmax(c_logits, dim=-1)
                    ul = F.log_softmax(u_logits, dim=-1)
                    lp = torch.log_softmax(cl + gen_config.guidance_scale * (cl - ul), dim=-1)
                else:
                    lp = F.log_softmax(c_logits, dim=-1)
                lp[..., model.config.audio_mask_id] = -float("inf")
            lp_arr = lp.detach().to(torch.float32).cpu().numpy()
            if lp_arr.ndim == 4:
                lp_arr = lp_arr[0]
            save_dump(os.path.join(dump_dir, "mg-log-probs-step0.bin"), lp_arr)

            pt_arr = pred_tokens.detach().to(torch.float32).cpu().numpy()
            if pt_arr.ndim == 3:
                pt_arr = pt_arr[0]
            save_dump(os.path.join(dump_dir, "mg-pred-tokens-step0.bin"), pt_arr)

            sc_arr = scores.detach().to(torch.float32).cpu().numpy()
            if sc_arr.ndim == 3:
                sc_arr = sc_arr[0]
            save_dump(os.path.join(dump_dir, "mg-scores-step0.bin"), sc_arr)
            seen["step0"] = True
        return pred_tokens, scores
    model._predict_tokens_with_scoring = hooked_pred

    orig_generate = model._generate_iterative
    def hooked_generate(task, gen_config):
        out = orig_generate(task, gen_config)
        if not seen["mg_tokens"]:
            save_dump(os.path.join(dump_dir, "mg-tokens.bin"), out[0])
            seen["mg_tokens"] = True
        return out
    model._generate_iterative = hooked_generate

    orig_decode = model.audio_tokenizer.decode
    def hooked_decode(*args, **kwargs):
        out = orig_decode(*args, **kwargs)
        if seen["audio"]:
            return out
        wav = getattr(out, "audio_values", out)
        if isinstance(wav, torch.Tensor):
            arr = wav.detach().to(torch.float32).cpu().numpy()
        else:
            arr = np.asarray(wav, dtype=np.float32)
        if arr.ndim == 3:
            arr = arr[0, 0]
        elif arr.ndim == 2:
            arr = arr[0]
        save_dump(os.path.join(dump_dir, "output-audio.bin"), arr)
        seen["audio"] = True
        return out
    model.audio_tokenizer.decode = hooked_decode

    # Capture the full reference audio code matrix [K, T_ref] before it goes
    # into the prompt, so a divergence in the codec encoder is visible at the
    # source rather than only via prompt-cond-ids row k=0.
    orig_encode = model.audio_tokenizer.encode
    seen_enc = {"done": False}
    def hooked_encode(*args, **kwargs):
        out = orig_encode(*args, **kwargs)
        if not seen_enc["done"]:
            codes = out.audio_codes if hasattr(out, "audio_codes") else out
            if isinstance(codes, torch.Tensor):
                arr = codes.detach().to(torch.float32).cpu().numpy()
                if arr.ndim == 3:
                    arr = arr[0]
                save_dump(os.path.join(dump_dir, "ref-audio-codes.bin"), arr)
                seen_enc["done"] = True
        return out
    model.audio_tokenizer.encode = hooked_encode

    # Capture the 16 kHz post resample audio and the HuBERT semantic features
    # at the boundary inside the audio tokenizer. Lets us bisect the codec
    # encoder : raw 24k -> resample 16k -> hubert -> sem_enc -> RVQ -> codes.
    orig_extract = model.audio_tokenizer._extract_semantic_features
    seen_sem = {"done": False}
    def hooked_extract(input_values):
        if not seen_sem["done"]:
            cfg = model.audio_tokenizer.config
            if cfg.sample_rate != cfg.semantic_sample_rate:
                v16 = torchaudio.functional.resample(input_values, cfg.sample_rate, cfg.semantic_sample_rate)
            else:
                v16 = input_values
            arr16 = v16[:, 0, :].squeeze(0).detach().to(torch.float32).cpu().numpy()
            save_dump(os.path.join(dump_dir, "ref-audio-16k.bin"), arr16)
        out = orig_extract(input_values)
        if not seen_sem["done"]:
            feat = out.squeeze(0).detach().to(torch.float32).cpu().numpy()
            save_dump(os.path.join(dump_dir, "ref-hubert-features.bin"), feat)
            seen_sem["done"] = True
        return out
    model.audio_tokenizer._extract_semantic_features = hooked_extract

    # Bisect HuBERT internals. The C++ counterpart marks the same 9 stages as
    # graph outputs in pipeline_codec_hubert_features_test and dumps them post
    # compute. Names match exactly so the Python compare loop can pair them.
    sm = model.audio_tokenizer.semantic_model
    seen_hub = {"feat": False, "proj_ln": False, "proj": False, "init": False,
                "l0":   False, "l5":      False, "l7":   False, "l9":   False, "l11":  False}

    def hub_hook_feat(module, inputs, output):
        # HF feature_extractor returns (B, C=512, T_feat). Squeeze gives
        # (C, T_feat) which matches the C++ ne=(T, C) tensor whose linear
        # buffer is laid out slow-first as (C, T). No transpose needed.
        if seen_hub["feat"]:
            return
        arr = output[0].detach().to(torch.float32).cpu().numpy()
        save_dump(os.path.join(dump_dir, "hubert-feat-extract.bin"), arr)
        seen_hub["feat"] = True
    sm.feature_extractor.register_forward_hook(hub_hook_feat)

    # feature_projection internal split : layer_norm first, then projection
    # Linear. The C++ side dumps the post LN tensor before the Linear, so we
    # mirror with a forward hook on the LN sub-module.
    def hub_hook_proj_ln(module, inputs, output):
        if seen_hub["proj_ln"]:
            return
        out_t = output[0] if isinstance(output, tuple) else output
        arr = out_t[0].detach().to(torch.float32).cpu().numpy()
        save_dump(os.path.join(dump_dir, "hubert-feat-proj-ln.bin"), arr)
        seen_hub["proj_ln"] = True
    sm.feature_projection.layer_norm.register_forward_hook(hub_hook_proj_ln)

    def hub_hook_proj(module, inputs, output):
        # feature_projection returns (B, T_feat, 768) already T-first.
        if seen_hub["proj"]:
            return
        out_t = output[0] if isinstance(output, tuple) else output
        arr = out_t[0].detach().to(torch.float32).cpu().numpy()
        save_dump(os.path.join(dump_dir, "hubert-feat-proj.bin"), arr)
        seen_hub["proj"] = True
    sm.feature_projection.register_forward_hook(hub_hook_proj)

    # encoder.layer_norm is the LN that follows pos_conv_embed add. Its output
    # equals the C++ enc_init output : x + pos_conv(x) -> LN.
    def hub_hook_init(module, inputs, output):
        if seen_hub["init"]:
            return
        arr = output[0].detach().to(torch.float32).cpu().numpy()
        save_dump(os.path.join(dump_dir, "hubert-enc-init.bin"), arr)
        seen_hub["init"] = True
    sm.encoder.layer_norm.register_forward_hook(hub_hook_init)

    # Mid stack taps : layer 0, 5, 7, 9, 11. Mirror the C++ states[1], [6],
    # [8], [10], [12] indexing (states[0] is enc-init dumped above). l7 and
    # l9 give the resolution to localize the explosion seen between l5 and l11.
    def make_layer_tap(key, fname):
        def hook(module, inputs, output):
            if seen_hub[key]:
                return
            h = output[0] if isinstance(output, tuple) else output
            arr = h[0].detach().to(torch.float32).cpu().numpy()
            save_dump(os.path.join(dump_dir, fname), arr)
            seen_hub[key] = True
        return hook
    sm.encoder.layers[0].register_forward_hook(make_layer_tap("l0",  "hubert-l0.bin"))
    sm.encoder.layers[5].register_forward_hook(make_layer_tap("l5",  "hubert-l5.bin"))
    sm.encoder.layers[7].register_forward_hook(make_layer_tap("l7",  "hubert-l7.bin"))
    sm.encoder.layers[9].register_forward_hook(make_layer_tap("l9",  "hubert-l9.bin"))
    sm.encoder.layers[11].register_forward_hook(make_layer_tap("l11", "hubert-l11.bin"))

    # Bisect the LM forward by dumping per layer hidden states at step 0.
    # The C++ pipeline dumps cond and uncond at layers 0, 6, 13, 20 and the
    # final norm. Mirror that exactly so a per layer max_abs_diff comparison
    # localizes where the drift starts.
    seen_lm = {"done": False}
    def make_layer_hook(name):
        def hook(module, inputs, output):
            if seen_lm["done"]:
                return
            h = output[0] if isinstance(output, tuple) else output
            if not isinstance(h, torch.Tensor) or h.ndim < 3:
                return
            cond_arr = h[0].detach().to(torch.float32).cpu().numpy()
            save_dump(os.path.join(dump_dir, f"lm-hidden-step0-cond-{name}.bin" if name else "lm-hidden-step0-cond.bin"), cond_arr)
            if h.shape[0] >= 2:
                unc_arr = h[1].detach().to(torch.float32).cpu().numpy()
                save_dump(os.path.join(dump_dir, f"lm-hidden-step0-uncond-{name}.bin" if name else "lm-hidden-step0-uncond.bin"), unc_arr)
        return hook
    lm_hook_handles = []
    for idx, name in [(0, "l0"), (1, "l1"), (2, "l2"), (3, "l3"), (4, "l4"), (5, "l5"), (6, "l6"),
                      (13, "l13"), (14, "l14"), (15, "l15"), (16, "l16"), (17, "l17"), (18, "l18"),
                      (19, "l19"), (20, "l20")]:
        if idx < len(model.llm.layers):
            lm_hook_handles.append(model.llm.layers[idx].register_forward_hook(make_layer_hook(name)))
    lm_hook_handles.append(model.llm.norm.register_forward_hook(make_layer_hook("")))
    # Trip seen_lm["done"] right after the first LM forward by hooking the
    # outermost forward via the existing _predict_tokens_with_scoring path
    # already gated on step0. We instead use an extra one-shot hook on the
    # last collected layer (norm) to flip the flag after its dump fires.
    def flip_done(module, inputs, output):
        seen_lm["done"] = True
    lm_hook_handles.append(model.llm.norm.register_forward_hook(flip_done))

    # Per-layer hooks above already cover layers 0-6, 13-20 and final norm.
    # Add the embed boundary on top and a sub-module bisect inside layer 1 to
    # locate where the L0 -> L1 jump (15x in one layer) originates.
    orig_prepare_embed = model._prepare_embed_inputs
    def hooked_prepare_embed(input_ids, audio_mask):
        out = orig_prepare_embed(input_ids, audio_mask)
        if not seen_lm["done"]:
            arr = out.detach().to(torch.float32).cpu().numpy()
            save_dump(os.path.join(dump_dir, "lm-hidden-step0-cond-embed.bin"), arr[0])
            if arr.shape[0] >= 2:
                save_dump(os.path.join(dump_dir, "lm-hidden-step0-uncond-embed.bin"), arr[1])
        return out
    model._prepare_embed_inputs = hooked_prepare_embed

    # Layer 1 sub-module bisect : dump after input_layernorm, after self_attn
    # (pre-residual), after post_attn_layernorm and after mlp (pre-residual).
    # Suffixes : -l1-norm1, -l1-attn, -l1-norm2, -l1-mlp.
    def make_sub_hook(suffix):
        def hook(module, inputs, output):
            if seen_lm["done"]:
                return
            h = output[0] if isinstance(output, tuple) else output
            if not isinstance(h, torch.Tensor) or h.ndim < 3:
                return
            arr = h.detach().to(torch.float32).cpu().numpy()
            save_dump(os.path.join(dump_dir, f"lm-hidden-step0-cond-l1-{suffix}.bin"), arr[0])
            if arr.shape[0] >= 2:
                save_dump(os.path.join(dump_dir, f"lm-hidden-step0-uncond-l1-{suffix}.bin"), arr[1])
        return hook
    if len(model.llm.layers) > 1:
        l1 = model.llm.layers[1]
        lm_hook_handles.append(l1.input_layernorm.register_forward_hook(make_sub_hook("norm1")))
        lm_hook_handles.append(l1.self_attn.register_forward_hook(make_sub_hook("attn")))
        lm_hook_handles.append(l1.post_attention_layernorm.register_forward_hook(make_sub_hook("norm2")))
        lm_hook_handles.append(l1.mlp.register_forward_hook(make_sub_hook("mlp")))

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--prompt",    default="../examples/prompt.txt")
    ap.add_argument("--ref-text",  default="../examples/freeman.txt")
    ap.add_argument("--ref-wav",   default="../examples/freeman.wav")
    ap.add_argument("--seed",      type=int, default=42)
    ap.add_argument("--lang",      default="English")
    ap.add_argument("--duration",  type=float, default=None)
    ap.add_argument("--quant",     default="F32",
                    help="quantization suffix for GGUF (default: F32, e.g. BF16, Q8_0, Q4_K_M)")
    ap.add_argument("--out-cpp",   default="cpp/clone-cpp.wav")
    ap.add_argument("--out-pt",    default="python/clone-python.wav")
    args = ap.parse_args()

    model_lm  = MODEL_LM_T.format(q=args.quant)
    model_cdc = MODEL_CDC_T.format(q=args.quant)
    for p in (model_lm, model_cdc):
        if not os.path.isfile(p):
            print(f"[Error] GGUF not found: {p}")
            sys.exit(1)
    print(f"[Quant] {args.quant} -> {model_lm} + {model_cdc}")

    ensure_dir(DUMP_CPP)
    ensure_dir(DUMP_PT)
    os.makedirs(os.path.dirname(args.out_cpp) or ".", exist_ok=True)

    with open(args.prompt, "r", encoding="utf-8") as f:
        text = f.read().strip()
    with open(args.ref_text, "r", encoding="utf-8") as f:
        ref_text = f.read().strip()
    print(f"[Input] Prompt: {len(text)} chars: {text[:60]}{'...' if len(text) > 60 else ''}")
    print(f"[Input] RefText: {len(ref_text)} chars: {ref_text[:60]}{'...' if len(ref_text) > 60 else ''}")
    print(f"[Input] RefWav: {args.ref_wav}")
    print(f"[Input] Language: {args.lang}")
    print(f"[Input] Seed: {args.seed}")

    fix_random_seed(args.seed)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    model  = OmniVoice.from_pretrained(
        CKPT,
        torch_dtype=torch.float32,
        attn_implementation="eager",
    ).to(device).eval()
    install_hooks(model, DUMP_PT)
    gen_kwargs = dict(
        text=text,
        language=args.lang,
        ref_text=ref_text,
        ref_audio=args.ref_wav,
        duration=args.duration,
    )
    audios = model.generate(**gen_kwargs)
    audio_pt = np.asarray(audios[0], dtype=np.float32)
    sf.write(args.out_pt, audio_pt, 24000, subtype="FLOAT")
    print(f"[Python] Audio: {audio_pt.shape[0]} samples {audio_pt.shape[0] / 24000:.2f}s -> {args.out_pt}")

    del model
    torch.cuda.empty_cache()

    # Strict F32 conformance: disable FlashAttention (eager attention) and
    # force strict F32 cuBLAS to match the Python side with allow_tf32=False
    # set at the top of this file. Both pipelines encode the reference audio
    # independently so every encoder stage gets compared frontally.
    cmd = [
        BIN,
        "--model",         model_lm,
        "--codec",         model_cdc,
        "--seed",          str(args.seed),
        "--ref-wav",       args.ref_wav,
        "--ref-text",      args.ref_text,
        "--lang",          args.lang,
        "--format",        "wav32",
        "--dump",          DUMP_CPP,
        "-o",              args.out_cpp,
        "--no-fa",
    ]
    if args.duration:
        cmd += ["--duration", str(args.duration)]
    print(f"[GGML] Cmd: {' '.join(cmd)}")
    r = subprocess.run(cmd, input=text, text=True)
    if r.returncode != 0:
        sys.exit(r.returncode)
    audio_cpp, sr = sf.read(args.out_cpp)
    if audio_cpp.ndim > 1:
        audio_cpp = audio_cpp[:, 0]
    audio_cpp = audio_cpp.astype(np.float32)
    print(f"[GGML] Audio: {audio_cpp.shape[0]} samples {sr} Hz {audio_cpp.shape[0] / sr:.2f}s -> {args.out_cpp}")

    # Cossim in pipeline order: prompt-ids -> logits -> tokens -> audio.
    # Cond and uncond on the same line so a drift localizes to the originating
    # stage. PromptIDs split into zones (style, text, ref-audio, target) when
    # the lengths can be inferred from the prompt log.
    def pair(name):
        a, _ = load_dump(os.path.join(DUMP_CPP, name))
        b, _ = load_dump(os.path.join(DUMP_PT,  name))
        return a, b

    ca, cb = pair("prompt-cond-ids.bin")
    ua, ub = pair("prompt-uncond-ids.bin")
    n = min(ca.size, cb.size)
    cai = ca.astype(np.int64).ravel()[:n]
    cbi = cb.astype(np.int64).ravel()[:n]
    cdiffs = np.where(cai != cbi)[0]
    n2 = min(ua.size, ub.size)
    uai = ua.astype(np.int64).ravel()[:n2]
    ubi = ub.astype(np.int64).ravel()[:n2]
    udiffs = np.where(uai != ubi)[0]
    print(f"[Cossim] PromptIDs cond exact: {100.0 * (1 - cdiffs.size / max(n, 1)):.2f}% diffs: {cdiffs.size} uncond exact: {100.0 * (1 - udiffs.size / max(n2, 1)):.2f}% diffs: {udiffs.size}")
    if cdiffs.size > 0:
        print(f"[Cossim] PromptIDs cond first diff range: s={cdiffs[0]}..{cdiffs[-1]}")
    if udiffs.size > 0:
        print(f"[Cossim] PromptIDs uncond first diff range: s={udiffs[0]}..{udiffs[-1]}")

    def maxabs(a, b):
        a = a.astype(np.float64).ravel()
        b = b.astype(np.float64).ravel()
        n = min(len(a), len(b))
        if not n:
            return 0.0
        a, b = a[:n], b[:n]
        # log_probs has -inf at the audio_mask_id slot on both sides. Drop
        # positions that are non finite on either side so the subtract is safe.
        keep = np.isfinite(a) & np.isfinite(b)
        if not keep.all():
            a = a[keep]
            b = b[keep]
        return float(np.max(np.abs(a - b))) if a.size else 0.0

    ra, rb = pair("ref-audio-16k.bin")
    print(f"[Cossim] RefAudio16k max_abs_diff: {maxabs(ra, rb):.3e} cossim: {cos(ra, rb):.6f} samples: {min(ra.size, rb.size)}")

    fa, fb = pair("ref-hubert-features.bin")
    print(f"[Cossim] HuBERT-features cossim: {cos(fa, fb):.6f} max_abs_diff: {maxabs(fa, fb):.3e} shape_cpp: {fa.shape} shape_pt: {fb.shape}")

    # HuBERT bisect taps in pipeline order : feat-extract (post 7 conv1d) ->
    # feat-proj-ln (post LN, pre Linear) -> feat-proj (post 512 -> 768 Linear)
    # -> enc-init (post pos_conv add + LN) -> l0 -> l5 -> l7 -> l9 -> l11. The
    # downsample 2x and the 13-state mean that follow are already covered by
    # HuBERT-features above.
    for tap in ["hubert-feat-extract", "hubert-feat-proj-ln", "hubert-feat-proj",
                "hubert-enc-init", "hubert-l0", "hubert-l5", "hubert-l7", "hubert-l9", "hubert-l11"]:
        ta, tb = pair(tap + ".bin")
        print(f"[Cossim] {tap} cossim: {cos(ta, tb):.6f} max_abs_diff: {maxabs(ta, tb):.3e} shape_cpp: {ta.shape} shape_pt: {tb.shape}")

    ka, kb = pair("ref-audio-codes.bin")
    n_k = min(ka.size, kb.size)
    kai = ka.astype(np.int64).ravel()[:n_k]
    kbi = kb.astype(np.int64).ravel()[:n_k]
    kdiffs = np.where(kai != kbi)[0]
    print(f"[Cossim] RefAudioCodes exact: {100.0 * (1 - kdiffs.size / max(n_k, 1)):.2f}% diffs: {kdiffs.size} shape_cpp: {ka.shape} shape_pt: {kb.shape}")
    if ka.ndim == 2 and kb.ndim == 2 and ka.shape == kb.shape:
        per_k = []
        for k in range(ka.shape[0]):
            d = int(np.sum(ka[k].astype(np.int64) != kb[k].astype(np.int64)))
            per_k.append(f"k{k}={100.0 * (1 - d / ka.shape[1]):.1f}%")
        print(f"[Cossim] RefAudioCodes per-codebook: {' '.join(per_k)}")

    # Per layer LM hidden state drift bisect. C++ dumps cond and uncond at
    # 4 mid layers and the final norm. Pairs only print if both files exist.
    for layer_name, label in [("l0", "L0"),
                              ("l1-norm1", "L1.norm1"), ("l1-attn", "L1.attn"),
                              ("l1-norm2", "L1.norm2"), ("l1-mlp", "L1.mlp"),
                              ("l1", "L1"), ("l2", "L2"), ("l3", "L3"), ("l4", "L4"), ("l5", "L5"), ("l6", "L6"),
                              ("l13", "L13"), ("l14", "L14"), ("l15", "L15"), ("l16", "L16"), ("l17", "L17"), ("l18", "L18"),
                              ("l19", "L19"), ("l20", "L20"), ("", "Lf")]:
        suffix = f"-{layer_name}" if layer_name else ""
        cn = f"lm-hidden-step0-cond{suffix}.bin"
        un = f"lm-hidden-step0-uncond{suffix}.bin"
        try:
            ca, cb = pair(cn)
            ua, ub = pair(un)
        except FileNotFoundError:
            continue
        # Trim cpp shape (which is c_len) into the larger pt shape (max_c_len)
        nc = min(ca.size, cb.size)
        nu = min(ua.size, ub.size)
        cad = ca.ravel()[:nc].astype(np.float64)
        cbd = cb.ravel()[:nc].astype(np.float64)
        uad = ua.ravel()[:nu].astype(np.float64)
        ubd = ub.ravel()[:nu].astype(np.float64)
        max_c = float(np.max(np.abs(cad - cbd))) if nc else 0.0
        max_u = float(np.max(np.abs(uad - ubd))) if nu else 0.0
        cos_c = cos(cad, cbd)
        cos_u = cos(uad, ubd)
        print(f"[Cossim] {label} hidden cond: cos={cos_c:.6f} max_abs={max_c:.3e}  uncond: cos={cos_u:.6f} max_abs={max_u:.3e}")

    ca, cb = pair("lm-logits-step0-cond.bin")
    ua, ub = pair("lm-logits-step0-uncond.bin")
    print(f"[Cossim] Logits cond: {cos(ca, cb):.6f} uncond: {cos(ua, ub):.6f}")

    la, lb = pair("mg-log-probs-step0.bin")
    print(f"[Cossim] Step0 log_probs cossim: {cos(la, lb):.6f} max_abs_diff: {maxabs(la, lb):.3e}")

    pa, pb = pair("mg-pred-tokens-step0.bin")
    n_p = min(pa.size, pb.size)
    pai = pa.astype(np.int64).ravel()[:n_p]
    pbi = pb.astype(np.int64).ravel()[:n_p]
    pdiffs = np.where(pai != pbi)[0]
    print(f"[Cossim] Step0 pred_tokens exact: {100.0 * (1 - pdiffs.size / max(n_p, 1)):.2f}% diffs: {pdiffs.size}/{n_p}")

    sa, sb = pair("mg-scores-step0.bin")
    print(f"[Cossim] Step0 scores cossim: {cos(sa, sb):.6f} max_abs_diff: {maxabs(sa, sb):.3e}")

    ta, tb = pair("mg-tokens.bin")
    n = min(ta.size, tb.size)
    ai = ta.astype(np.int64).ravel()[:n]
    bi = tb.astype(np.int64).ravel()[:n]
    print(f"[Cossim] Tokens: {cos(ta, tb):.6f} exact: {100.0 * float(np.mean(ai == bi)):.2f}%")

    aa, ab = pair("output-audio.bin")
    print(f"[Cossim] Audio: {cos(aa, ab):.6f}")

    n = min(audio_cpp.size, audio_pt.size)
    print(f"[Cossim] WAV stft_cos: {stft_cos(audio_cpp[:n], audio_pt[:n]):.6f} samples: {n}")

if __name__ == "__main__":
    main()
