#!/usr/bin/env python3
"""Does blur make a real photograph look AI-generated to the Sieve detector?

Runs exactly the two studies in PREREG-blur.md and writes blur.json. The verdict field is
computed from the pre-registered decision table, not chosen after looking -- gen_page.py
generates the page's blur paragraph from it rather than quoting a typed sentence.

    python3 blur.py

No model inference: variance of the Laplacian is a pixel statistic, so this is seconds on a
one-core box, not the hours I had budgeted when I thought it needed rescoring.
"""
import hashlib, json, os, sys
import numpy as np
from PIL import Image

HERE = os.path.dirname(os.path.abspath(__file__))
PREREG = os.path.join(HERE, "PREREG-blur.md")
SRC = "/home/agent/work/sieve-test/v0.10-ft44s"
IMGS = "/home/agent/work/sieve-test/images/clean"
NBOOT = 10000
SEED = 20260830

# The pre-registered decision table, as data. Kept here so the thresholds cannot drift away from
# the document that fixed them; the document's own hash goes into the output.
BOUND = {"effect": 0.20, "max_half_width": 0.30}


def load_corpus():
    """(rows, provenance) for Study A. Prefer the images; fall back to the shipped measures.

    The 140 images are deliberately NOT redistributed: the corpus includes face datasets and a
    mugshot set, and republishing those is not mine to do. blur_measures.json is the complete input
    to every statistic here, so the analysis reproduces from the bundle without them -- and anyone
    holding the datasets can recompute it, because every row names its file.

    Bundle paths come first. A machine-local path that happens to exist makes a clone test pass
    while resolving somewhere the reader has never heard of. [[clone-portability]]
    """
    cache = f"{HERE}/blur_measures.json"
    imgs = next((p for p in (f"{HERE}/images/clean", IMGS) if os.path.isdir(p)), None)
    scores = next((p for p in (f"{HERE}/scores_clean.json", f"{SRC}/scores_clean.json")
                   if os.path.exists(p)), None)
    if imgs and scores:
        rows = []
        for r in (x for x in json.load(open(scores)) if x["label"] == 0):
            p = os.path.join(imgs, r["file"])
            if not os.path.exists(p):
                sys.exit(f"blur.py: {scores} scores an image that is not in {imgs}: {r['file']}")
            rows.append({"file": r["file"], "source": r["source"], "score": r["score"],
                         "logvol": float(np.log10(max(vol(p), 1e-12))),
                         "logarea": float(np.log10(r["w"] * r["h"]))})
        return rows, f"images from {imgs}, scores from {scores}"
    if os.path.exists(cache):
        return json.load(open(cache)), f"derived measures from {cache} (images not present)"
    sys.exit("blur.py: need either the scored images or the shipped measures. Looked for:\n"
             f"  images  {HERE}/images/clean  or  {IMGS}\n"
             f"  scores  {HERE}/scores_clean.json  or  {SRC}/scores_clean.json\n"
             f"  cache   {cache}\n"
             "The images are not in the bundle on purpose (face datasets and mugshots); "
             "blur_measures.json is, and it is the whole input to the statistics.")


def ordinal(n):
    """1st, 2nd, 3rd, 9th. Lives here because gen_page.py and gen_blurnote.py both quote the
    winner's blur rank, and two hand-written copies drift. [[one-rule-two-copies]]"""
    return f"{n}{'th' if 10 <= n % 100 <= 20 else {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th')}"


def derived(here=None):
    """Everything both page generators need out of blur.json / blur_power.json, computed once."""
    here = here or HERE
    b = json.load(open(f"{here}/blur.json"))
    bp = json.load(open(f"{here}/blur_power.json"))
    a, sb = b["study_a"], b["study_b_descriptive"]
    lo, hi = a["cluster_ci95"]
    return {
        "B": b, "BP": bp, "A": a, "SB": sb, "lo": lo, "hi": hi,
        "half": (hi - lo) / 2,
        "top": max(sb["flagged_vol_ranks_of_n"], key=lambda r: r["score"]),
        "power": bp["targets"]["0.20"]["frac_ci_excludes_zero"],
        "false_pos": bp["targets"]["0.00"]["frac_ci_excludes_zero"],
        "bound": a["bound"]["effect"],
        "sound": bp["calibration_ok"] and bp["powered_at_prereg_effect"],
    }


def vol(path):
    """Variance of the 3x3 Laplacian of the greyscale image, as stored on disk."""
    g = np.asarray(Image.open(path).convert("L"), dtype=np.float64)
    lap = (-4 * g[1:-1, 1:-1] + g[:-2, 1:-1] + g[2:, 1:-1] + g[1:-1, :-2] + g[1:-1, 2:])
    return float(lap.var())


def rank(a):
    """Average ranks, so ties cannot be broken two different ways in two places."""
    a = np.asarray(a, dtype=np.float64)
    order = np.argsort(a, kind="mergesort")
    r = np.empty(len(a), dtype=np.float64)
    r[order] = np.arange(1, len(a) + 1, dtype=np.float64)
    # average over tied runs
    s = a[order]
    i = 0
    while i < len(s):
        j = i
        while j + 1 < len(s) and s[j + 1] == s[i]:
            j += 1
        if j > i:
            r[order[i:j + 1]] = r[order[i:j + 1]].mean()
        i = j + 1
    return r


def pearson(x, y):
    x = np.asarray(x, float) - np.mean(x)
    y = np.asarray(y, float) - np.mean(y)
    d = np.sqrt((x * x).sum() * (y * y).sum())
    return float((x * y).sum() / d) if d > 0 else float("nan")


def within_source(rows, key):
    """Rank `key` inside each source and centre it. Source is the nuisance variable: pooling
    across 14 datasets would mostly measure which datasets are in the corpus."""
    out = np.empty(len(rows))
    for s in {r["source"] for r in rows}:
        idx = [i for i, r in enumerate(rows) if r["source"] == s]
        rk = rank([rows[i][key] for i in idx])
        out[idx] = rk - rk.mean()
    return out


def residualise(y, z):
    """OLS residuals of y on z (both already within-source centred)."""
    zz = float((z * z).sum())
    if zz <= 1e-12:
        return y, False        # z carries no within-source variance; partialling is a no-op
    return y - (float((y * z).sum()) / zz) * z, True


def estimate(rows):
    x = within_source(rows, "logvol")
    y = within_source(rows, "score")
    z = within_source(rows, "logarea")
    rho = pearson(x, y)
    xr, used = residualise(x, z)
    yr, _ = residualise(y, z)
    return rho, pearson(xr, yr), used


def main():
    prereg_sha = hashlib.sha256(open(PREREG, "rb").read()).hexdigest()

    rows, provenance = load_corpus()
    json.dump(rows, open(f"{HERE}/blur_measures.json", "w"), indent=1)
    sources = sorted({r["source"] for r in rows})
    if len(rows) != 140 or len(sources) != 14:
        sys.exit(f"blur.py: prereg says 140 images in 14 sources; got {len(rows)} in {len(sources)}")

    rho, rho_partial, partial_used = estimate(rows)
    rho_pooled = pearson(rank([r["logvol"] for r in rows]), rank([r["score"] for r in rows]))

    # Cluster bootstrap over SOURCES, not images -- the images nest in 14 datasets, and an
    # image-level interval here would be a lie about the power. [[cluster-your-cis]]
    by_src = {s: [r for r in rows if r["source"] == s] for s in sources}
    rng = np.random.default_rng(SEED)
    boot = []
    for _ in range(NBOOT):
        draw = rng.integers(0, len(sources), len(sources))
        # relabel duplicated sources so within-source ranking treats them as separate clusters
        rs = []
        for k, di in enumerate(draw):
            for r in by_src[sources[di]]:
                rs.append({**r, "source": f"{r['source']}#{k}"})
        boot.append(estimate(rs)[0])
    lo, hi = (float(v) for v in np.percentile(boot, [2.5, 97.5]))
    half = (hi - lo) / 2

    # Positive control: permuting scores within source must send the estimator to ~0. A seeded
    # bootstrap draws positions, so this checks the statistic, not the draw. [[seed-is-not-determinism]]
    perm = []
    for _ in range(200):
        rs = [dict(r) for r in rows]
        for s in sources:
            idx = [i for i, r in enumerate(rs) if r["source"] == s]
            sc = rng.permutation([rs[i]["score"] for i in idx])
            for i, v in zip(idx, sc):
                rs[i]["score"] = float(v)
        perm.append(estimate(rs)[0])
    perm_mean = float(np.mean(perm))
    if abs(perm_mean) > 0.05:
        sys.exit(f"blur.py: permutation control failed — within-source rho on shuffled scores is "
                 f"{perm_mean:+.3f}, should be ~0. The estimator is wrong; no verdict issued.")

    # Pre-registered decision table. Order matters: UNDERPOWERED overrides any point estimate.
    if half > BOUND["max_half_width"]:
        verdict = "UNDERPOWERED"
    elif (rho < 0) != (rho_partial < 0):
        verdict = "INCONCLUSIVE"       # the two estimates disagree in sign
    elif lo <= 0 <= hi:
        verdict = "INCONCLUSIVE"
    elif rho <= -BOUND["effect"]:
        verdict = "H1"
    elif rho >= BOUND["effect"]:
        verdict = "H2"
    else:
        verdict = "INCONCLUSIVE"       # interval excludes 0 but the effect is below the bound

    # Study B: descriptive only. n=17 with three positives, two of them one person and one dog.
    res = json.load(open(f"{HERE}/results.json"))
    subcache = f"{HERE}/blur_measures_subs.json"
    have_subs = all(os.path.exists(os.path.join(HERE, "subs",
                                                c.get("local") or f"claim{c['claim_id']}.bin"))
                    for c in res["claims"])
    if have_subs:
        subs = []
        for c in res["claims"]:
            # `local` is a bare filename relative to subs/, not to HERE
            p = os.path.join(HERE, "subs", c.get("local") or f"claim{c['claim_id']}.bin")
            if c.get("score") is None:
                sys.exit(f"blur.py: claim {c['claim_id']} has no score; the prereg puts all "
                         f"{len(res['claims'])} in Study B")
            subs.append({"id": c["claim_id"], "submitter": c["submitter"], "score": c["score"],
                         "logvol": float(np.log10(max(vol(p), 1e-12)))})
        json.dump(subs, open(subcache, "w"), indent=1)
    elif os.path.exists(subcache):
        # The submission images live on IPFS and are linked from the page rather than copied into
        # the bundle; their measures ship so Study B reproduces anyway.
        subs = json.load(open(subcache))
    else:
        sys.exit(f"blur.py: Study B needs the submission images in {HERE}/subs/ or the shipped "
                 f"{subcache}. Neither is present.")
    subs.sort(key=lambda r: r["logvol"])           # blurriest first
    for i, r in enumerate(subs, 1):
        r["vol_rank"] = i                          # 1 = blurriest of the board
    flagged = sorted((r for r in subs if r["score"] >= 0.65), key=lambda r: -r["score"])

    out = {
        "prereg": "PREREG-blur.md", "prereg_sha256": prereg_sha,
        "model_version": "ft44s-2026-08-19", "threshold": 0.65,
        "input": provenance,
        "study_a": {
            "n": len(rows), "n_sources": len(sources), "condition": "clean",
            "rho_within_source": round(rho, 4),
            "rho_within_source_partial_area": round(rho_partial, 4),
            "area_partial_applied": partial_used,
            "rho_pooled_ignoring_source": round(rho_pooled, 4),
            "cluster_ci95": [round(lo, 4), round(hi, 4)], "ci_half_width": round(half, 4),
            "n_boot": NBOOT, "seed": SEED,
            "permutation_control_mean_rho": round(perm_mean, 4),
            "bound": BOUND, "verdict": verdict,
        },
        "study_b_descriptive": {
            "n": len(subs), "n_flagged": len(flagged),
            "note": "descriptive only; no p-value is computed or quoted (see PREREG-blur.md)",
            "flagged_vol_ranks_of_n": [
                {"id": r["id"], "score": round(r["score"], 4), "vol_rank": r["vol_rank"],
                 "of": len(subs)} for r in flagged],
            "all": [{"id": r["id"], "score": round(r["score"], 4),
                     "log10_vol": round(r["logvol"], 3), "vol_rank": r["vol_rank"]} for r in subs],
        },
    }
    json.dump(out, open(f"{HERE}/blur.json", "w"), indent=1)

    print(f"Study A  n={len(rows)} in {len(sources)} sources, condition=clean")
    print(f"  input: {provenance}")
    print(f"  pooled (ignores source)      rho = {rho_pooled:+.3f}   <- not the estimate")
    print(f"  within-source                rho = {rho:+.3f}")
    print(f"  within-source, area partial  rho = {rho_partial:+.3f}"
          f"{'' if partial_used else '   (no within-source area variance; no-op)'}")
    print(f"  cluster CI95 over {len(sources)} sources  [{lo:+.3f}, {hi:+.3f}]  half-width {half:.3f}")
    print(f"  permutation control          rho = {perm_mean:+.3f}  (must be ~0)")
    print(f"  VERDICT: {verdict}   (bound: |rho|>={BOUND['effect']}, half-width<={BOUND['max_half_width']})")
    print(f"\nStudy B  n={len(subs)} submissions, descriptive only")
    for r in flagged:
        print(f"  claim {r['id']}  score {r['score']:.4f}  blur rank {r['vol_rank']}/{len(subs)}"
              f"  (1 = blurriest)")


if __name__ == "__main__":
    main()
