#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Vérificateur AUTONOME de ChaserBench — sans Chaser, sans compte, sans réseau.

Pourquoi ce fichier existe
--------------------------
Le moteur Chaser est fermé. Vous ne pouvez donc pas nous croire sur parole quand
nous publions « −58 % de coût ». Ce script retire la confiance de l'équation sur
tout ce qui PEUT être vérifié sans nous :

  1. l'empreinte SHA-256 du corpus gelé correspond bien à celle annoncée ;
  2. les agrégats publiés (coût total, réussite, latence médiane) se recalculent
     bien depuis les 30 lignes par tâche du même fichier ;
  3. les bornes de confiance anytime-valid (empirical-Bernstein, Waudby-Smith &
     Ramdas 2024) sont recalculées ICI, par une implémentation indépendante en
     bibliothèque standard — pas par la nôtre ;
  4. le gain agrégé ET sa médiane par tâche sont recalculés et comparés à ce que
     la page publique affiche.

Ce que ce script NE prouve PAS (dit franchement) : que les réponses des modèles
ont bien été produites comme décrit. Les appels API ne sont pas rejouables par un
tiers — seul un run avec vos propres clés le ferait. Ce script prouve que nos
chiffres publiés sont FIDÈLES à nos données publiées, et que nos statistiques
sont correctes. C'est le maximum vérifiable sans nous ; le reste demande votre
propre run (voir README ci-dessous).

Usage
-----
    python verifier_bench.py                          # vérifie le run le plus récent du dossier
    python verifier_bench.py chaserbench-*.json       # un run précis
    python verifier_bench.py --corpus corpus_v2_2026Q3.jsonl
    python verifier_bench.py --json                   # sortie machine
    python verifier_bench.py test                     # auto-test du vérificateur

Code de sortie : 0 = CONFORME, 1 = ÉCART (fail-closed), 2 = erreur d'usage.
Dépendances : aucune (bibliothèque standard uniquement). Python ≥ 3.8.
"""

import hashlib
import json
import math
import os
import statistics
import sys

# Ce script tourne chez des inconnus, sur des consoles qu'on ne choisit pas.
# Sans ce garde, une console Windows en cp1252 lève UnicodeEncodeError au
# premier tiret cadratin — un vérificateur qui plante ne vérifie rien.
try:
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError, OSError):
    pass

TOLERANCE = 5e-4          # écart absolu toléré sur les valeurs arrondies publiées
C_LAMBDA = 0.5            # plafond de λ_t (papier : c ∈ [1/2, 3/4])


# ---------------------------------------------------------------- statistiques
def _psi_e(lam):
    return (-math.log(1.0 - lam) - lam) / 4.0


def eb_sequence(valeurs, alpha=0.05, c=C_LAMBDA):
    """Séquence de confiance empirical-Bernstein sur la moyenne de valeurs
    bornées dans [0,1]. Renvoie (centre, bas, haut, n). Anytime-valid : la
    couverture tient même si l'on regarde après chaque observation."""
    xs = [min(1.0, max(0.0, float(v))) for v in valeurs if v is not None]
    n = len(xs)
    if n == 0:
        return 0.0, 0.0, 1.0, 0
    ln2a = math.log(2.0 / alpha)
    mu_prec, sigma2_prec = 0.5, 0.25
    somme_x = somme_carres = 0.0
    s_lx = s_l = s_v_psi = 0.0
    for t, x in enumerate(xs, start=1):
        lam = min(math.sqrt(2.0 * ln2a / (sigma2_prec * t * math.log(t + 1.0))), c)
        s_lx += lam * x
        s_l += lam
        s_v_psi += 4.0 * (x - mu_prec) ** 2 * _psi_e(lam)
        somme_x += x
        mu_t = (0.5 + somme_x) / (t + 1.0)
        somme_carres += (x - mu_t) ** 2
        sigma2_prec = (0.25 + somme_carres) / (t + 1.0)
        mu_prec = mu_t
    centre = s_lx / s_l
    rayon = (ln2a + s_v_psi) / s_l
    return centre, max(0.0, centre - rayon), min(1.0, centre + rayon), n


def eb_signe(deltas, alpha=0.05):
    """EB sur des deltas ∈ [−1,1] : remap affine vers [0,1] puis re-transformation."""
    c, bas, haut, n = eb_sequence([(d + 1.0) / 2.0 for d in deltas], alpha)
    return {"n": n, "moyenne": 2 * c - 1, "borne_basse": 2 * bas - 1,
            "borne_haute": 2 * haut - 1}


# ---------------------------------------------------------------- vérification
def _proche(a, b, tol=TOLERANCE):
    return a is not None and b is not None and abs(float(a) - float(b)) <= tol


def sha256_fichier(chemin):
    h = hashlib.sha256()
    with open(chemin, "rb") as f:
        for bloc in iter(lambda: f.read(65536), b""):
            h.update(bloc)
    return h.hexdigest()


def verifier_run(run, corpus_path=None):
    """Recalcule tout depuis `par_tache` et compare aux agrégats du même fichier.
    Renvoie (liste de contrôles, dict de valeurs recalculées)."""
    ctrl, val = [], {}
    pt = run.get("par_tache") or []
    alpha = run.get("alpha", 0.05)

    def note(nom, ok, detail=""):
        ctrl.append({"controle": nom, "ok": bool(ok), "detail": detail})

    note("par_tache present", bool(pt), "%d tache(s)" % len(pt))
    if not pt:
        return ctrl, val

    n_annonce = (run.get("corpus") or {}).get("n_taches")
    if n_annonce is not None:
        note("n_taches coherent", len(pt) == n_annonce,
             "annonce %s, trouve %d" % (n_annonce, len(pt)))

    # --- agrégats par bras, recalculés
    for bras in ("reference", "chaser"):
        cout = sum(t[bras]["cout_usd"] for t in pt)
        oks = [1.0 if t[bras]["reussite"] else 0.0 for t in pt]
        lats = sorted(t[bras]["latence_s"] for t in pt)
        med = lats[len(lats) // 2]
        pub = (run.get("bras") or {}).get(bras, {})
        val[bras] = {"cout_total_usd": cout, "reussites": int(sum(oks)),
                     "latence_mediane_s": med}
        note("%s : cout total" % bras, _proche(round(cout, 5), pub.get("cout_total_usd")),
             "recalcule %.5f $ vs publie %s $" % (cout, pub.get("cout_total_usd")))
        c, bas, haut, _ = eb_sequence(oks, alpha)
        pub_r = pub.get("reussite") or {}
        note("%s : reussite EB" % bras,
             _proche(round(c, 4), pub_r.get("centre"))
             and _proche(round(bas, 4), pub_r.get("borne_basse")),
             "recalcule centre %.4f [%.4f ; %.4f] vs publie %s [%s ; %s]"
             % (c, bas, haut, pub_r.get("centre"), pub_r.get("borne_basse"),
                pub_r.get("borne_haute")))
        note("%s : latence mediane" % bras,
             _proche(round(med, 2), pub.get("latence_mediane_s"), 5e-3),
             "recalcule %.2f s vs publie %s s" % (med, pub.get("latence_mediane_s")))

    # --- deltas appariés, recalculés dans l'ordre du fichier (EB est séquentiel)
    d_cout, d_qual, gains = [], [], []
    for t in pt:
        base = max(t["reference"]["cout_usd"], 1e-12)
        g = (t["reference"]["cout_usd"] - t["chaser"]["cout_usd"]) / base
        gains.append(g)
        d_cout.append(max(-1.0, min(1.0, g)))
        d_qual.append((1.0 if t["reference"]["reussite"] else 0.0)
                      - (1.0 if t["chaser"]["reussite"] else 0.0))

    paires = run.get("paires") or {}
    for cle, deltas in (("gain_cout_relatif", d_cout),
                        ("delta_qualite_ref_moins_chaser", d_qual)):
        e = eb_signe(deltas, alpha)
        pub = paires.get(cle) or {}
        val[cle] = e
        note("%s : moyenne + bornes EB" % cle,
             _proche(round(e["moyenne"], 4), pub.get("moyenne"))
             and _proche(round(e["borne_basse"], 4), pub.get("borne_basse"))
             and _proche(round(e["borne_haute"], 4), pub.get("borne_haute")),
             "recalcule %.4f [%.4f ; %.4f] vs publie %s [%s ; %s]"
             % (e["moyenne"], e["borne_basse"], e["borne_haute"], pub.get("moyenne"),
                pub.get("borne_basse"), pub.get("borne_haute")))

    # --- les deux lectures du gain : agrégat ET médiane (jamais l'un sans l'autre)
    agr = 1.0 - val["chaser"]["cout_total_usd"] / max(val["reference"]["cout_total_usd"], 1e-12)
    val["gain_agrege"] = agr
    val["gain_median_par_tache"] = statistics.median(gains)
    val["gain_moyen_par_tache"] = statistics.fmean(gains)

    # --- corpus : l'empreinte annoncée est-elle celle du fichier ?
    sha_annonce = (run.get("corpus") or {}).get("sha256")
    if corpus_path and os.path.exists(corpus_path):
        reel = sha256_fichier(corpus_path)
        note("corpus SHA-256", reel == sha_annonce,
             "fichier %s -> %s… vs annonce %s…"
             % (os.path.basename(corpus_path), reel[:12], (sha_annonce or "?")[:12]))
        val["corpus_sha256"] = reel
    else:
        note("corpus SHA-256", True,
             "corpus absent du dossier — controle NON EFFECTUE (fournir --corpus)")
        ctrl[-1]["ok"] = None       # ni vert ni rouge : non testé

    return ctrl, val


def dernier_run(dossier):
    fics = [f for f in os.listdir(dossier)
            if f.startswith("chaserbench-") and f.endswith(".json")]
    dates = []
    for f in fics:
        try:
            d = json.load(open(os.path.join(dossier, f), encoding="utf-8"))
        except Exception:
            continue
        if "par_tache" in d:
            dates.append((d.get("date", ""), f))
    if not dates:
        return None
    return os.path.join(dossier, max(dates)[1])


def _corpus_probable(run, dossier):
    nom = os.path.basename(((run.get("corpus") or {}).get("fichier") or "").replace("\\", "/"))
    if not nom:
        return None
    for cand in (os.path.join(dossier, nom), os.path.join(dossier, "..", "bench", nom), nom):
        if os.path.exists(cand):
            return cand
    return None


# ------------------------------------------------------------------- affichage
def afficher(chemin, ctrl, val):
    print("Verificateur autonome ChaserBench — %s" % os.path.basename(chemin))
    print("  (aucune dependance, aucun appel reseau, statistiques recalculees ici)\n")
    for c in ctrl:
        marque = "  ?  " if c["ok"] is None else ("  OK " if c["ok"] else "  !! ")
        print("%s %-32s %s" % (marque, c["controle"], c["detail"]))
    if val:
        print("\n  Lecture du gain (les DEUX, jamais l'un sans l'autre) :")
        print("    agregat (cout total)      : %.1f %%" % (100 * val["gain_agrege"]))
        print("    mediane par tache         : %.1f %%" % (100 * val["gain_median_par_tache"]))
        print("    moyenne par tache         : %.1f %%" % (100 * val["gain_moyen_par_tache"]))
        g = val.get("gain_cout_relatif") or {}
        if g:
            print("    borne basse EB (anytime)  : %.4f  -> %s"
                  % (g["borne_basse"],
                     "gain PROUVE (> 0)" if g["borne_basse"] > 0 else "NON prouve"))
    echecs = [c for c in ctrl if c["ok"] is False]
    print("\nVERDICT : %s" % ("CONFORME" if not echecs
                              else "ECART (%d controle(s) en echec)" % len(echecs)))
    return 1 if echecs else 0


# ------------------------------------------------------------------- auto-test
def test():
    """Auto-test : le vérificateur doit ACCEPTER un run cohérent et REFUSER un
    run trafiqué. Un vérificateur qu'on n'a pas essayé de tromper ne vaut rien."""
    ok = [True]

    def chk(cond, nom):
        print(("  OK " if cond else "  !! ") + nom)
        if not cond:
            ok[0] = False

    # run synthétique cohérent, construit par les mêmes formules
    pt = []
    for i in range(10):
        ref_c = 0.010 + 0.001 * i
        pt.append({"id": "t%d" % i, "categorie": "test",
                   "reference": {"reussite": True, "cout_usd": ref_c, "latence_s": 4.0},
                   "chaser": {"reussite": True, "cout_usd": ref_c * 0.4, "latence_s": 2.0}})
    d_cout = [0.6] * 10
    e = eb_signe(d_cout, 0.05)
    q = eb_signe([0.0] * 10, 0.05)
    cr, cbas, chaut, _ = eb_sequence([1.0] * 10, 0.05)
    run = {"alpha": 0.05, "corpus": {"n_taches": 10, "sha256": "x" * 64},
           "bras": {"reference": {"cout_total_usd": round(sum(t["reference"]["cout_usd"] for t in pt), 5),
                                  "reussite": {"centre": round(cr, 4), "borne_basse": round(cbas, 4),
                                               "borne_haute": round(chaut, 4)},
                                  "latence_mediane_s": 4.0},
                    "chaser": {"cout_total_usd": round(sum(t["chaser"]["cout_usd"] for t in pt), 5),
                               "reussite": {"centre": round(cr, 4), "borne_basse": round(cbas, 4),
                                            "borne_haute": round(chaut, 4)},
                               "latence_mediane_s": 2.0}},
           "paires": {"gain_cout_relatif": {k: round(v, 4) for k, v in e.items() if k != "n"},
                      "delta_qualite_ref_moins_chaser": {k: round(v, 4) for k, v in q.items() if k != "n"}},
           "par_tache": pt}
    ctrl, val = verifier_run(run)
    chk(not [c for c in ctrl if c["ok"] is False], "run coherent -> CONFORME")
    chk(abs(val["gain_agrege"] - 0.6) < 1e-9, "gain agrege recalcule = 60 %")
    chk(abs(val["gain_median_par_tache"] - 0.6) < 1e-9, "mediane par tache = 60 %")

    # mutations : chaque mensonge plausible doit être ATTRAPÉ
    import copy
    for nom, mut in (
        ("cout total gonfle", lambda r: r["bras"]["reference"].update({"cout_total_usd": 0.30})),
        ("gain publie exagere", lambda r: r["paires"]["gain_cout_relatif"].update({"moyenne": 0.9})),
        ("borne basse elargie", lambda r: r["paires"]["gain_cout_relatif"].update({"borne_basse": 0.5})),
        ("latence embellie", lambda r: r["bras"]["chaser"].update({"latence_mediane_s": 0.5})),
        ("une tache retiree du detail", lambda r: r["par_tache"].pop()),
        ("reussite EB embellie", lambda r: r["bras"]["chaser"]["reussite"].update({"borne_basse": 0.99})),
    ):
        r = copy.deepcopy(run)
        mut(r)
        c2, _ = verifier_run(r)
        chk(any(c["ok"] is False for c in c2), "mutation attrapee : %s" % nom)

    print("\nAUTO-TEST : %s" % ("OK" if ok[0] else "ECHEC"))
    return 0 if ok[0] else 1


def main(argv):
    argv = list(argv)
    if "test" in argv:
        return test()
    sortie_json = "--json" in argv
    argv = [a for a in argv if a != "--json"]
    corpus = None
    if "--corpus" in argv:
        i = argv.index("--corpus")
        try:
            corpus = argv[i + 1]
        except IndexError:
            print("usage : --corpus <fichier.jsonl>")
            return 2
        del argv[i:i + 2]

    dossier = os.path.dirname(os.path.abspath(__file__))
    chemin = argv[1] if len(argv) > 1 else dernier_run(dossier)
    if not chemin or not os.path.exists(chemin):
        print("Aucun run trouve. Telechargez un JSON depuis "
              "https://chaser-orchestrator.com/bench/index.json")
        return 2
    run = json.load(open(chemin, encoding="utf-8"))
    if corpus is None:
        corpus = _corpus_probable(run, dossier)
    ctrl, val = verifier_run(run, corpus)
    if sortie_json:
        print(json.dumps({"fichier": os.path.basename(chemin), "controles": ctrl,
                          "recalcule": val,
                          "verdict": "CONFORME" if not [c for c in ctrl if c["ok"] is False]
                                     else "ECART"},
                         ensure_ascii=False, indent=2))
        return 1 if [c for c in ctrl if c["ok"] is False] else 0
    return afficher(chemin, ctrl, val)


if __name__ == "__main__":
    sys.exit(main(sys.argv))
