#!/usr/bin/env python3
"""WS2 (г) verification — INDEPENDENT re-derivation of the output-token fertility
coefficients (L2-budget-wrong-unit fix), NOT trusting fertility_calib.json.

Reproduces est_out = f_cjk*cjk_src + f_other*other_src (OLS, no intercept) over the
25-chapter guzhenren rerun, and cross-checks the §1.6 finding: fertility_calib's
ord-range CJK classifier differs from the backend EstimateTokens class set
(unicode.Han|Hiragana|Katakana|Hangul). We fit under BOTH classifiers and report the
coefficient delta so the plan can decide (align vs document-invariance).

$0: deterministic, no LLM, no network. Reuses eval/tokenizers/glm-4.6 + rerun/records.json.
Writes result to /home/ubuntu/books/gu-zhenren/design11/ws2_fertility_verify.json (OUT of git).
"""
import json, os, sys
import numpy as np
from tokenizers import Tokenizer

ROOT = "/home/ubuntu/projects/textmachine"
RECORDS = "/home/ubuntu/books/gu-zhenren/rerun/records.json"
TOK = f"{ROOT}/eval/tokenizers/glm-4.6/tokenizer.json"
STORED = "/home/ubuntu/books/gu-zhenren/exp15/fertility_calib.json"
OUT = "/home/ubuntu/books/gu-zhenren/design11/ws2_fertility_verify.json"

# ---- classifier A: fertility_calib.py is_cjk (ord ranges, NO Hangul) ----
def is_cjk_ord(c):
    o = ord(c)
    return (0x4E00 <= o <= 0x9FFF or 0x3040 <= o <= 0x30FF or 0x3400 <= o <= 0x4DBF
            or 0xF900 <= o <= 0xFAFF or 0x20000 <= o <= 0x2A6DF)

# ---- classifier B: backend EstimateTokens (unicode property Han|Hira|Kata|Hangul) ----
import unicodedata
def is_cjk_prop(c):
    # Approximate Go unicode.Han|Hiragana|Katakana|Hangul via Unicode script.
    # Python has no stdlib script table; use unicodedata name prefixes as a proxy that
    # matches the Go ranges for the scripts in question.
    try:
        name = unicodedata.name(c)
    except ValueError:
        return False
    o = ord(c)
    # Han (CJK ideographs incl ext), Hiragana, Katakana, Hangul syllables/jamo
    if (0x3400 <= o <= 0x9FFF or 0xF900 <= o <= 0xFAFF or 0x20000 <= o <= 0x2FA1F):
        return "CJK" in name or "IDEOGRAPH" in name
    if 0x3040 <= o <= 0x309F: return True   # Hiragana
    if 0x30A0 <= o <= 0x30FF: return True   # Katakana (incl. 0x30FB middle dot? Go: Katakana table)
    if 0xAC00 <= o <= 0xD7A3 or 0x1100 <= o <= 0x11FF or 0x3130 <= o <= 0x318F: return True  # Hangul
    return False

def classes(text, is_cjk):
    cjk = other = 0
    for c in text:
        if c.isspace():
            continue
        if is_cjk(c):
            cjk += 1
        else:
            other += 1
    return cjk, other

def fit(rows):
    A = np.array([[c, o] for (c, o, _) in rows], dtype=float)
    y = np.array([t for (_, _, t) in rows], dtype=float)
    coef, *_ = np.linalg.lstsq(A, y, rcond=None)
    pred = A @ coef
    ss_res = float(np.sum((y - pred) ** 2))
    ss_tot = float(np.sum((y - y.mean()) ** 2))
    r2 = 1 - ss_res / ss_tot
    mape = float(np.mean(np.abs(y - pred) / y) * 100)
    return float(coef[0]), float(coef[1]), r2, mape

def main():
    tok = Tokenizer.from_file(TOK)
    recs = json.load(open(RECORDS))
    rows_A, rows_B = [], []
    used = skipped_empty = skipped_tiny = 0
    for r in recs:
        src = r.get("source", "") or ""
        fin = r.get("final", "") or ""
        if not src.strip() or not fin.strip():
            skipped_empty += 1
            continue
        cA, oA = classes(src, is_cjk_ord)
        if cA + oA < 200:
            skipped_tiny += 1
            continue
        out_tok = len(tok.encode(fin).ids)
        cB, oB = classes(src, is_cjk_prop)
        rows_A.append((cA, oA, out_tok))
        rows_B.append((cB, oB, out_tok))
        used += 1

    fa = fit(rows_A)   # classifier A (ord ranges, as fertility_calib)
    fb = fit(rows_B)   # classifier B (backend property set)
    stored = json.load(open(STORED))

    result = {
        "n_records_used": used, "skipped_empty": skipped_empty, "skipped_tiny": skipped_tiny,
        "total_records": len(recs),
        "classifier_A_ord_ranges (fertility_calib)": {
            "f_cjk": round(fa[0], 4), "f_other": round(fa[1], 4),
            "r2": round(fa[2], 4), "mape_pct": round(fa[3], 2)},
        "classifier_B_backend_property (Han|Hira|Kata|Hangul)": {
            "f_cjk": round(fb[0], 4), "f_other": round(fb[1], 4),
            "r2": round(fb[2], 4), "mape_pct": round(fb[3], 2)},
        "stored_artifact": {"f_cjk": stored["f_cjk"], "f_other": stored["f_other"],
                            "r2": stored["r2"], "mape_pct": stored["mape_pct"],
                            "n_records": stored["n_records"]},
        "coef_delta_A_vs_stored": {"f_cjk": round(abs(fa[0] - stored["f_cjk"]), 6),
                                   "f_other": round(abs(fa[1] - stored["f_other"]), 6)},
        "coef_delta_B_vs_A (classifier mismatch impact)": {
            "f_cjk": round(fb[0] - fa[0], 4), "f_other": round(fb[1] - fa[1], 4),
            "pct_est_out_shift_on_median_zh_chunk": None},
    }
    # quantify classifier mismatch: predicted est_out on the median src chunk under A vs B
    med = rows_A[len(rows_A)//2]
    estA = fa[0]*med[0] + fa[1]*med[1]
    # use classifier B counts for the same chunk under fit B
    medB = rows_B[len(rows_B)//2]
    estB = fb[0]*medB[0] + fb[1]*medB[1]
    result["coef_delta_B_vs_A (classifier mismatch impact)"]["pct_est_out_shift_on_median_zh_chunk"] = round((estB-estA)/estA*100, 3)

    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    json.dump(result, open(OUT, "w"), ensure_ascii=False, indent=2)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    # PASS assertion: independent re-derivation matches stored to <1e-3
    dA = result["coef_delta_A_vs_stored"]
    ok = dA["f_cjk"] < 1e-3 and dA["f_other"] < 1e-3
    print(f"\nVERDICT: independent re-derivation {'REPRODUCES' if ok else 'DIVERGES FROM'} stored coefficients "
          f"(Δf_cjk={dA['f_cjk']}, Δf_other={dA['f_other']}).")

if __name__ == "__main__":
    main()
