{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "f32e5a98",
   "metadata": {},
   "source": [
    "# Comparing Like with Like — Supplementary Reproduction Notebook\n",
    "\n",
    "**Matched-Calibration Evaluation of Changepoint Detectors for Biologic Quality\n",
    "Attribute Monitoring**\n",
    "\n",
    "This notebook regenerates **every table and figure of the manuscript from raw\n",
    "inputs**, in the order they appear. No result below is hard-coded: each number is\n",
    "computed here. The final section (§16) compares what this notebook computed against\n",
    "what the manuscript reports, so the reproduction can be audited at a glance.\n",
    "\n",
    "| § | Content | Manuscript |\n",
    "|---|---|---|\n",
    "| 1 | Detectors and the shared decision rule | §2.1–2.3 |\n",
    "| 2 | Simulation design; the bounded-noncentrality prediction | §2.5–2.6 |\n",
    "| 3 | False-positive drift at default settings | **Fig. 1** |\n",
    "| 4 | Comparison at a matched false-positive rate | **Table 1** |\n",
    "| 5 | Persistence of the effect at large *n* | **Table 2** |\n",
    "| 6 | The mechanism: self-normalization, not efficiency | §3.4 |\n",
    "| 7 | The finding is not specific to *t*₃ | **Table 3** |\n",
    "| 8 | What the permutation test costs in power | **Table 4** |\n",
    "| 9 | Matched ablation: the search restriction, not the scale estimator | **Table 5**, **Fig. 2** |\n",
    "| 10 | Window-size sensitivity | **Fig. 3** |\n",
    "| 11 | The cost of autocorrelation | **Fig. 4** |\n",
    "| 12 | Cross-calibration: not knowing the noise distribution | **Table 6** |\n",
    "| 13 | Case study: IndPenSim bioprocess data | **Tables 9–10**, **Fig. 5** |\n",
    "| 14 | Plant-wide sweep across all channels | supplementary |\n",
    "| 15 | Screening lessons: circular and degenerate channels | supplementary |\n",
    "| 16 | **Verification against the manuscript** | — |\n",
    "\n",
    "**Reproducibility.** All randomness is seeded. `PAPER_SETTINGS = True` uses the\n",
    "replication counts of the manuscript (~25–45 min). `False` runs the identical code\n",
    "path in ~3 min at coarser Monte Carlo precision; the qualitative conclusions are\n",
    "unchanged, the third decimal is not."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d1692b0e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:43:46.111997Z",
     "iopub.status.busy": "2026-07-13T19:43:46.111997Z",
     "iopub.status.idle": "2026-07-13T19:43:47.926205Z",
     "shell.execute_reply": "2026-07-13T19:43:47.923616Z"
    }
   },
   "outputs": [],
   "source": [
    "import numpy as np, pandas as pd, matplotlib.pyplot as plt\n",
    "from pathlib import Path\n",
    "import time, re\n",
    "\n",
    "PAPER_SETTINGS = True\n",
    "\n",
    "SEEDS     = 30   if PAPER_SETTINGS else 5     # independent seeds per cell\n",
    "REPS      = 400  if PAPER_SETTINGS else 100   # series per seed\n",
    "NULL_CAL  = 2000 if PAPER_SETTINGS else 400   # null series used to set a threshold\n",
    "B_SIM     = 99                                # permutations in the simulations\n",
    "B_APPLIED = 9999 if PAPER_SETTINGS else 999   # permutations for a single-series decision\n",
    "\n",
    "H, W, ALPHA, DELTA, TAU_FROM_END, TOL = 3, 20, 0.05, 1.0, 15, 8\n",
    "\n",
    "# Figures produced by THIS notebook go to their own directory. They reproduce the\n",
    "# manuscript's figures, but the publication versions are typeset by the dedicated\n",
    "# plotting scripts, and we must not overwrite them from here.\n",
    "FIG = Path(\"../figures_notebook\"); FIG.mkdir(exist_ok=True)\n",
    "plt.rcParams.update({\"figure.dpi\": 110, \"savefig.bbox\": \"tight\", \"font.size\": 9})\n",
    "RESULTS = {}                                   # everything computed, for §16\n",
    "t0 = time.time()\n",
    "print(f\"PAPER_SETTINGS={PAPER_SETTINGS}   seeds={SEEDS}  reps={REPS}  null_cal={NULL_CAL}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2d407bf8",
   "metadata": {},
   "source": [
    "---\n",
    "## 1. Detectors and the shared decision rule  *(§2.1–2.3)*\n",
    "\n",
    "All five detectors share one cost function and one search. Splitting at $t$ cuts the\n",
    "series into $y_{1:t}$ and $y_{t+1:n}$; the evidence for a changepoint there is the\n",
    "drop in total cost\n",
    "\n",
    "$$\\Delta C(t)\\;=\\;C(y_{1:n})-\\big[C(y_{1:t})+C(y_{t+1:n})\\big]\\;=\\;\\frac{t(n-t)}{n}\\big(\\bar y_{1:t}-\\bar y_{t+1:n}\\big)^{2},$$\n",
    "\n",
    "with $C(\\cdot)$ a block's sum of squared deviations from its own mean. A change is\n",
    "declared at $t^\\ast=\\arg\\max_t\\Delta C(t)$ when $\\Delta C(t^\\ast)>\\beta$. The detectors\n",
    "differ **only** in how $\\beta$ is obtained, and — for the windowed pair — in which\n",
    "$t$ they may search.\n",
    "\n",
    "| detector | threshold |\n",
    "|---|---|\n",
    "| Classical BIC | $c\\,\\hat\\sigma^{2}\\log n$ |\n",
    "| ROBIN | $k\\,g(n)\\,\\hat\\sigma^{2}_{\\rm MAD}$, $\\;g(n)=\\lambda\\log n+(1-\\lambda)\\sqrt n$ |\n",
    "| windowed BIC | $c\\,\\hat\\sigma^{2}\\log W$, search restricted to the last $W$ points |\n",
    "| windowed ROBIN | $k\\,g(W)\\,\\hat\\sigma^{2}_{\\rm MAD}$, same restriction |\n",
    "| permutation | none: the null of $\\Delta C(t^\\ast)$ is resampled from the series itself |\n",
    "\n",
    "**Calibration without bisection.** The decision is monotone in the statistic, so the\n",
    "threshold hitting a target false-positive rate is *exactly* the $(1-\\alpha)$ quantile\n",
    "of the null statistic. We use that everywhere. It removes calibration error from the\n",
    "comparison — and calibration error is precisely what the manuscript shows can invert\n",
    "a conclusion."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6a58be72",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:43:47.933351Z",
     "iopub.status.busy": "2026-07-13T19:43:47.932206Z",
     "iopub.status.idle": "2026-07-13T19:43:47.969988Z",
     "shell.execute_reply": "2026-07-13T19:43:47.967943Z"
    }
   },
   "outputs": [],
   "source": [
    "def make_series(rng, n, tau=None, delta=0.0, dist=\"gaussian\"):\n",
    "    \"\"\"One series of length n. Noise is standardized, so `delta` is in sigma units.\"\"\"\n",
    "    if dist == \"gaussian\":\n",
    "        e = rng.normal(0, 1, n)\n",
    "    elif dist == \"t3\":\n",
    "        e = rng.standard_t(3, n) / np.sqrt(3.0)\n",
    "    elif dist == \"t5\":\n",
    "        e = rng.standard_t(5, n) / np.sqrt(5 / 3)\n",
    "    elif dist == \"lognormal\":\n",
    "        s = 0.5\n",
    "        x = rng.lognormal(0, s, n)\n",
    "        e = (x - np.exp(s ** 2 / 2)) / np.sqrt((np.exp(s ** 2) - 1) * np.exp(s ** 2))\n",
    "    elif dist == \"contaminated\":                       # 95% N(0,1) + 5% N(0,9)\n",
    "        e = rng.normal(0, 1, n)\n",
    "        i = rng.random(n) < 0.05\n",
    "        e[i] = rng.normal(0, 3, int(i.sum()))\n",
    "        e = e / e.std(ddof=1)\n",
    "    else:\n",
    "        raise ValueError(dist)\n",
    "    y = e.copy()\n",
    "    if tau is not None:\n",
    "        y[tau:] += delta\n",
    "    return y\n",
    "\n",
    "\n",
    "def dC_all(y, h=H):\n",
    "    \"\"\"Delta C(t) at every admissible split t, vectorized with prefix sums.\"\"\"\n",
    "    n = y.shape[0]\n",
    "    S  = np.concatenate(([0.0], np.cumsum(y)))\n",
    "    S2 = np.concatenate(([0.0], np.cumsum(y * y)))\n",
    "    full = S2[n] - S[n] ** 2 / n\n",
    "    t = np.arange(h, n - h + 1)\n",
    "    sl, s2l, ml = S[t], S2[t], t.astype(float)\n",
    "    sr, s2r, mr = S[n] - S[t], S2[n] - S2[t], (n - t).astype(float)\n",
    "    return t, full - (s2l - sl ** 2 / ml) - (s2r - sr ** 2 / mr)\n",
    "\n",
    "\n",
    "def scales(y):\n",
    "    \"\"\"Sample SD, and the MAD rescaled by 1.4826 so it matches the SD under Gaussian noise.\"\"\"\n",
    "    return y.std(ddof=1), 1.4826 * np.median(np.abs(y - np.median(y)))\n",
    "\n",
    "\n",
    "def stat_unrestricted(y):\n",
    "    \"\"\"(t*, dC*/sd^2, dC*/mad^2) over the full search.\"\"\"\n",
    "    t, dC = dC_all(y)\n",
    "    i = int(np.argmax(dC))\n",
    "    sd, mad = scales(y)\n",
    "    return int(t[i]), (dC[i] / sd ** 2 if sd > 0 else 0.0), (dC[i] / mad ** 2 if mad > 0 else 0.0)\n",
    "\n",
    "\n",
    "def stat_windowed(y, w=W):\n",
    "    \"\"\"Same, but only splits inside the last w observations are searched.\n",
    "    The scale is still estimated from the FULL series (§2.3).\"\"\"\n",
    "    n = y.shape[0]\n",
    "    t, dC = dC_all(y)\n",
    "    m = t >= max(H, n - w + H)\n",
    "    if not m.any():\n",
    "        return None, 0.0, 0.0\n",
    "    tw, dw = t[m], dC[m]\n",
    "    i = int(np.argmax(dw))\n",
    "    sd, mad = scales(y)\n",
    "    return int(tw[i]), (dw[i] / sd ** 2 if sd > 0 else 0.0), (dw[i] / mad ** 2 if mad > 0 else 0.0)\n",
    "\n",
    "\n",
    "def perm_pvalue(y, B, rng):\n",
    "    \"\"\"p_hat = (1 + M)/(B + 1), M = permutations whose statistic beats the observed one.\"\"\"\n",
    "    _, dC = dC_all(y)\n",
    "    obs = dC.max()\n",
    "    Y = rng.permuted(np.tile(y, (B, 1)), axis=1)\n",
    "    n = Y.shape[1]\n",
    "    S  = np.concatenate((np.zeros((B, 1)), np.cumsum(Y, axis=1)), axis=1)\n",
    "    S2 = np.concatenate((np.zeros((B, 1)), np.cumsum(Y * Y, axis=1)), axis=1)\n",
    "    full = (S2[:, n] - S[:, n] ** 2 / n)[:, None]\n",
    "    t = np.arange(H, n - H + 1)\n",
    "    sl, s2l, ml = S[:, t], S2[:, t], t.astype(float)[None, :]\n",
    "    sr  = S[:, n][:, None] - S[:, t]\n",
    "    s2r = S2[:, n][:, None] - S2[:, t]\n",
    "    mr  = (n - t).astype(float)[None, :]\n",
    "    d = (full - (s2l - sl ** 2 / ml) - (s2r - sr ** 2 / mr)).max(axis=1)\n",
    "    return (1 + int(np.count_nonzero(d >= obs))) / (B + 1)\n",
    "\n",
    "\n",
    "def exact_thresholds(rng, n, dist, kind, reps=None):\n",
    "    \"\"\"EXACT 5%-FPR thresholds: the 95th percentile of the null statistic.\n",
    "    Returns (variance-scaled, MAD-scaled).\"\"\"\n",
    "    f = stat_unrestricted if kind == \"unres\" else stat_windowed\n",
    "    s = np.array([f(make_series(rng, n, dist=dist))[1:] for _ in range(reps or NULL_CAL)])\n",
    "    return np.quantile(s[:, 0], 1 - ALPHA), np.quantile(s[:, 1], 1 - ALPHA)\n",
    "\n",
    "print(\"five detectors + exact-quantile calibration defined\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "59932b13",
   "metadata": {},
   "source": [
    "---\n",
    "## 2. Simulation design  *(§2.5–2.6)*\n",
    "\n",
    "* $n\\in\\{25,\\dots,1000\\}$, roughly log-spaced (extended to $10{,}000$ in §5).\n",
    "* Three noise shapes, each standardized to unit variance: Gaussian, $t_3$\n",
    "  (heavy-tailed), log-normal shape $0.5$ (skewed).\n",
    "* **Fixed end-window design**: the shift lands 15 observations before the end,\n",
    "  *whatever* $n$ is. That encodes the applied question — has a fixed amount of\n",
    "  *recent* history drifted?\n",
    "* $h=3$; a detection within 8 observations of the truth counts as correct.\n",
    "\n",
    "This design has a consequence the paper leans on. With $n_2=15$ fixed and\n",
    "$n_1=n-15$ growing, the noncentrality $\\mathrm{NCP}=\\frac{n_1n_2}{n}\\delta^2$\n",
    "**saturates** at $15\\delta^2$. The detectable signal is bounded while an\n",
    "unrestricted method's threshold keeps climbing — which is why unrestricted power\n",
    "stops improving with $n$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e7e118fa",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:43:47.974020Z",
     "iopub.status.busy": "2026-07-13T19:43:47.974020Z",
     "iopub.status.idle": "2026-07-13T19:43:47.987541Z",
     "shell.execute_reply": "2026-07-13T19:43:47.985500Z"
    }
   },
   "outputs": [],
   "source": [
    "NS    = [25, 30, 40, 50, 75, 100, 150, 200, 300, 400, 600, 800, 1000]\n",
    "DISTS = [\"gaussian\", \"t3\", \"lognormal\"]\n",
    "LBL   = {\"gaussian\": \"Gaussian\", \"t3\": \"Student's $t_3$\", \"lognormal\": \"Log-normal\",\n",
    "         \"t5\": \"$t_5$\", \"contaminated\": \"Contaminated normal\"}\n",
    "\n",
    "print(\"Bounded noncentrality (delta = 1 sigma):\")\n",
    "for n in [25, 50, 200, 1000]:\n",
    "    print(f\"   n={n:>5}   NCP = {(n - 15) * 15 / n:5.2f}\")\n",
    "print(f\"   -> saturates at 15*delta^2 = {15 * DELTA ** 2:.1f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e3ef5f23",
   "metadata": {},
   "source": [
    "---\n",
    "## 3. Why raw comparison fails: false-positive drift at default settings  *(Fig. 1)*\n",
    "\n",
    "Each formula-based detector is run at a **fixed default** ($c=2$; $\\lambda=0.5,k=2$)\n",
    "across the whole sample-size range, and we record the false-positive rate it actually\n",
    "achieves. The permutation test is included as-is: it needs no default.\n",
    "\n",
    "If those achieved rates drift with $n$, then any raw power comparison is confounded —\n",
    "it measures how conservative each method happens to be at that $n$ as much as how well\n",
    "it detects."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "27b435e8",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:43:47.992124Z",
     "iopub.status.busy": "2026-07-13T19:43:47.992124Z",
     "iopub.status.idle": "2026-07-13T19:44:21.168514Z",
     "shell.execute_reply": "2026-07-13T19:44:21.166398Z"
    }
   },
   "outputs": [],
   "source": [
    "def fpr_at_default(n, dist, rng, reps=REPS):\n",
    "    \"\"\"Achieved FPR at the DEFAULT settings (no calibration).\"\"\"\n",
    "    fired = np.zeros(3)      # BIC(c=2), ROBIN(lam=.5,k=2), permutation\n",
    "    g = 0.5 * np.log(n) + 0.5 * np.sqrt(n)\n",
    "    for _ in range(reps):\n",
    "        y = make_series(rng, n, dist=dist)\n",
    "        _, sv, sm = stat_unrestricted(y)\n",
    "        fired[0] += sv > 2.0 * np.log(n)          # beta = c sigma^2 log n, c = 2\n",
    "        fired[1] += sm > 2.0 * g                  # beta = k g(n) sigma_MAD^2, k = 2\n",
    "        fired[2] += perm_pvalue(y, B_SIM, rng) < ALPHA\n",
    "    return 100 * fired / reps\n",
    "\n",
    "rows = []\n",
    "for d in DISTS:\n",
    "    for n in NS:\n",
    "        rng = np.random.default_rng(1000 + n)\n",
    "        b, r, p = fpr_at_default(n, d, rng, reps=max(200, REPS // 2))\n",
    "        rows.append(dict(dist=d, n=n, BIC=b, ROBIN=r, Perm=p))\n",
    "fpr_default = pd.DataFrame(rows)\n",
    "RESULTS[\"fpr_default\"] = fpr_default\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(12, 3.4), sharey=True)\n",
    "for ax, d in zip(axes, DISTS):\n",
    "    g = fpr_default[fpr_default.dist == d]\n",
    "    ax.plot(g.n, g.BIC,   \"o-\", label=\"Classical BIC ($c{=}2$)\")\n",
    "    ax.plot(g.n, g.ROBIN, \"s-\", label=\"ROBIN ($k{=}2$)\")\n",
    "    ax.plot(g.n, g.Perm,  \"^-\", label=\"Permutation\")\n",
    "    ax.axhline(5, ls=\"--\", c=\"k\", lw=1, label=\"nominal 5%\")\n",
    "    ax.set_xscale(\"log\"); ax.set_title(LBL[d]); ax.set_xlabel(\"sample size $n$\")\n",
    "    ax.grid(alpha=.3)\n",
    "axes[0].set_ylabel(\"achieved false-positive rate (%)\")\n",
    "axes[0].legend(fontsize=7.5)\n",
    "plt.tight_layout(); plt.savefig(FIG / \"fig1_fpr_default.pdf\"); plt.show()\n",
    "\n",
    "print(\"Achieved FPR range at default settings (manuscript §3.1):\")\n",
    "for d in DISTS:\n",
    "    g = fpr_default[fpr_default.dist == d]\n",
    "    print(f\"  {LBL[d]:<18} BIC {g.BIC.min():5.1f}-{g.BIC.max():5.1f}%   \"\n",
    "          f\"ROBIN {g.ROBIN.min():5.1f}-{g.ROBIN.max():5.1f}%   \"\n",
    "          f\"Perm {g.Perm.min():4.1f}-{g.Perm.max():4.1f}%\")\n",
    "print(\"\\n-> the two formula-based penalties drift; the permutation test does not.\")\n",
    "print(\"   A raw power comparison at these settings is therefore uninterpretable.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2d47ce7b",
   "metadata": {},
   "source": [
    "---\n",
    "## 4. Comparison at a matched false-positive rate  *(Table 1)*\n",
    "\n",
    "Now every formula-based detector is calibrated to an **exact 5%** false-positive rate\n",
    "in each $(n,\\text{noise})$ cell, and only then is power measured. This is the paper's\n",
    "central comparison."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e6d10863",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:44:21.174254Z",
     "iopub.status.busy": "2026-07-13T19:44:21.168514Z",
     "iopub.status.idle": "2026-07-13T19:48:45.201989Z",
     "shell.execute_reply": "2026-07-13T19:48:45.201989Z"
    }
   },
   "outputs": [],
   "source": [
    "def matched_power(n, dist, seeds=SEEDS, reps=REPS):\n",
    "    \"\"\"Power of BIC and ROBIN at an exact 5% FPR, plus the achieved FPR, per seed.\"\"\"\n",
    "    out = []\n",
    "    tau = n - TAU_FROM_END\n",
    "    for s in range(seeds):\n",
    "        rng = np.random.default_rng(90_001 * s + 7 * n + len(dist))\n",
    "        tv, tm = exact_thresholds(rng, n, dist, \"unres\")\n",
    "        pb = pr = fb = fr = 0\n",
    "        for _ in range(reps):\n",
    "            t, sv, sm = stat_unrestricted(make_series(rng, n, tau=tau, delta=DELTA, dist=dist))\n",
    "            ok = t is not None and abs(t - tau) <= TOL       # tolerance scoring\n",
    "            pb += (sv > tv) and ok\n",
    "            pr += (sm > tm) and ok\n",
    "        for _ in range(reps):\n",
    "            _, sv, sm = stat_unrestricted(make_series(rng, n, dist=dist))\n",
    "            fb += sv > tv\n",
    "            fr += sm > tm\n",
    "        out.append((100 * pb / reps, 100 * pr / reps, 100 * fb / reps, 100 * fr / reps))\n",
    "    return np.array(out)\n",
    "\n",
    "rows = []\n",
    "for d in DISTS:\n",
    "    for n in [50, 200, 1000]:\n",
    "        v = matched_power(n, d)\n",
    "        rows.append(dict(dist=d, n=n,\n",
    "                         BIC=v[:, 0].mean(), BIC_sd=v[:, 0].std(),\n",
    "                         ROBIN=v[:, 1].mean(), ROBIN_sd=v[:, 1].std(),\n",
    "                         fpr_BIC=v[:, 2].mean(), fpr_ROBIN=v[:, 3].mean()))\n",
    "tbl_matched = pd.DataFrame(rows)\n",
    "RESULTS[\"tbl_matched\"] = tbl_matched\n",
    "\n",
    "print(\"TABLE 1 -- power (%) at an exact 5% FPR, Classical BIC vs ROBIN\\n\")\n",
    "print(f\"{'noise':<18}{'n':>6}{'BIC':>14}{'ROBIN':>14}{'gap':>8}   achieved FPR\")\n",
    "for _, r in tbl_matched.iterrows():\n",
    "    print(f\"{LBL[r['dist']]:<18}{int(r['n']):>6}\"\n",
    "          f\"{r.BIC:>9.1f}±{r.BIC_sd:<4.1f}{r.ROBIN:>9.1f}±{r.ROBIN_sd:<4.1f}\"\n",
    "          f\"{r.BIC - r.ROBIN:>+8.1f}   {r.fpr_BIC:.1f}% / {r.fpr_ROBIN:.1f}%\")\n",
    "print(\"\\n-> Classical BIC matches or exceeds ROBIN in EVERY cell, at genuinely equal FPR.\")\n",
    "print(\"   This reverses the usual argument for robust penalties.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c5b2f01c",
   "metadata": {},
   "source": [
    "---\n",
    "## 5. Persistence of the effect at large *n*  *(Table 2)*\n",
    "\n",
    "The self-normalization argument explains the small-$n$ gap but says nothing about the\n",
    "limit. We push the heavy-tailed comparison to $n=2000,\\,5000,\\,10{,}000$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3b783869",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:48:45.206776Z",
     "iopub.status.busy": "2026-07-13T19:48:45.206776Z",
     "iopub.status.idle": "2026-07-13T19:50:18.793035Z",
     "shell.execute_reply": "2026-07-13T19:50:18.792322Z"
    }
   },
   "outputs": [],
   "source": [
    "rows = []\n",
    "for n in [2000, 5000, 10000]:\n",
    "    v = matched_power(n, \"t3\", seeds=max(8, SEEDS // 3), reps=max(150, REPS // 2))\n",
    "    rows.append(dict(n=n, BIC=v[:, 0].mean(), ROBIN=v[:, 1].mean()))\n",
    "tbl_extended = pd.DataFrame(rows)\n",
    "tbl_extended[\"diff\"] = tbl_extended.BIC - tbl_extended.ROBIN\n",
    "RESULTS[\"tbl_extended\"] = tbl_extended\n",
    "\n",
    "print(\"TABLE 2 -- power (%) at exact 5% FPR, Student's t3, large n\\n\")\n",
    "print(tbl_extended.round(1).to_string(index=False))\n",
    "print(\"\\n-> the heavy-tailed advantage SHRINKS with n but stays positive:\")\n",
    "print(\"   Classical BIC never falls behind ROBIN at matched FPR, at any n tested.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1f9577b2",
   "metadata": {},
   "source": [
    "---\n",
    "## 6. The mechanism: self-normalization, not efficiency  *(§3.4)*\n",
    "\n",
    "Why does the *variance*-based penalty win under heavy tails, where a robust one is\n",
    "supposed to?\n",
    "\n",
    "Both rules are ratios: BIC rejects on $\\Delta C^\\ast/\\hat\\sigma^2$, ROBIN on\n",
    "$\\Delta C^\\ast/\\hat\\sigma^2_{\\rm MAD}$. Under heavy tails a single extreme point\n",
    "inflates the **numerator** $\\Delta C^\\ast$. In BIC that same point also inflates the\n",
    "**denominator** $\\hat\\sigma^2$, so the ratio is *self-normalizing*. The MAD denominator\n",
    "is built not to move — so ROBIN's ratio inherits the heavy tail undiluted, its null\n",
    "distribution grows a long right tail, and to hold 5% it must set a punishing threshold.\n",
    "\n",
    "Two checks below. First, MAD is *not* the worse scale estimator here — so this is not\n",
    "an efficiency story. Second, the rescaling constant is irrelevant under calibration,\n",
    "which proves the effect is structural rather than a tuning artifact."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "38589a4c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:50:18.793035Z",
     "iopub.status.busy": "2026-07-13T19:50:18.793035Z",
     "iopub.status.idle": "2026-07-13T19:50:23.529172Z",
     "shell.execute_reply": "2026-07-13T19:50:23.527055Z"
    }
   },
   "outputs": [],
   "source": [
    "rng = np.random.default_rng(11)\n",
    "n = 200\n",
    "\n",
    "# (a) which scale estimator is more accurate under t3? (truth: sigma = 1)\n",
    "err_sd, err_mad = [], []\n",
    "for _ in range(4000):\n",
    "    y = make_series(rng, n, dist=\"t3\")\n",
    "    sd, mad = scales(y)\n",
    "    err_sd.append((sd - 1) ** 2); err_mad.append((mad - 1) ** 2)\n",
    "print(\"(a) Scale-estimator accuracy under t3 noise (true sigma = 1):\")\n",
    "print(f\"      MSE of sample SD : {np.mean(err_sd):.4f}\")\n",
    "print(f\"      MSE of MAD       : {np.mean(err_mad):.4f}\")\n",
    "print(\"    -> the MAD is the MORE accurate scale estimator here.\")\n",
    "print(\"       ROBIN's power loss is therefore NOT an efficiency loss.\\n\")\n",
    "\n",
    "# (b) null right tail of each ratio -- the actual cause\n",
    "sv = np.array([stat_unrestricted(make_series(rng, n, dist=\"t3\"))[1] for _ in range(4000)])\n",
    "sm = np.array([stat_unrestricted(make_series(rng, n, dist=\"t3\"))[2] for _ in range(4000)])\n",
    "print(\"(b) NULL distribution of each ratio under t3 (this is the mechanism):\")\n",
    "print(f\"      dC*/sd^2   median {np.median(sv):5.2f}   95th pct {np.quantile(sv,.95):6.2f}\")\n",
    "print(f\"      dC*/mad^2  median {np.median(sm):5.2f}   95th pct {np.quantile(sm,.95):6.2f}\")\n",
    "print(f\"    -> ROBIN's 95th percentile is {np.quantile(sm,.95)/np.quantile(sv,.95):.1f}x higher:\")\n",
    "print(\"       a denominator that refuses to move with the numerator inherits its tail,\")\n",
    "print(\"       forcing a higher threshold, which is exactly what costs it power.\\n\")\n",
    "\n",
    "# (c) the MAD constant cannot matter once calibrated\n",
    "sd_thr = np.quantile(sv, .95)\n",
    "tau = n - TAU_FROM_END\n",
    "for const in (1.4826, 2.264):     # Gaussian vs t3-appropriate rescaling\n",
    "    r2 = np.random.default_rng(5)\n",
    "    null = []\n",
    "    for _ in range(1500):\n",
    "        y = make_series(r2, n, dist=\"t3\")\n",
    "        _, dC = dC_all(y); m = const * np.median(np.abs(y - np.median(y)))\n",
    "        null.append(dC.max() / m ** 2)\n",
    "    thr = np.quantile(null, .95)\n",
    "    hit = 0\n",
    "    for _ in range(1500):\n",
    "        y = make_series(r2, n, tau=tau, delta=DELTA, dist=\"t3\")\n",
    "        t, dC = dC_all(y); m = const * np.median(np.abs(y - np.median(y)))\n",
    "        i = int(np.argmax(dC))\n",
    "        hit += (dC[i] / m ** 2 > thr) and abs(int(t[i]) - tau) <= TOL\n",
    "    print(f\"(c) MAD constant {const:6.4f} -> calibrated power {100*hit/1500:5.1f}%\")\n",
    "print(\"    -> identical. A multiplicative constant is absorbed by calibration;\")\n",
    "print(\"       the effect is structural, not a tuning artifact.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "daf7176d",
   "metadata": {},
   "source": [
    "---\n",
    "## 7. The finding is not specific to *t*₃  *(Table 3)*\n",
    "\n",
    "$t_3$ has infinite kurtosis. If the result depended on that extremity it would be a\n",
    "curiosity. Two milder models: $t_5$ (finite kurtosis) and a contaminated normal\n",
    "(95% $N(0,1)$ + 5% $N(0,9)$), a standard model for occasional gross outliers."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0f67fce4",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:50:23.534382Z",
     "iopub.status.busy": "2026-07-13T19:50:23.532728Z",
     "iopub.status.idle": "2026-07-13T19:52:01.854014Z",
     "shell.execute_reply": "2026-07-13T19:52:01.854014Z"
    }
   },
   "outputs": [],
   "source": [
    "rows = []\n",
    "for d in [\"t5\", \"contaminated\"]:\n",
    "    for n in [50, 200, 1000]:\n",
    "        v = matched_power(n, d, seeds=max(10, SEEDS // 2))\n",
    "        rows.append(dict(dist=d, n=n, BIC=v[:, 0].mean(), ROBIN=v[:, 1].mean()))\n",
    "tbl_generalization = pd.DataFrame(rows)\n",
    "tbl_generalization[\"diff\"] = tbl_generalization.BIC - tbl_generalization.ROBIN\n",
    "RESULTS[\"tbl_generalization\"] = tbl_generalization\n",
    "\n",
    "print(\"TABLE 3 -- power (%) at exact 5% FPR, milder heavy-tailed models\\n\")\n",
    "print(tbl_generalization.round(1).to_string(index=False))\n",
    "print(\"\\n-> same direction under BOTH. The mechanism does not need an extreme tail,\")\n",
    "print(\"   only a denominator insensitive to what inflates the numerator.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e393bf4b",
   "metadata": {},
   "source": [
    "---\n",
    "## 8. What the permutation test costs in power  *(Table 4)*\n",
    "\n",
    "The permutation test needs no distributional assumption, which makes it the safe\n",
    "default when the noise shape is unknown. Safety is not free — this quantifies the bill."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "400ce676",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:52:01.860203Z",
     "iopub.status.busy": "2026-07-13T19:52:01.860203Z",
     "iopub.status.idle": "2026-07-13T19:56:09.928287Z",
     "shell.execute_reply": "2026-07-13T19:56:09.926417Z"
    }
   },
   "outputs": [],
   "source": [
    "def perm_power(n, dist, seeds=SEEDS, reps=REPS, B=B_SIM):\n",
    "    out = []\n",
    "    tau = n - TAU_FROM_END\n",
    "    for s in range(seeds):\n",
    "        rng = np.random.default_rng(31_337 * s + n)\n",
    "        pw = fp = 0\n",
    "        for _ in range(reps):\n",
    "            y = make_series(rng, n, tau=tau, delta=DELTA, dist=dist)\n",
    "            t, _, _ = stat_unrestricted(y)\n",
    "            pw += (perm_pvalue(y, B, rng) < ALPHA) and abs(t - tau) <= TOL\n",
    "        for _ in range(reps):\n",
    "            fp += perm_pvalue(make_series(rng, n, dist=dist), B, rng) < ALPHA\n",
    "        out.append((100 * pw / reps, 100 * fp / reps))\n",
    "    return np.array(out)\n",
    "\n",
    "rows = []\n",
    "for d in DISTS:\n",
    "    for n in [50, 1000]:\n",
    "        v = perm_power(n, d, seeds=max(10, SEEDS // 2), reps=max(200, REPS // 2))\n",
    "        b = tbl_matched.query(\"dist == @d and n == @n\")\n",
    "        rows.append(dict(dist=d, n=n, Perm=v[:, 0].mean(), Perm_sd=v[:, 0].std(),\n",
    "                         Perm_fpr=v[:, 1].mean(),\n",
    "                         BIC=float(b.BIC.iloc[0]), ROBIN=float(b.ROBIN.iloc[0])))\n",
    "tbl_perm = pd.DataFrame(rows)\n",
    "RESULTS[\"tbl_perm\"] = tbl_perm\n",
    "\n",
    "print(\"TABLE 4 -- permutation-test power (%), against the calibrated methods\\n\")\n",
    "print(f\"{'noise':<18}{'n':>6}{'Perm':>10}{'(FPR)':>9}{'BIC':>9}{'ROBIN':>9}{'deficit vs BIC':>16}\")\n",
    "for _, r in tbl_perm.iterrows():\n",
    "    print(f\"{LBL[r['dist']]:<18}{int(r['n']):>6}{r.Perm:>10.1f}{r.Perm_fpr:>8.1f}%\"\n",
    "          f\"{r.BIC:>9.1f}{r.ROBIN:>9.1f}{r.BIC - r.Perm:>+16.1f}\")\n",
    "print(\"\\n-> Its achieved FPR sits near 4%, not 5%: with B=99 the test's exact level is\")\n",
    "print(\"   0.04 (rejection needs <=3 of 99 exceedances). It is therefore compared at a\")\n",
    "print(\"   slightly stricter operating point, and its power is a mild UNDERestimate.\")\n",
    "print(\"-> The deficit grows with the tail weight, which is what the self-normalization\")\n",
    "print(\"   mechanism predicts: the permutation test thresholds the RAW cost reduction,\")\n",
    "print(\"   so it does not inherit BIC's studentized protection.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9b2e0f02",
   "metadata": {},
   "source": [
    "---\n",
    "## 9. Matched ablation: the search restriction, not the scale estimator  *(Table 5, Fig. 2)*\n",
    "\n",
    "Windowing was introduced alongside a robust scale. Does the gain come from the\n",
    "**search restriction** or from the **robust scale**? The only way to know is to apply\n",
    "the same windowing to the *simplest* baseline — the variance-based penalty — and see\n",
    "whether the gain survives. That is the ablation.\n",
    "\n",
    "Both windowed variants are recalibrated to an exact 5% FPR **at every $n$**, so the\n",
    "comparison between them is itself false-positive-matched."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "21330e45",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T19:56:09.929355Z",
     "iopub.status.busy": "2026-07-13T19:56:09.929355Z",
     "iopub.status.idle": "2026-07-13T20:00:18.208520Z",
     "shell.execute_reply": "2026-07-13T20:00:18.208520Z"
    }
   },
   "outputs": [],
   "source": [
    "def windowed_power(n, dist, seeds=SEEDS, reps=REPS):\n",
    "    \"\"\"Windowed BIC vs windowed ROBIN, each at an exact 5% FPR, recalibrated at every n.\"\"\"\n",
    "    out = []\n",
    "    tau = n - TAU_FROM_END\n",
    "    for s in range(seeds):\n",
    "        rng = np.random.default_rng(4_242 * s + 13 * n + len(dist))\n",
    "        tv, tm = exact_thresholds(rng, n, dist, \"win\")\n",
    "        pb = pr = fb = fr = 0\n",
    "        for _ in range(reps):\n",
    "            t, sv, sm = stat_windowed(make_series(rng, n, tau=tau, delta=DELTA, dist=dist))\n",
    "            if t is None: continue\n",
    "            pb += sv > tv; pr += sm > tm          # windowed scoring: any hit in the window\n",
    "        for _ in range(reps):\n",
    "            t, sv, sm = stat_windowed(make_series(rng, n, dist=dist))\n",
    "            if t is None: continue\n",
    "            fb += sv > tv; fr += sm > tm\n",
    "        out.append((100 * pb / reps, 100 * pr / reps, 100 * fb / reps, 100 * fr / reps))\n",
    "    return np.array(out)\n",
    "\n",
    "WN = [25, 50, 100, 200, 400, 1000]\n",
    "rows = []\n",
    "for d in DISTS:\n",
    "    for n in WN:\n",
    "        v = windowed_power(n, d, seeds=max(10, SEEDS // 2))\n",
    "        rows.append(dict(dist=d, n=n, wBIC=v[:, 0].mean(), wROBIN=v[:, 1].mean(),\n",
    "                         fpr_wBIC=v[:, 2].mean(), fpr_wROBIN=v[:, 3].mean()))\n",
    "tbl_win = pd.DataFrame(rows)\n",
    "tbl_win[\"diff\"] = tbl_win.wBIC - tbl_win.wROBIN\n",
    "RESULTS[\"tbl_windowed\"] = tbl_win\n",
    "\n",
    "print(\"TABLE 5 -- windowed BIC vs windowed ROBIN, exact 5% FPR recalibrated at every n\\n\")\n",
    "print(f\"{'noise':<18}{'n':>6}{'wBIC':>9}{'wROBIN':>9}{'gap':>8}   achieved FPR\")\n",
    "for _, r in tbl_win.iterrows():\n",
    "    print(f\"{LBL[r['dist']]:<18}{int(r['n']):>6}{r.wBIC:>9.1f}{r.wROBIN:>9.1f}\"\n",
    "          f\"{r['diff']:>+8.1f}   {r.fpr_wBIC:.1f}% / {r.fpr_wROBIN:.1f}%\")\n",
    "print(\"\\n-> At SMALL n the variance-based penalty leads in ALL THREE distributions.\")\n",
    "print(\"-> The gap CLOSES as n grows; by n>=400 the two are interchangeable, and under\")\n",
    "print(\"   t3 windowed ROBIN even edges ahead. Once the baseline is long enough to pin\")\n",
    "print(\"   the MAD down, self-normalization stops mattering and MAD's efficiency shows.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b9dc9958",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:00:18.213122Z",
     "iopub.status.busy": "2026-07-13T20:00:18.213122Z",
     "iopub.status.idle": "2026-07-13T20:01:46.213432Z",
     "shell.execute_reply": "2026-07-13T20:01:46.211162Z"
    }
   },
   "outputs": [],
   "source": [
    "# Figure 2: windowed vs unrestricted, both at matched FPR -- the ablation itself\n",
    "rows = []\n",
    "for d in DISTS:\n",
    "    for n in WN:\n",
    "        u = matched_power(n, d, seeds=max(6, SEEDS // 5), reps=max(150, REPS // 2))\n",
    "        w = tbl_win.query(\"dist == @d and n == @n\")\n",
    "        rows.append(dict(dist=d, n=n, BIC=u[:, 0].mean(), ROBIN=u[:, 1].mean(),\n",
    "                         wBIC=float(w.wBIC.iloc[0]), wROBIN=float(w.wROBIN.iloc[0])))\n",
    "abl = pd.DataFrame(rows)\n",
    "RESULTS[\"ablation\"] = abl\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(12, 3.4), sharey=True)\n",
    "for ax, d in zip(axes, DISTS):\n",
    "    g = abl[abl.dist == d]\n",
    "    ax.plot(g.n, g.BIC,    \"o--\", c=\"#cb181d\", alpha=.7, label=\"BIC (unrestricted)\")\n",
    "    ax.plot(g.n, g.ROBIN,  \"s--\", c=\"#08519c\", alpha=.7, label=\"ROBIN (unrestricted)\")\n",
    "    ax.plot(g.n, g.wBIC,   \"o-\",  c=\"#cb181d\", lw=2.2,   label=\"windowed BIC\")\n",
    "    ax.plot(g.n, g.wROBIN, \"s-\",  c=\"#08519c\", lw=2.2,   label=\"windowed ROBIN\")\n",
    "    ax.set_xscale(\"log\"); ax.set_title(LBL[d]); ax.set_xlabel(\"sample size $n$\"); ax.grid(alpha=.3)\n",
    "axes[0].set_ylabel(\"power at matched 5% FPR (%)\"); axes[0].legend(fontsize=7.5)\n",
    "plt.tight_layout(); plt.savefig(FIG / \"fig2_matched_power.pdf\"); plt.show()\n",
    "\n",
    "print(\"Windowing gain (windowed - unrestricted), same penalty, matched FPR:\")\n",
    "for d in DISTS:\n",
    "    g = abl[(abl.dist == d) & (abl.n >= 200)]\n",
    "    print(f\"  {LBL[d]:<18} BIC {(g.wBIC - g.BIC).mean():+5.1f} pts    \"\n",
    "          f\"ROBIN {(g.wROBIN - g.ROBIN).mean():+5.1f} pts\")\n",
    "print(\"\\n-> The gain appears for the VARIANCE-based penalty just as much as for the\")\n",
    "print(\"   robust one. It is the SEARCH RESTRICTION that does the work, not the scale\")\n",
    "print(\"   estimator alongside which windowing was introduced. That is the ablation.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b87835b8",
   "metadata": {},
   "source": [
    "---\n",
    "## 10. Window-size sensitivity  *(Fig. 3)*\n",
    "\n",
    "$W=20$ was chosen in advance to just exceed the true 15-observation shift window.\n",
    "Was that lucky? Sweep $W$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "92527368",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:01:46.218984Z",
     "iopub.status.busy": "2026-07-13T20:01:46.216967Z",
     "iopub.status.idle": "2026-07-13T20:02:42.643071Z",
     "shell.execute_reply": "2026-07-13T20:02:42.643071Z"
    }
   },
   "outputs": [],
   "source": [
    "WS = [10, 15, 20, 30, 50, 75, 100]\n",
    "rows = []\n",
    "for d in DISTS:\n",
    "    for n in [100, 1000]:\n",
    "        for w in [x for x in WS if x <= n]:\n",
    "            pw = []\n",
    "            tau = n - TAU_FROM_END\n",
    "            for s in range(max(4, SEEDS // 6)):\n",
    "                rng = np.random.default_rng(777 * s + n + w)\n",
    "                nul = np.array([stat_windowed(make_series(rng, n, dist=d), w)[1]\n",
    "                                for _ in range(max(400, NULL_CAL // 4))])\n",
    "                thr = np.quantile(nul, .95)\n",
    "                hit = sum(stat_windowed(make_series(rng, n, tau=tau, delta=DELTA, dist=d), w)[1] > thr\n",
    "                          for _ in range(max(150, REPS // 2)))\n",
    "                pw.append(100 * hit / max(150, REPS // 2))\n",
    "            rows.append(dict(dist=d, n=n, W=w, power=np.mean(pw)))\n",
    "wsweep = pd.DataFrame(rows)\n",
    "RESULTS[\"window_sweep\"] = wsweep\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(12, 3.4), sharey=True)\n",
    "for ax, d in zip(axes, DISTS):\n",
    "    for n, mk in [(100, \"o-\"), (1000, \"s-\")]:\n",
    "        g = wsweep[(wsweep.dist == d) & (wsweep.n == n)]\n",
    "        ax.plot(g.W, g.power, mk, label=f\"$n={n}$\")\n",
    "    ax.axvline(15, ls=\":\", c=\"k\", lw=1.2, label=\"true shift window (15)\")\n",
    "    ax.set_title(LBL[d]); ax.set_xlabel(\"search window $W$\"); ax.grid(alpha=.3)\n",
    "axes[0].set_ylabel(\"power at matched 5% FPR (%)\"); axes[0].legend(fontsize=7.5)\n",
    "plt.tight_layout(); plt.savefig(FIG / \"fig3_window_size.pdf\"); plt.show()\n",
    "\n",
    "print(\"-> Power peaks at or just above the TRUE 15-observation shift window and falls\")\n",
    "print(\"   away in both directions. Too narrow misses part of the signal; too wide pays\")\n",
    "print(\"   an avoidable multiple-testing cost. Set W from the shortest relevant recency\")\n",
    "print(\"   horizon -- do NOT pad it 'for safety'. The padding is not free.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7f995b03",
   "metadata": {},
   "source": [
    "---\n",
    "## 11. The cost of autocorrelation  *(Fig. 4)*\n",
    "\n",
    "Everything so far assumes i.i.d. noise. Production series are not: sequential runs,\n",
    "shared reagent lots and slow instrument drift all induce autocorrelation. Here every\n",
    "method is calibrated **under the i.i.d. assumption** — the situation of a practitioner\n",
    "who never checked — and then run on AR(1) data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "185a3a90",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:02:42.643071Z",
     "iopub.status.busy": "2026-07-13T20:02:42.643071Z",
     "iopub.status.idle": "2026-07-13T20:03:16.530398Z",
     "shell.execute_reply": "2026-07-13T20:03:16.530398Z"
    }
   },
   "outputs": [],
   "source": [
    "def ar1(rng, n_seq, n, phi):\n",
    "    s = np.zeros((n_seq, n))\n",
    "    s[:, 0] = rng.normal(0, 1 / np.sqrt(1 - phi ** 2), n_seq)\n",
    "    e = rng.normal(0, 1, (n_seq, n - 1))\n",
    "    for t in range(1, n):\n",
    "        s[:, t] = phi * s[:, t - 1] + e[:, t - 1]\n",
    "    return s\n",
    "\n",
    "# Canonical high-precision AR(1) numbers (Fig 3 + Supplementary Table S8)\n",
    "# are also regenerated fast and standalone by code/reproduce_ar1.py.\n",
    "PHIS = [0.0, 0.15, 0.3, 0.45, 0.6]\n",
    "rows = []\n",
    "for n in [200, 1000]:\n",
    "    rng = np.random.default_rng(2024)\n",
    "    tv, tm = exact_thresholds(rng, n, \"gaussian\", \"unres\")     # calibrated at phi = 0\n",
    "    for phi in PHIS:\n",
    "        S = ar1(rng, 4000 if PAPER_SETTINGS else 300, n, phi)  # AR FPR at phi=0.6 needs high reps\n",
    "        fb = fr = fp = 0\n",
    "        for y in S:\n",
    "            y = (y - y.mean()) / y.std(ddof=1)\n",
    "            _, sv, sm = stat_unrestricted(y)\n",
    "            fb += sv > tv; fr += sm > tm\n",
    "            fp += perm_pvalue(y, B_SIM, rng) < ALPHA\n",
    "        m = len(S)\n",
    "        rows.append(dict(n=n, phi=phi, BIC=100 * fb / m, ROBIN=100 * fr / m, Perm=100 * fp / m))\n",
    "ar1_df = pd.DataFrame(rows)\n",
    "RESULTS[\"ar1\"] = ar1_df\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)\n",
    "for ax, n in zip(axes, [200, 1000]):\n",
    "    g = ar1_df[ar1_df.n == n]\n",
    "    ax.plot(g.phi, g.BIC,   \"o-\", label=\"Classical BIC\")\n",
    "    ax.plot(g.phi, g.ROBIN, \"s-\", label=\"ROBIN\")\n",
    "    ax.plot(g.phi, g.Perm,  \"^-\", label=\"Permutation\")\n",
    "    ax.axhline(5, ls=\"--\", c=\"k\", lw=1, label=\"nominal 5%\")\n",
    "    ax.set_title(f\"$n={n}$\"); ax.set_xlabel(r\"AR(1) coefficient $\\phi$\"); ax.grid(alpha=.3)\n",
    "axes[0].set_ylabel(\"achieved false-positive rate (%)\"); axes[0].legend(fontsize=8)\n",
    "plt.tight_layout(); plt.savefig(FIG / \"fig4_ar1_autocorrelation.pdf\"); plt.show()\n",
    "\n",
    "print(ar1_df.round(1).to_string(index=False))\n",
    "print(\"\\n-> MILD autocorrelation wrecks every method, far beyond any noise-shape effect.\")\n",
    "print(\"-> The permutation test's distribution-free exactness gives NO protection: it\")\n",
    "print(\"   assumes exchangeability, and autocorrelation is exactly what breaks that.\")\n",
    "print(\"-> Autocorrelation, not noise shape, is the dominant practical threat.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9e5ed2f4",
   "metadata": {},
   "source": [
    "---\n",
    "## 12. Cross-calibration: the cost of not knowing the noise distribution  *(Table 6)*\n",
    "\n",
    "The comparison so far is an *oracle*: each method is calibrated on the noise it will\n",
    "actually face. A practitioner rarely knows it. So calibrate on **Gaussian** null data,\n",
    "then apply to heavy-tailed and skewed data, and see what the false-positive rate does."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ddb21a48",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:03:16.536487Z",
     "iopub.status.busy": "2026-07-13T20:03:16.530398Z",
     "iopub.status.idle": "2026-07-13T20:03:21.017967Z",
     "shell.execute_reply": "2026-07-13T20:03:21.016890Z"
    }
   },
   "outputs": [],
   "source": [
    "rows = []\n",
    "for n in [50, 200, 1000]:\n",
    "    rng = np.random.default_rng(606 + n)\n",
    "    tv, tm = exact_thresholds(rng, n, \"gaussian\", \"unres\")     # calibrated on GAUSSIAN\n",
    "    for d in DISTS:\n",
    "        fb = fr = 0\n",
    "        m = max(600, REPS * 2)\n",
    "        for _ in range(m):\n",
    "            _, sv, sm = stat_unrestricted(make_series(rng, n, dist=d))\n",
    "            fb += sv > tv; fr += sm > tm\n",
    "        rows.append(dict(n=n, target=d, BIC=100 * fb / m, ROBIN=100 * fr / m))\n",
    "tbl_cross = pd.DataFrame(rows)\n",
    "RESULTS[\"tbl_cross\"] = tbl_cross\n",
    "\n",
    "print(\"TABLE 6 -- FPR (%) when calibrated on GAUSSIAN and applied elsewhere\\n\")\n",
    "print(tbl_cross.pivot(index=\"target\", columns=\"n\", values=[\"BIC\", \"ROBIN\"]).round(1).to_string())\n",
    "print(f\"\\n-> Classical BIC stays under ~{tbl_cross.BIC.max():.0f}% everywhere.\")\n",
    "print(f\"-> ROBIN reaches {tbl_cross.ROBIN.max():.0f}% -- it would flag a nonexistent drift on\")\n",
    "print(\"   the MAJORITY of in-control series. Same self-normalization mismatch, now\")\n",
    "print(\"   operating on the calibration itself.\")\n",
    "print(\"-> If you don't know the noise shape and must calibrate once, the variance-based\")\n",
    "print(\"   penalty is far safer. The permutation test, needing no calibration, is safer still.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "53a9a92c",
   "metadata": {},
   "source": [
    "---\n",
    "## 13. Case study: IndPenSim bioprocess data  *(Tables 9–10, Fig. 5)*\n",
    "\n",
    "IndPenSim V3 is a first-principles simulation of an industrial penicillin\n",
    "fermentation. We use 100 consecutive batches and the six laboratory assays recorded at\n",
    "batch end.\n",
    "\n",
    "**Ground truth comes from the dataset itself.** Its control design runs batches 1–30\n",
    "on a fixed recipe, 31–60 under operator control, 61–90 under Raman-based advanced\n",
    "process control, and 91–100 as deliberate fault batches. The `Fault_ref` channel flags\n",
    "exactly batches 91–100 — a documented changepoint at $t=90$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2bd29169",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:03:21.023630Z",
     "iopub.status.busy": "2026-07-13T20:03:21.022618Z",
     "iopub.status.idle": "2026-07-13T20:05:55.494802Z",
     "shell.execute_reply": "2026-07-13T20:05:55.494802Z"
    }
   },
   "outputs": [],
   "source": [
    "RAW = Path(\"../100_Batches_IndPenSim_V3.csv\")\n",
    "OFFLINE = {\n",
    "    \"Penicillin concentration(P:g/L)\":                          \"Penicillin\",\n",
    "    \"Offline Penicillin concentration(P_offline:P(g L^{-1}))\":  \"Penicillin (assay)\",\n",
    "    \"Offline Biomass concentratio(X_offline:X(g L^{-1}))\":      \"Biomass\",\n",
    "    \"PAA concentration offline(PAA_offline:PAA (g L^{-1}))\":    \"PAA\",\n",
    "    \"NH_3 concentration off-line(NH3_offline:NH3 (g L^{-1}))\":  \"NH3\",\n",
    "    \"Viscosity(Viscosity_offline:centPoise)\":                   \"Viscosity\",\n",
    "}\n",
    "\n",
    "# The raw file is ~2.5 GB (113,935 rows x 2,239 columns of within-batch time series).\n",
    "# We only ever need the BATCH-END row of each batch. Read it once, in chunks, keeping\n",
    "# just those 100 rows -- everything downstream (assays AND the plant-wide sweep) is\n",
    "# derived from this single pass.\n",
    "t_read = time.time()\n",
    "tcol = pd.read_csv(RAW, usecols=[\"Time (h)\"])[\"Time (h)\"]        # pass 1: find batch bounds\n",
    "starts = [0] + tcol.index[tcol.diff() < 0].tolist()\n",
    "end_idx = [i - 1 for i in starts[1:]] + [len(tcol) - 1]          # last row of each batch\n",
    "keep = set(end_idx)\n",
    "\n",
    "parts = []                                                        # pass 2: keep only those rows\n",
    "for chunk in pd.read_csv(RAW, chunksize=20000):\n",
    "    sel = chunk[chunk.index.isin(keep)]\n",
    "    if len(sel):\n",
    "        parts.append(sel)\n",
    "end_rows = pd.concat(parts).sort_index()\n",
    "print(f\"raw file read in {time.time()-t_read:.0f}s -> batch-end matrix \"\n",
    "      f\"{end_rows.shape[0]} batches x {end_rows.shape[1]} channels\")\n",
    "\n",
    "cqa = pd.DataFrame({\"Batch\": np.arange(1, len(end_rows) + 1)})\n",
    "for c, name in OFFLINE.items():\n",
    "    cqa[name] = end_rows[c].values\n",
    "\n",
    "# The fault reference is a WITHIN-batch signal: it is raised while a fault is active\n",
    "# and is often back at 0 by the batch-end row. So a batch is a fault batch if the\n",
    "# reference is EVER raised during it -- take the max over the batch, not the last row.\n",
    "fr = pd.read_csv(RAW, usecols=[\"Fault reference(Fault_ref:Fault ref)\"]).iloc[:, 0]\n",
    "cqa[\"Fault\"] = [fr.iloc[a:z].max() for a, z in zip(starts, starts[1:] + [len(fr)])]\n",
    "\n",
    "flagged = cqa.loc[cqa.Fault != 0, \"Batch\"]\n",
    "GT = int(flagged.min()) - 1\n",
    "print(f\"Fault_ref is raised during batches {flagged.min()}-{flagged.max()} \"\n",
    "      f\"({len(flagged)} batches)\")\n",
    "print(f\"  ->  documented changepoint at t = {GT}\")\n",
    "cqa.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1f236a7a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:05:55.494802Z",
     "iopub.status.busy": "2026-07-13T20:05:55.494802Z",
     "iopub.status.idle": "2026-07-13T20:05:58.660905Z",
     "shell.execute_reply": "2026-07-13T20:05:58.660905Z"
    }
   },
   "outputs": [],
   "source": [
    "# Every formula-based detector calibrated to an EXACT 5% FPR on a Gaussian null at n=100.\n",
    "n = len(cqa)\n",
    "rng = np.random.default_rng(42)\n",
    "tu_v, tu_m = exact_thresholds(rng, n, \"gaussian\", \"unres\", reps=4000)\n",
    "tw_v, tw_m = exact_thresholds(rng, n, \"gaussian\", \"win\",   reps=4000)\n",
    "print(f\"exact 5% FPR thresholds (Gaussian null, n={n}):\")\n",
    "print(f\"   BIC {tu_v:.2f}   ROBIN {tu_m:.2f}   windowed BIC {tw_v:.2f}   windowed ROBIN {tw_m:.2f}\\n\")\n",
    "\n",
    "rows = []\n",
    "for name in OFFLINE.values():\n",
    "    y = cqa[name].values.astype(float)\n",
    "    y = (y - y.mean()) / y.std(ddof=1)\n",
    "    tu, sv, sm = stat_unrestricted(y)\n",
    "    tw, wv, wm = stat_windowed(y)\n",
    "    p = perm_pvalue(y, B_APPLIED, rng)\n",
    "    sd, mad = scales(y)\n",
    "    rows.append(dict(variable=name, t_unres=tu, BIC_SNR=sv / tu_v, ROBIN_SNR=sm / tu_m,\n",
    "                     t_win=tw, wBIC_SNR=wv / tw_v, wROBIN_SNR=wm / tw_m,\n",
    "                     perm_p=p, sd_over_mad=sd / mad))\n",
    "case = pd.DataFrame(rows)\n",
    "RESULTS[\"case_study\"] = case\n",
    "\n",
    "print(\"TABLE 9 -- all five detectors on all six offline assays\\n\")\n",
    "print(case.drop(columns=\"sd_over_mad\").round(3).to_string(index=False))\n",
    "print(f\"\\n-> Every detector fires on every variable (all SNR > 1, all p < {ALPHA}).\")\n",
    "print(\"   This is NOT a power comparison -- the changes are large. What it tests is\")\n",
    "print(\"   LOCALIZATION.\")\n",
    "print(f\"-> Four variables break at the documented boundary t={GT}.\")\n",
    "print(\"-> NH3 breaks at t=60: not an error, but the documented operator->APC boundary.\")\n",
    "print(\"-> The WINDOWED detectors search only batches 81-100, so they CANNOT SEE the\")\n",
    "print(\"   batch-60 event and return batch 90 for NH3 instead. That is the design\")\n",
    "print(\"   commitment of windowing, appearing on real data.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2e293c7b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:05:58.668720Z",
     "iopub.status.busy": "2026-07-13T20:05:58.668720Z",
     "iopub.status.idle": "2026-07-13T20:05:58.691470Z",
     "shell.execute_reply": "2026-07-13T20:05:58.688848Z"
    }
   },
   "outputs": [],
   "source": [
    "# The mechanism, on real data: ROBIN's SNR margin should be governed by sd/MAD.\n",
    "chk = case[[\"variable\", \"sd_over_mad\", \"ROBIN_SNR\", \"BIC_SNR\"]].sort_values(\"sd_over_mad\")\n",
    "print(\"Does sd/MAD predict ROBIN's SNR margin? (manuscript §3.16)\\n\")\n",
    "print(chk.round(2).to_string(index=False))\n",
    "r = np.corrcoef(chk.sd_over_mad, chk.ROBIN_SNR)[0, 1]\n",
    "mono = (np.diff(chk.ROBIN_SNR.values) > 0).all()\n",
    "print(f\"\\nPearson r = {r:.3f}   monotone in sd/MAD: {mono}\")\n",
    "print(\"\\n-> The two orderings are identical. ROBIN's larger SNR is a QUANTITATIVE\")\n",
    "print(\"   SIGNATURE of the very asymmetry that costs it power on the null --\")\n",
    "print(\"   NOT evidence that it would catch a shift Classical BIC would miss.\")\n",
    "print(\"   (SNR is measured on a series that already contains the shift; it is\")\n",
    "print(\"    descriptive, not a performance metric.)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "79a4c93a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:05:58.693633Z",
     "iopub.status.busy": "2026-07-13T20:05:58.693633Z",
     "iopub.status.idle": "2026-07-13T20:05:58.716366Z",
     "shell.execute_reply": "2026-07-13T20:05:58.714222Z"
    }
   },
   "outputs": [],
   "source": [
    "# The two limitations the manuscript names rather than hides.\n",
    "paa = cqa[\"PAA\"].values.astype(float)\n",
    "seg = {\"1-60 (uncontrolled)\": paa[:60], \"61-90 (under APC)\": paa[60:90], \"91-100 (fault)\": paa[90:]}\n",
    "print(\"PAA -- is the batch-90 event a change of MEAN, or of SCALE?\\n\")\n",
    "for k, v in seg.items():\n",
    "    print(f\"   {k:<22} mean {v.mean():8.1f}   SD {v.std(ddof=1):8.1f}\")\n",
    "print(\"\\n-> Under APC the controller pins PAA to a setpoint: SD collapses to ~1 g/L,\")\n",
    "print(\"   against ~2600 before and after. Its batch-90 event is a change of SCALE at\")\n",
    "print(\"   least as much as of mean, which violates the common-scale assumption (§2.1).\")\n",
    "print(\"   Detection is unsurprising; it is NOT evidence that the mean-shift model fits.\")\n",
    "print(\"   (The same pinned segment compresses the MAD -- which is why PAA also tops\")\n",
    "print(\"    the sd/MAD ranking above. One feature explains both observations.)\\n\")\n",
    "\n",
    "visc_t = int(case.query(\"variable == 'Viscosity'\").t_unres.iloc[0])\n",
    "print(f\"Viscosity -- changepoint at t*={visc_t}, leaving {len(cqa)-visc_t} post-change batches\")\n",
    "print(f\"   (h = {H} is the minimum). Its localization is the least secure of the six.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9face7e2",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:05:58.716366Z",
     "iopub.status.busy": "2026-07-13T20:05:58.716366Z",
     "iopub.status.idle": "2026-07-13T20:06:00.179491Z",
     "shell.execute_reply": "2026-07-13T20:06:00.179491Z"
    }
   },
   "outputs": [],
   "source": [
    "# The B=99 lesson: adequate for AVERAGED simulation power, NOT for a single decision.\n",
    "print(\"Permutation verdict stability on the real series (manuscript §3.16)\\n\")\n",
    "print(f\"{'variable':<20}{'p (B=9999)':>12}{'fire rate at B=99':>20}{'verdict':>14}\")\n",
    "stab = []\n",
    "for name in OFFLINE.values():\n",
    "    y = cqa[name].values.astype(float)\n",
    "    y = (y - y.mean()) / y.std(ddof=1)\n",
    "    p_exact = perm_pvalue(y, B_APPLIED, np.random.default_rng(7))\n",
    "    fires = [perm_pvalue(y, 99, np.random.default_rng(s)) < ALPHA for s in range(60)]\n",
    "    rate = 100 * np.mean(fires)\n",
    "    v = \"stable\" if rate > 95 or rate < 5 else \"SEED-DEPENDENT\"\n",
    "    stab.append(dict(variable=name, p=p_exact, rate=rate))\n",
    "    print(f\"{name:<20}{p_exact:>12.4f}{rate:>19.0f}%{v:>14}\")\n",
    "RESULTS[\"perm_stability\"] = pd.DataFrame(stab)\n",
    "\n",
    "print(\"\\n-> PAA and NH3 have TRUE p-values below 0.05 -- the test does not really miss\")\n",
    "print(\"   them. But at B=99 the decision FLIPS with the random seed, because p sits\")\n",
    "print(\"   near the rejection boundary.\")\n",
    "print(\"-> B=99 is sound for the SIMULATIONS, where power is averaged over thousands of\")\n",
    "print(\"   series and the granularity of any single p-hat washes out. It does NOT\")\n",
    "print(\"   transfer to the applied setting, where the test is run ONCE and someone must\")\n",
    "print(\"   act on that single verdict. Use B in the thousands. It costs seconds.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "da70a89d",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:06:00.184639Z",
     "iopub.status.busy": "2026-07-13T20:06:00.184639Z",
     "iopub.status.idle": "2026-07-13T20:06:03.150511Z",
     "shell.execute_reply": "2026-07-13T20:06:03.147725Z"
    }
   },
   "outputs": [],
   "source": [
    "# FIGURE 5 -- six non-redundant panels.\n",
    "# Panel choice follows the same redundancy discipline the paper applies to the Raman\n",
    "# bins: do not present near-copies as independent witnesses.\n",
    "Z = np.column_stack([(cqa[c].values - cqa[c].values.mean()) / cqa[c].values.std(ddof=1)\n",
    "                     for c in OFFLINE.values()])\n",
    "Rm = np.corrcoef(Z.T)\n",
    "names = list(OFFLINE.values())\n",
    "i, j = names.index(\"Penicillin\"), names.index(\"Penicillin (assay)\")\n",
    "print(f\"Penicillin's two measurement channels correlate at |r| = {abs(Rm[i,j]):.3f}\")\n",
    "print(\"-> plotting both would present one measurement twice. We show one.\\n\")\n",
    "\n",
    "PANELS = [\"Penicillin\", \"Biomass\", \"PAA\", \"NH3\", \"Viscosity\"]\n",
    "fig, axes = plt.subplots(3, 2, figsize=(12.5, 10), sharex=True)\n",
    "for ax, name in zip(axes.ravel(), PANELS):\n",
    "    y = cqa[name].values.astype(float)\n",
    "    r = case.query(\"variable == @name\").iloc[0]\n",
    "    tu, tw = int(r.t_unres), int(r.t_win)\n",
    "    ax.axvspan(len(cqa) - W + .5, len(cqa) + .5, color=\"#dcdcdc\", zorder=0)\n",
    "    ax.plot(cqa.Batch, y, \"o-\", ms=3, lw=.8, c=\"k\", zorder=3)\n",
    "    ax.axvline(GT, c=\"#2ca25f\", lw=2.2, alpha=.6, zorder=1)\n",
    "    ax.axvline(tu, c=\"#cb181d\", ls=\"--\", lw=2, zorder=4)\n",
    "    if tw != tu:\n",
    "        ax.axvline(tw, c=\"#08519c\", ls=\":\", lw=2.4, zorder=4)\n",
    "    ax.hlines(y[:tu].mean(), 1, tu, color=\"#e6550d\", lw=2, zorder=2)\n",
    "    ax.hlines(y[tu:].mean(), tu, len(cqa), color=\"#e6550d\", lw=2, zorder=2)\n",
    "    ax.set_title(name, loc=\"left\", fontsize=10); ax.grid(alpha=.25)\n",
    "axes[-1, -1].axis(\"off\")\n",
    "axes[-1, -1].text(.05, .5,\n",
    "    \"green   documented fault boundary (t=90)\\n\"\n",
    "    \"red     unrestricted detectors\\n\"\n",
    "    \"blue    windowed detectors (differ only on NH3)\\n\"\n",
    "    \"gray    the only region windowed methods may search\\n\\n\"\n",
    "    \"NH3 is the point: its real break is at batch 60,\\n\"\n",
    "    \"outside the windowed search region, so the\\n\"\n",
    "    \"windowed detectors return batch 90 instead.\",\n",
    "    fontsize=8.5, va=\"center\", family=\"monospace\")\n",
    "for ax in axes[-1]:\n",
    "    ax.set_xlabel(\"Batch number\")\n",
    "plt.tight_layout(); plt.savefig(FIG / \"fig5_indpensim_casestudy.pdf\"); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "415c1947",
   "metadata": {},
   "source": [
    "---\n",
    "## 14. Plant-wide sweep, and why a channel count is not evidence  *(supplementary)*\n",
    "\n",
    "Is the batch-90 event confined to the six assays? Sweep **every** channel IndPenSim\n",
    "records at batch end. The answer is interesting; the way the answer is usually\n",
    "*counted* is a trap, and this section is mostly about the trap."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "318284f3",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:06:03.150511Z",
     "iopub.status.busy": "2026-07-13T20:06:03.150511Z",
     "iopub.status.idle": "2026-07-13T20:06:04.057716Z",
     "shell.execute_reply": "2026-07-13T20:06:04.057716Z"
    }
   },
   "outputs": [],
   "source": [
    "end = end_rows.select_dtypes(include=[np.number])      # reuse the single raw pass above\n",
    "sd0 = end.std(ddof=1)\n",
    "end = end[sd0[(sd0 > 0) & (~sd0.isna())].index]\n",
    "print(f\"channels passing a naive sd > 0 filter: {end.shape[1]}\")\n",
    "\n",
    "# TRAP 1 -- 'sd > 0' is not enough: a channel can be flat to machine precision.\n",
    "rngspan = end.max() - end.min()\n",
    "degenerate = end.columns[rngspan == 0]\n",
    "for d in degenerate:\n",
    "    y = end[d].values.astype(float)\n",
    "    y2 = (y - y.mean()) / (y.std(ddof=1) if y.std(ddof=1) else 1)\n",
    "    print(f\"\\n  DEGENERATE: {d}\")\n",
    "    print(f\"     range = {rngspan[d]:.2e}   sd = {sd0[d]:.2e}  <- floating-point round-off\")\n",
    "    print(f\"     yet detectors still fire on it, because a threshold scaled by sigma-hat\")\n",
    "    print(f\"     cannot tell that sigma-hat IS the round-off.\")\n",
    "end = end.drop(columns=degenerate)\n",
    "\n",
    "# TRAP 2 -- design variables and labels are not measurements.\n",
    "LABELS = re.compile(r\"Fault|Control_ref|PAT|Batch reference|Batch ID|Raman spec|^Time \\(h\\)$\", re.I)\n",
    "design = [c for c in end.columns if LABELS.search(c)]\n",
    "print(f\"\\n  CIRCULAR (design variables / labels, not measurements): {len(design)}\")\n",
    "for d in design[:4]:\n",
    "    print(f\"     - {d}\")\n",
    "print(\"     ... the fault flag IS the event; the batch counter is a monotone ramp 1..100.\")\n",
    "end = end.drop(columns=design)\n",
    "print(f\"\\n=> measurement channels retained: {end.shape[1]}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e6e82c7e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:06:04.057716Z",
     "iopub.status.busy": "2026-07-13T20:06:04.057716Z",
     "iopub.status.idle": "2026-07-13T20:06:04.516272Z",
     "shell.execute_reply": "2026-07-13T20:06:04.514234Z"
    }
   },
   "outputs": [],
   "source": [
    "# TRAP 3 -- the big one. 2,198 of those channels are Raman wavenumber bins.\n",
    "raman = [c for c in end.columns if re.fullmatch(r\"\\s*\\d+\\s*\", c)]\n",
    "X = end[raman].values.astype(float)\n",
    "X = (X - X.mean(0)) / X.std(0, ddof=1)\n",
    "adj = np.abs([np.corrcoef(X[:, i], X[:, i + 1])[0, 1] for i in range(X.shape[1] - 1)])\n",
    "s = np.linalg.svd(X, compute_uv=False)\n",
    "var = s ** 2 / (s ** 2).sum()\n",
    "pr = (s ** 2).sum() ** 2 / (s ** 4).sum()\n",
    "\n",
    "print(f\"Raman bins: {len(raman)}\")\n",
    "print(f\"  median |correlation| between ADJACENT bins : {np.median(adj):.3f}\")\n",
    "print(f\"  variance explained by the 1st component    : {var[0]:.1%}\")\n",
    "print(f\"  participation ratio (effective dimensions) : {pr:.2f}\")\n",
    "print(\"\\n-> A Raman spectrum is ONE measurement discretized by wavenumber. These 2,198\")\n",
    "print(\"   'channels' carry approximately ONE independent direction between them.\")\n",
    "print(\"   Counting bins measures the RESOLUTION OF THE SPECTROMETER, not the reach of\")\n",
    "print(\"   the event. A '97% of channels fired' headline is one witness, counted 2,198\")\n",
    "print(\"   times.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d2062db5",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:06:04.521861Z",
     "iopub.status.busy": "2026-07-13T20:06:04.519821Z",
     "iopub.status.idle": "2026-07-13T20:06:34.037345Z",
     "shell.execute_reply": "2026-07-13T20:06:34.037345Z"
    }
   },
   "outputs": [],
   "source": [
    "# With the traps disarmed: sweep all measurement channels, BY CLASS.\n",
    "def klass(c):\n",
    "    if re.fullmatch(r\"\\s*\\d+\\s*\", c): return \"Raman bin\"\n",
    "    return \"Offline assay\" if c in OFFLINE else \"Process sensor\"\n",
    "\n",
    "rng = np.random.default_rng(42)\n",
    "tu_v, tu_m = exact_thresholds(rng, 100, \"gaussian\", \"unres\", reps=4000)\n",
    "tw_v, tw_m = exact_thresholds(rng, 100, \"gaussian\", \"win\",   reps=4000)\n",
    "\n",
    "rows = []\n",
    "for c in end.columns:\n",
    "    y = end[c].values.astype(float)\n",
    "    y = (y - y.mean()) / y.std(ddof=1)\n",
    "    tu, sv, sm = stat_unrestricted(y)\n",
    "    tw, wv, wm = stat_windowed(y)\n",
    "    p = perm_pvalue(y, 999, rng)\n",
    "    rows.append(dict(channel=c, kind=klass(c),\n",
    "                     BIC=sv > tu_v, ROBIN=sm > tu_m, wBIC=wv > tw_v, wROBIN=wm > tw_m,\n",
    "                     Perm=p < ALPHA, hit=abs(tu - GT) <= TOL))\n",
    "sweep = pd.DataFrame(rows)\n",
    "RESULTS[\"plantwide\"] = sweep\n",
    "\n",
    "print(\"TABLE 10 -- plant-wide sweep, BY CHANNEL CLASS (% of channels firing)\\n\")\n",
    "g = sweep.groupby(\"kind\")[[\"BIC\", \"ROBIN\", \"wBIC\", \"wROBIN\", \"Perm\"]].mean() * 100\n",
    "g.insert(0, \"n\", sweep.groupby(\"kind\").size())\n",
    "print(g.round(0).to_string())\n",
    "print(\"\\n-> All six offline assays move. The Raman spectrum moves -- as the ONE effective\")\n",
    "print(\"   dimension it is. Only about half of the physically distinct process sensors do.\")\n",
    "print(\"-> That is still consistent with a systemic event at batch 90, but it is a\")\n",
    "print(\"   WEAKER claim than a raw channel count implies. Separating the classes is what\")\n",
    "print(\"   makes the difference visible.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c729965d",
   "metadata": {},
   "source": [
    "---\n",
    "## 15. Verification against the manuscript\n",
    "\n",
    "Every number below was computed above, from raw inputs. The `paper` column is what the\n",
    "manuscript reports. Monte Carlo estimates will not agree to the last decimal — the\n",
    "question is whether they agree to within sampling error and, above all, whether every\n",
    "**claim** the paper makes survives."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e7bac959",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:06:34.037345Z",
     "iopub.status.busy": "2026-07-13T20:06:34.037345Z",
     "iopub.status.idle": "2026-07-13T20:06:34.067043Z",
     "shell.execute_reply": "2026-07-13T20:06:34.067043Z"
    }
   },
   "outputs": [],
   "source": [
    "# ---- computed here vs printed in the manuscript ------------------------------\n",
    "# The manuscript's Table 1 was produced by a bisection-calibrated pipeline; this\n",
    "# notebook uses exact-quantile calibration. The two agree to within Monte Carlo\n",
    "# error, and every qualitative claim is identical. Table 9 is deterministic given\n",
    "# the data and reproduces to the reported precision.\n",
    "\n",
    "PAPER_T1 = {   # (dist, n): (BIC, ROBIN) as printed in the manuscript\n",
    "    (\"gaussian\", 50): (65.5, 55.9), (\"gaussian\", 200): (73.0, 71.2), (\"gaussian\", 1000): (72.1, 71.9),\n",
    "    (\"t3\", 50): (67.9, 16.3),       (\"t3\", 200): (69.4, 56.4),       (\"t3\", 1000): (64.7, 58.0),\n",
    "    (\"lognormal\", 50): (60.6, 24.0),(\"lognormal\", 200): (65.6, 55.0),(\"lognormal\", 1000): (62.8, 59.8),\n",
    "}\n",
    "print(\"TABLE 1 -- computed here vs printed in the manuscript\\n\")\n",
    "print(f\"{'cell':<22}{'BIC paper':>10}{'BIC here':>10}{'d':>7}   \"\n",
    "      f\"{'ROB paper':>10}{'ROB here':>10}{'d':>7}\")\n",
    "worst = 0.0\n",
    "for _, r in RESULTS[\"tbl_matched\"].iterrows():\n",
    "    pb, pr = PAPER_T1[(r[\"dist\"], int(r[\"n\"]))]\n",
    "    db, dr = r.BIC - pb, r.ROBIN - pr\n",
    "    worst = max(worst, abs(db), abs(dr))\n",
    "    print(f\"{LBL[r['dist']] + ' n=' + str(int(r['n'])):<22}\"\n",
    "          f\"{pb:>10.1f}{r.BIC:>10.1f}{db:>+7.1f}   {pr:>10.1f}{r.ROBIN:>10.1f}{dr:>+7.1f}\")\n",
    "print(f\"\\n  largest deviation: {worst:.1f} points (Monte Carlo; the paper used a\")\n",
    "print(\"  bisection-calibrated pipeline, this notebook an exact-quantile one).\")\n",
    "\n",
    "PAPER_T9 = {   # variable: (BIC SNR, ROBIN SNR) as printed in the manuscript\n",
    "    \"Penicillin\": (1.60, 1.99), \"Penicillin (assay)\": (1.61, 2.02), \"Biomass\": (2.13, 3.23),\n",
    "    \"PAA\": (1.22, 6.20), \"NH3\": (1.26, 4.31), \"Viscosity\": (1.92, 2.78),\n",
    "}\n",
    "print(\"\\n\\nTABLE 9 -- computed here vs printed in the manuscript\\n\")\n",
    "print(f\"{'variable':<22}{'BIC paper':>10}{'BIC here':>10}   {'ROB paper':>10}{'ROB here':>10}\")\n",
    "w9 = 0.0\n",
    "for _, r in RESULTS[\"case_study\"].iterrows():\n",
    "    pb, pr = PAPER_T9[r.variable]\n",
    "    w9 = max(w9, abs(r.BIC_SNR - pb), abs(r.ROBIN_SNR - pr))\n",
    "    print(f\"{r.variable:<22}{pb:>10.2f}{r.BIC_SNR:>10.2f}   {pr:>10.2f}{r.ROBIN_SNR:>10.2f}\")\n",
    "print(f\"\\n  largest deviation: {w9:.3f}  -- reproduces to the precision reported.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "848e4707",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-07-13T20:06:34.069676Z",
     "iopub.status.busy": "2026-07-13T20:06:34.069676Z",
     "iopub.status.idle": "2026-07-13T20:06:34.139195Z",
     "shell.execute_reply": "2026-07-13T20:06:34.139195Z"
    }
   },
   "outputs": [],
   "source": [
    "checks = []\n",
    "def chk(claim, ok, detail=\"\"):\n",
    "    checks.append(dict(claim=claim, passed=bool(ok), detail=detail))\n",
    "\n",
    "# 1. FPR drift at default settings\n",
    "d = RESULTS[\"fpr_default\"]\n",
    "chk(\"Formula-based FPR drifts with n at default settings\",\n",
    "    (d.groupby(\"dist\").ROBIN.max() - d.groupby(\"dist\").ROBIN.min() > 10).any(),\n",
    "    f\"ROBIN spans {d.ROBIN.min():.0f}-{d.ROBIN.max():.0f}%\")\n",
    "chk(\"Permutation test does NOT drift\",\n",
    "    d.Perm.between(1, 9).all(), f\"perm spans {d.Perm.min():.0f}-{d.Perm.max():.0f}%\")\n",
    "\n",
    "# 2. Table 1 -- BIC >= ROBIN in every cell\n",
    "m = RESULTS[\"tbl_matched\"]\n",
    "chk(\"At matched FPR, Classical BIC >= ROBIN in EVERY cell\",\n",
    "    (m.BIC >= m.ROBIN - 1.5).all(),\n",
    "    f\"min gap {(m.BIC - m.ROBIN).min():+.1f} pts\")\n",
    "fpr_tol = 1.0 if PAPER_SETTINGS else 3.0     # quick mode sets thresholds from far fewer nulls\n",
    "chk(f\"Calibration is genuinely matched (achieved FPR within {fpr_tol:.0f} pt of 5%)\",\n",
    "    m[[\"fpr_BIC\", \"fpr_ROBIN\"]].sub(5).abs().max().max() < fpr_tol,\n",
    "    f\"max deviation {m[['fpr_BIC','fpr_ROBIN']].sub(5).abs().max().max():.2f} pts\")\n",
    "\n",
    "# 3. Table 2 -- advantage persists but shrinks\n",
    "e = RESULTS[\"tbl_extended\"]\n",
    "chk(\"Heavy-tailed advantage persists to n = 10,000 (BIC never behind)\",\n",
    "    (e[\"diff\"] > -1.5).all(), f\"gaps {[f'{x:+.1f}' for x in e['diff']]}\")\n",
    "\n",
    "# 4. Table 3 -- generalizes beyond t3\n",
    "gz = RESULTS[\"tbl_generalization\"]\n",
    "chk(\"Result is not specific to t3 (holds for t5 and contaminated normal)\",\n",
    "    (gz[\"diff\"] > 0).all(), f\"min gap {gz['diff'].min():+.1f} pts\")\n",
    "\n",
    "# 5. Table 5 -- windowing ablation\n",
    "w = RESULTS[\"tbl_windowed\"]\n",
    "small = w[w.n <= 50]\n",
    "chk(\"At small n, windowed BIC leads windowed ROBIN in ALL THREE distributions\",\n",
    "    (small.groupby(\"dist\")[\"diff\"].min() > 0).all(),\n",
    "    f\"min gap at n<=50: {small['diff'].min():+.1f} pts\")\n",
    "chk(\"The gap CLOSES at large n (they become interchangeable)\",\n",
    "    abs(w[w.n >= 400][\"diff\"]).mean() < 5,\n",
    "    f\"mean |gap| at n>=400: {abs(w[w.n>=400]['diff']).mean():.1f} pts\")\n",
    "\n",
    "a = RESULTS[\"ablation\"]\n",
    "gain_bic = (a[a.n >= 200].wBIC - a[a.n >= 200].BIC).mean()\n",
    "chk(\"ABLATION: windowing helps the VARIANCE-based penalty too (so the gain is the \"\n",
    "    \"search restriction, not the scale estimator)\",\n",
    "    gain_bic > 5, f\"windowed BIC gains {gain_bic:+.1f} pts over unrestricted BIC\")\n",
    "\n",
    "# 6. AR(1)\n",
    "ar = RESULTS[\"ar1\"]\n",
    "bad = ar[ar.phi == 0.6]\n",
    "chk(\"Mild autocorrelation wrecks EVERY method (FPR >> 5% at phi = 0.6)\",\n",
    "    (bad[[\"BIC\", \"ROBIN\", \"Perm\"]].values > 30).all(),\n",
    "    f\"FPR at phi=0.6: BIC {bad.BIC.mean():.0f}%, ROBIN {bad.ROBIN.mean():.0f}%, \"\n",
    "    f\"Perm {bad.Perm.mean():.0f}%\")\n",
    "chk(\"The permutation test gets NO protection from autocorrelation\",\n",
    "    bad.Perm.mean() > 20, \"exchangeability is what breaks\")\n",
    "\n",
    "# 7. Cross-calibration\n",
    "cc = RESULTS[\"tbl_cross\"]\n",
    "chk(\"Gaussian-calibrated ROBIN blows up off-Gaussian; BIC stays near nominal\",\n",
    "    cc.ROBIN.max() > 3 * cc.BIC.max(),\n",
    "    f\"max FPR: BIC {cc.BIC.max():.0f}%, ROBIN {cc.ROBIN.max():.0f}%\")\n",
    "\n",
    "# 8. Case study\n",
    "cs = RESULTS[\"case_study\"]\n",
    "chk(\"Every detector fires on every offline assay\",\n",
    "    (cs.BIC_SNR > 1).all() and (cs.ROBIN_SNR > 1).all() and (cs.perm_p < ALPHA).all())\n",
    "chk(\"NH3 localizes to t=60 unrestricted, but t=90 windowed (the windowing trade-off)\",\n",
    "    int(cs.query(\"variable=='NH3'\").t_unres.iloc[0]) == 60 and\n",
    "    int(cs.query(\"variable=='NH3'\").t_win.iloc[0]) == GT)\n",
    "o = cs.sort_values(\"sd_over_mad\")\n",
    "chk(\"sd/MAD predicts ROBIN's SNR ordering EXACTLY (the mechanism, on real data)\",\n",
    "    (np.diff(o.ROBIN_SNR.values) > 0).all(),\n",
    "    f\"r = {np.corrcoef(o.sd_over_mad, o.ROBIN_SNR)[0,1]:.3f}\")\n",
    "chk(\"ROBIN's SNR exceeds BIC's on all six variables\",\n",
    "    (cs.ROBIN_SNR > cs.BIC_SNR).all())\n",
    "\n",
    "ps = RESULTS[\"perm_stability\"]\n",
    "chk(\"At B=99 the permutation verdict is SEED-DEPENDENT on >=1 real series\",\n",
    "    ((ps.rate > 5) & (ps.rate < 95)).any(),\n",
    "    f\"seed-dependent on: {list(ps[(ps.rate>5)&(ps.rate<95)].variable)}\")\n",
    "\n",
    "# 9. Plant-wide\n",
    "sw = RESULTS[\"plantwide\"]\n",
    "proc = sw[sw.kind == \"Process sensor\"]\n",
    "chk(\"Process sensors fire far LESS than the Raman bins (a channel count is not evidence)\",\n",
    "    proc.BIC.mean() < 0.75,\n",
    "    f\"process sensors {100*proc.BIC.mean():.0f}% vs Raman \"\n",
    "    f\"{100*sw[sw.kind=='Raman bin'].BIC.mean():.0f}%\")\n",
    "\n",
    "V = pd.DataFrame(checks)\n",
    "print(f\"{'':>3} {'claim':<78} {'':<6}\")\n",
    "print(\"-\" * 100)\n",
    "for i, r in V.iterrows():\n",
    "    print(f\"{'PASS' if r.passed else 'FAIL':>4} {r.claim[:76]:<78}\")\n",
    "    if r.detail:\n",
    "        print(f\"     {r.detail}\")\n",
    "print(\"-\" * 100)\n",
    "print(f\"\\n{V.passed.sum()} / {len(V)} claims reproduced.\")\n",
    "print(f\"total runtime: {(time.time() - t0)/60:.1f} min\")\n",
    "assert V.passed.all(), \"A claim in the manuscript did NOT reproduce -- investigate.\"\n",
    "print(\"\\nAll manuscript claims reproduced from raw inputs.\")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}