"""
P-AE Core B - Surprise / Uncertainty Engine (minimal, runnable, CPU, deterministic)
===================================================================================
Substrate-invariant core for "No Act Without Proof" (P-AE / F2). v0.1 NO-TOOLS
reference: pure numpy, no torch, no sim install. Ensemble members are random-feature
linear models (deep-ensemble in spirit; linear-in-random-features for determinism
and freedom from collapse / lr-tuning footguns).

Demonstrates + TESTS at runtime (--demo):
  D1 epistemic (ensemble disagreement) vs aleatoric (noise) split
  D2 noisy-TV FIREWALL: pure-aleatoric region NOT chased, via epistemic - lambda*aleatoric
  D3 pre-committed adaptation benchmark (post-shift cumulative-error ratio)   [v0.1: NOT cleared]
  D4 agree-but-wrong SIM-ONLY diagnostic
  D5 CALIBRATED_IGNORANCE: does epistemic predict the agent's own error?       [B, config-sensitive]
  D6 self-model surprise (P37 interiority)

STRUCTURAL INVARIANT: outside "sim_explore", gate_signal returns only a token
(REFUSE / PROCEED_CANDIDATE_ONLY), never an actuation. Candidate/diagnostic machinery
only; grants NO authority; nothing here may be read as evidence of understanding (W0).

Honest status (tested 2026-06-02, see tested_runtime_results_PAE_coreB.md):
  D1 PASS, D2 PASS (robust), D6 positive; D3 NOT_DEMONSTRATED_v0.1 (deferred to a real
  world-model sim); D5 [B] marginal/config-sensitive (needs kNN-residual aleatoric).
"""
from __future__ import annotations
import argparse, json, math
from dataclasses import dataclass
from typing import Optional
import numpy as np


def make_rng(seed: int) -> np.random.Generator:
    return np.random.default_rng(seed)


@dataclass
class Member:
    n_rff: int; in_dim: int; out_dim: int; seed: int
    bandwidth: float = 1.0; ridge: float = 1e-3; var_ridge: float = 1e-2

    def __post_init__(self):
        rng = make_rng(self.seed)
        self.Omega = rng.normal(0.0, 1.0 / self.bandwidth, size=(self.n_rff, self.in_dim))
        self.b = rng.uniform(0.0, 2 * math.pi, size=(self.n_rff,))
        self.W = np.zeros((self.out_dim, self.n_rff))
        self.var_head = np.zeros((self.out_dim, self.n_rff))

    def phi(self, X):
        X = np.atleast_2d(X)
        return math.sqrt(2.0 / self.n_rff) * np.cos(X @ self.Omega.T + self.b)

    def fit(self, X, Y):
        P = self.phi(X)
        A = P.T @ P + self.ridge * np.eye(self.n_rff)
        self.W = np.linalg.solve(A, P.T @ Y).T
        resid = Y - (P @ self.W.T)
        logsq = np.log(resid ** 2 + 1e-8)
        Av = P.T @ P + self.var_ridge * np.eye(self.n_rff)   # heavier ridge: stable extrapolation
        self.var_head = np.linalg.solve(Av, P.T @ logsq).T

    def predict_mean(self, X):
        return self.phi(X) @ self.W.T

    def predict_aleatoric_var(self, X):
        log = np.clip(self.phi(X) @ self.var_head.T, -10.0, 6.0)
        return np.clip(np.exp(log), 1e-4, 4e2)


@dataclass
class SurpriseConfig:
    n_members: int = 7; n_rff: int = 256; bandwidth: float = 1.0; ridge: float = 1e-3
    lambda_noise: float = 1.0; lp_halflife: int = 50; eps_ceiling: float = 0.25; seed: int = 0


class SurpriseEngine:
    def __init__(self, in_dim, out_dim, cfg: SurpriseConfig):
        self.cfg = cfg; self.in_dim = in_dim; self.out_dim = out_dim
        self.members = [Member(cfg.n_rff, in_dim, out_dim, seed=cfg.seed * 1000 + k,
                               bandwidth=cfg.bandwidth, ridge=cfg.ridge)
                        for k in range(cfg.n_members)]
        self._err_ema: Optional[float] = None
        self._lp_ema = 0.0
        self._alpha = 1.0 - 0.5 ** (1.0 / max(1, cfg.lp_halflife))
        self._self_w = np.zeros(3); self._self_fitted = False
        self._self_resid_ema: Optional[float] = None

    def fit(self, X, Y, rng):
        n = len(X)
        for m in self.members:
            idx = rng.integers(0, n, size=n)        # bootstrap -> epistemic diversity
            m.fit(X[idx], Y[idx])

    def _member_means(self, X):
        return np.stack([m.predict_mean(X) for m in self.members], axis=0)

    def epistemic(self, X):
        return self._member_means(X).var(axis=0).mean(axis=-1)

    def aleatoric(self, X):
        al = np.stack([m.predict_aleatoric_var(X) for m in self.members], axis=0)
        return al.mean(axis=0).mean(axis=-1)

    def predict(self, X):
        return self._member_means(X).mean(axis=0)

    def update_learning_progress(self, current_error: float):
        if self._err_ema is None:
            self._err_ema = current_error; return
        delta = self._err_ema - current_error
        self._err_ema = (1 - self._alpha) * self._err_ema + self._alpha * current_error
        self._lp_ema = (1 - self._alpha) * self._lp_ema + self._alpha * delta

    def learning_progress(self):
        return self._lp_ema

    def explore_score(self, X):
        eps = self.epistemic(X); ale = self.aleatoric(X)
        # noisy-TV firewall lives HERE: high-aleatoric regions get a negative score and are
        # clipped out. We deliberately do NOT use a GLOBAL learning-progress gate: it would
        # suppress exploration right after a regime shift, exactly when it is needed.
        # (LP gating, if used, must be PER-REGION. Lesson from runtime testing 2026-06-02.)
        score = eps - self.cfg.lambda_noise * ale
        return np.maximum(score, 0.0)

    def gate_signal(self, X, mode):
        if mode == "sim_explore":
            return ("EXPLORE_SCORE", self.explore_score(X))
        if mode == "real_refuse":
            eps = self.epistemic(X)
            tokens = np.where(eps > self.cfg.eps_ceiling, "REFUSE", "PROCEED_CANDIDATE_ONLY")
            assert all(t in ("REFUSE", "PROCEED_CANDIDATE_ONLY") for t in np.atleast_1d(tokens))
            return ("TOKENS", tokens)
        raise ValueError(f"unknown mode {mode!r}")

    def fit_self_model(self, eps_hist, ale_hist, err_hist):
        Xs = np.stack([eps_hist, ale_hist, np.ones_like(eps_hist)], axis=1)
        self._self_w = np.linalg.lstsq(Xs, err_hist, rcond=None)[0]; self._self_fitted = True
        self._self_resid_ema = float(np.mean(np.abs(Xs @ self._self_w - err_hist)))  # pre-shift baseline

    def self_model_surprise(self, eps, ale, actual_err):
        if not self._self_fitted: return float("nan")
        pred = np.array([eps, ale, 1.0]) @ self._self_w
        resid = abs(actual_err - pred)
        if self._self_resid_ema is None: self._self_resid_ema = resid
        s = resid - self._self_resid_ema
        self._self_resid_ema = 0.9 * self._self_resid_ema + 0.1 * resid
        return float(s)


@dataclass
class World:
    in_dim: int = 4; out_dim: int = 2; seed: int = 1
    noisy_tv_center: float = 2.5; noisy_tv_radius: float = 0.6; noisy_tv_sigma: float = 3.0
    base_sigma: float = 0.05; shifted: bool = False
    shift_center: float = -1.5; shift_radius: float = 0.8   # LOCAL regime-shift band on dim 1

    def __post_init__(self):
        rng = make_rng(self.seed)
        self._A = rng.normal(size=(self.out_dim, self.in_dim))
        self._B = self._A + rng.normal(0, 1.2, size=(self.out_dim, self.in_dim))  # local perturbation
        self._freq = rng.uniform(0.5, 1.5, size=self.in_dim)

    def _is_noisy_tv(self, X):
        return np.abs(X[:, 0] - self.noisy_tv_center) < self.noisy_tv_radius

    def _in_shift_band(self, X):
        return np.abs(X[:, 1] - self.shift_center) < self.shift_radius

    def step(self, X, rng):
        X = np.atleast_2d(X)
        feat = np.tanh(X * self._freq)
        smooth = feat @ self._A.T
        if self.shifted:                       # only the band's dynamics change (local)
            band = self._in_shift_band(X)
            if band.any():
                smooth[band] = feat[band] @ self._B.T
        noise = rng.normal(0, self.base_sigma, size=smooth.shape)
        tv = self._is_noisy_tv(X)
        if tv.any():
            noise[tv] += rng.normal(0, self.noisy_tv_sigma, size=(int(tv.sum()), self.out_dim))
        return smooth + noise

    def sample_states(self, n, rng):
        return rng.uniform(-3, 3, size=(n, self.in_dim))


PRE_COMMITTED = {
    "D2_noisy_tv_max_budget_frac": 0.20,
    "D3_recovery_speedup_min": 1.5,
    "D5_calibration_min_corr": 0.30,
    "alpha": 0.05,
}


def run_demo(seed=0, budget=60, batch=64, shift_step=30, mem_steps=1000):
    rng = make_rng(seed)
    world = World(seed=seed + 7)
    results = {}
    Xeval = world.sample_states(4000, make_rng(999))
    Xeval = Xeval[~world._is_noisy_tv(Xeval)]

    def eval_err(engine, shifted):
        world.shifted = shifted
        Yt = world.step(Xeval, make_rng(12345))
        return float(np.mean((engine.predict(Xeval) - Yt) ** 2))

    def train_loop(strategy):
        eng = SurpriseEngine(world.in_dim, world.out_dim, SurpriseConfig(seed=seed))
        Xbuf, Ybuf, curve, noisy_frac = [], [], [], []
        eps_h, ale_h, err_h = [], [], []
        for t in range(budget):
            shifted = t >= shift_step
            world.shifted = shifted
            cand = world.sample_states(batch * 8, rng)
            if strategy == "uniform" or len(Xbuf) == 0:
                pick = rng.choice(len(cand), size=batch, replace=False)
            elif strategy == "surprise":
                pick = np.argsort(-eng.explore_score(cand))[:batch]
            elif strategy == "naive_pe":
                pe = eng.epistemic(cand) + eng.aleatoric(cand)
                pick = np.argsort(-pe)[:batch]
            Xs = cand[pick]; Ys = world.step(Xs, rng)
            Xbuf.append(Xs); Ybuf.append(Ys)
            if len(Xbuf) > mem_steps:               # finite recency window (handles drift)
                Xbuf.pop(0); Ybuf.pop(0)
            eng.fit(np.concatenate(Xbuf), np.concatenate(Ybuf), rng)
            e = eval_err(eng, shifted)
            eng.update_learning_progress(e)
            curve.append(e); noisy_frac.append(float(world._is_noisy_tv(Xs).mean()))
            eps_h.append(float(eng.epistemic(Xeval).mean()))
            ale_h.append(float(eng.aleatoric(Xeval).mean())); err_h.append(e)
        return dict(engine=eng, curve=np.array(curve), noisy_frac=np.array(noisy_frac),
                    eps_h=np.array(eps_h), ale_h=np.array(ale_h), err_h=np.array(err_h))

    A = train_loop("uniform"); B = train_loop("surprise"); C = train_loop("naive_pe")
    eng = B["engine"]

    Xl = world.sample_states(4000, make_rng(3)); Xl = Xl[~world._is_noisy_tv(Xl)]
    Xtv = world.sample_states(40000, make_rng(4)); Xtv = Xtv[world._is_noisy_tv(Xtv)][:2000]
    a_learn = float(eng.aleatoric(Xl).mean()); a_tv = float(eng.aleatoric(Xtv).mean())
    results["D1_epistemic_vs_aleatoric"] = {
        "learnable_mean_aleatoric": round(a_learn, 5), "noisy_tv_mean_aleatoric": round(a_tv, 5),
        "aleatoric_separates_noisy_tv": bool(a_tv > 3 * a_learn)}

    s_nf = float(B["noisy_frac"].mean()); n_nf = float(C["noisy_frac"].mean())
    results["D2_noisy_tv_firewall"] = {
        "surprise_budget_frac_in_noisy_tv": round(s_nf, 4),
        "naive_pe_budget_frac_in_noisy_tv": round(n_nf, 4),
        "pre_committed_max": PRE_COMMITTED["D2_noisy_tv_max_budget_frac"],
        "PASS": bool(s_nf <= PRE_COMMITTED["D2_noisy_tv_max_budget_frac"] and s_nf < n_nf)}

    post_u = A["curve"][shift_step:]; post_s = B["curve"][shift_step:]
    regret_ratio = float(post_u.sum() / max(1e-9, post_s.sum()))
    def recovery_steps(curve, shift, tol_mult=1.5):
        target = curve[shift - 1] * tol_mult
        post = curve[shift:]; below = np.where(post <= target)[0]
        return int(below[0]) + 1 if len(below) else len(post)
    ru = recovery_steps(A["curve"], shift_step); rsp = recovery_steps(B["curve"], shift_step)
    results["D3_adaptation_speedup"] = {
        "metric": "post_shift_cumulative_error_ratio (uniform/surprise); >1 = surprise better",
        "regret_ratio": round(regret_ratio, 3),
        "uniform_recovery_steps": ru, "surprise_recovery_steps": rsp,
        "recovery_speedup_x": round(ru / max(1, rsp), 3),
        "pre_committed_min": PRE_COMMITTED["D3_recovery_speedup_min"],
        "PASS": bool(regret_ratio >= PRE_COMMITTED["D3_recovery_speedup_min"]),
        "status_note": "v0.1 numpy synthetic does not faithfully model recency-replay; "
                       "deferred to real world-model sim (Dreamer/JEPA on Genesis/MuJoCo)"}

    world.shifted = False
    Xg = world.sample_states(5000, make_rng(5)); Xg = Xg[~world._is_noisy_tv(Xg)]
    Yt = world.step(Xg, make_rng(777))
    err_pt = ((eng.predict(Xg) - Yt) ** 2).mean(axis=1); eps_pt = eng.epistemic(Xg)
    lo = eps_pt < np.quantile(eps_pt, 0.25); hi = err_pt > np.quantile(err_pt, 0.90)
    abw = float(np.mean(lo & hi))
    results["D4_agree_but_wrong"] = {
        "fraction_confident_and_wrong": round(abw, 4),
        "note": "only measurable in sim (needs ground truth); a deployment blind spot",
        "flagged": bool(abw > 0.01)}

    corr = float(np.corrcoef(eps_pt, err_pt)[0, 1])
    results["D5_calibrated_ignorance"] = {
        "corr_epistemic_vs_own_error": round(corr, 4),
        "pre_committed_min": PRE_COMMITTED["D5_calibration_min_corr"],
        "verdict": "CALIBRATED_IGNORANCE_SIGNAL_GRADE_B" if corr >= PRE_COMMITTED["D5_calibration_min_corr"] else "UNCALIBRATED",
        "grade": "B (config-sensitive 0.04-0.37; NOT a [D] proof -- needs kNN-residual aleatoric "
                 "before a CALIBRATED_IGNORANCE_PROVEN [D] claim is permitted)"}

    eng.fit_self_model(B["eps_h"][:shift_step], B["ale_h"][:shift_step], B["err_h"][:shift_step])
    surprises = [eng.self_model_surprise(B["eps_h"][t], B["ale_h"][t], B["err_h"][t])
                 for t in range(shift_step, budget)]
    results["D6_self_model_surprise"] = {
        "surprise_at_first_post_shift_step": round(float(surprises[0]), 4),
        "max_post_shift_surprise": round(float(np.nanmax(surprises)), 4),
        "interpretation": "positive = pre-shift self-model failed to anticipate its own post-shift error spike (P37 interiority)"}

    results["_meta"] = {"module": "P-AE Core B (surprise engine) v0.1 no-tools reference",
                        "grants_authority": False,
                        "forbidden_reading": "none of D1..D6 may be read as evidence of understanding",
                        "pre_committed": PRE_COMMITTED, "seed": seed, "mem_steps": mem_steps}
    return results


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--demo", action="store_true")
    ap.add_argument("--seed", type=int, default=0)
    ap.add_argument("--mem_steps", type=int, default=1000)
    ap.add_argument("--multiseed", type=int, default=0)
    args = ap.parse_args()
    if args.multiseed > 0:
        agg = {"D2": 0, "D3": 0, "D5": 0, "sp": []}
        for s in range(args.multiseed):
            r = run_demo(seed=s, mem_steps=args.mem_steps)
            agg["D2"] += r["D2_noisy_tv_firewall"]["PASS"]
            agg["D3"] += r["D3_adaptation_speedup"]["PASS"]
            agg["D5"] += (r["D5_calibrated_ignorance"]["verdict"] == "CALIBRATED_IGNORANCE_SIGNAL_GRADE_B")
            agg["sp"].append(r["D3_adaptation_speedup"]["regret_ratio"])
        n = args.multiseed
        print(json.dumps({"seeds": n, "mem_steps": args.mem_steps,
                          "D2_firewall_pass_rate": agg["D2"] / n,
                          "D3_speedup_pass_rate": agg["D3"] / n,
                          "D3_regret_ratio_mean": round(float(np.mean(agg["sp"])), 3),
                          "D3_regret_ratio_min": round(float(np.min(agg["sp"])), 3),
                          "D3_regret_ratio_max": round(float(np.max(agg["sp"])), 3),
                          "D5_calibrated_ignorance_pass_rate": agg["D5"] / n}, indent=2))
    else:
        print(json.dumps(run_demo(seed=args.seed, mem_steps=args.mem_steps), indent=2))


if __name__ == "__main__":
    main()
