#!/usr/bin/env python3
"""p8v2 visualization renderer.

Implements tests/pprog/p8v2-viz-spec.md in full: the model of S 4, the geometry
of S 5, the encoding of S 6, the cameras of S 7, the five animation phases of
S 8, the outputs of S 9, the statistics of S 10, and the self-checks of S 12.

Takes no arguments (S 2.3).  Writes everything to ./out/.
Standard library + numpy + matplotlib only (S 13).
"""

import base64
import hashlib
import math
import os
import re
import sys

import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from PIL import Image

# ---------------------------------------------------------------- S 2 fixture

B = (b'<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.3/" '
     b'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLoca')

ANCHORS = {0: '<', 10: ' ', 17: '"', 18: 'h', 57: '/', 58: '"', 60: 'x',
           70: '"', 71: 'h', 111: 'e', 112: '"', 114: 'x', 127: 'a'}

# ------------------------------------------------------------- S 3 parameters

N        = 128
K        = 8
L_WIN    = 32
L_CHAIN  = 128
BUDGET_K = 256
S_ITERS  = 24
AND_THETA = 0.05

W_FWD, W_BWD, W_TOK = 1.0, 0.5, 2.0

ARTIST_CAP = 4000
LEFT_COL = 0.215                  # figure fraction reserved for legend + panel

# ------------------------------------------------------------- S 6 palette

DARK = dict(surface="#1a1a19", grid="#2c2c2a", muted="#898781", ink="#ffffff",
            c1="#3987e5", c2="#d95926", c3="#199e70", warn="#fab219",
            lo="#184f95", hi="#cde2fb")
LIGHT = dict(surface="#fcfcfb", grid="#e1e0d9", muted="#898781", ink="#0b0b0b",
             c1="#2a78d6", c2="#eb6834", c3="#1baf7a", warn="#fab219",
             lo="#86b6ef", hi="#0d366b")

PLACEHOLDERS = [
    ("strengths",         "log-scaled occurrence counts",        "stands in for LSA"),
    ("keep/prune score",  "occurrence count, budget never binds", "stands in for omega"),
    ("composite predict", "longest context wins",                "rejected by the design"),
    ("trace membership",  "composite mispredictions",            "belongs to omega, unspecified"),
    ("settling",          "iterative relaxation, 24 iters",      "stands in for f"),
]

# --------------------------------------------------------------- S 7 cameras

CAMERAS = {                       # (elevation, azimuth) in matplotlib convention
    "iso":    (22.0, -60.0),
    "chain":  (0.0,  -90.0),
    "levels": (8.0,    0.0),
    "top":    (88.0, -90.0),
}
XLIM_FULL = (-4.0, 142.0)
XLIM_ISO  = (88.0, 142.0)
YLIM      = (-8.0, 264.0)
ZLIM      = (-2.0, 62.0)
YS        = 0.12                  # display scaling of the value axis


def hexrgb(h):
    h = h.lstrip("#")
    return tuple(int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4))


def ramp(pal, u):
    """S 6.4 magnitude ramp, low -> high, u in [0,1]."""
    a, b = hexrgb(pal["lo"]), hexrgb(pal["hi"])
    u = min(max(u, 0.0), 1.0)
    return tuple(a[i] + (b[i] - a[i]) * u for i in range(3))


# =============================================================== S 4 the model

class Model:
    """Everything of S 4, computed once, deterministically, no randomness."""

    def __init__(self):
        assert len(B) == N, "S 12.1: len(B) == 128"
        for i, c in ANCHORS.items():
            assert chr(B[i]) == c, "S 2.2 anchor %d: %r != %r" % (i, chr(B[i]), c)

        # --- S 4.3 k0, the marginal model
        self.count0 = [0] * 256
        for v in B:
            self.count0[v] += 1
        self.k0 = max(range(256), key=lambda v: (self.count0[v], -v))
        self.distinct = sum(1 for c in self.count0 if c)

        # --- S 4.3 k1, the one-byte model (argmax only, ties to lowest byte)
        self.count1 = {}
        for t in range(1, N):
            self.count1.setdefault(B[t - 1], [0] * 256)[B[t]] += 1
        self.k1, self.k1_ties = {}, 0
        for a in sorted(self.count1):
            row = self.count1[a]
            best = max(range(256), key=lambda b: (row[b], -b))
            self.k1[a] = best
            if sum(1 for b in range(256) if row[b] == row[best]) > 1:
                self.k1_ties += 1

        # --- S 4.4 tokens, first-occurrence ordered, never reordered
        self.lex, self.occ, self.follow, self.cons, self.first_t = {}, {}, {}, {}, {}
        for k in range(2, K + 1):
            self.lex[k], self.occ[k], self.follow[k], self.first_t[k] = [], {}, {}, {}
            for t in range(k, N):
                w = bytes(B[t - k:t])
                if w not in self.occ[k]:
                    self.lex[k].append(w)
                    self.occ[k][w] = 0
                    self.follow[k][w] = [0] * 256
                    self.first_t[k][w] = t
                self.occ[k][w] += 1
                self.follow[k][w][B[t]] += 1
            self.cons[k] = {}
            for w in self.lex[k]:
                row = self.follow[k][w]
                self.cons[k][w] = max(range(256), key=lambda c: (row[c], -c))
            assert len(self.lex[k]) <= BUDGET_K, "S 3: budget must not bind here"

        # --- S 5.3 y_tok, stable for the whole run
        self.y_tok = {}
        for k in range(2, K + 1):
            by_last = {}
            for w in self.lex[k]:
                by_last.setdefault(w[k - 1], []).append(w)
            for last, group in by_last.items():
                n = len(group)
                for r, w in enumerate(group):
                    self.y_tok[(k, w)] = last + 0.8 * (r + 0.5) / n - 0.4

        # --- S 4.7 the two traces
        self.trace_k1 = [t for t in range(N) if self.predict(t, 1)[1] != B[t]]
        self.trace_composite = [t for t in range(N) if self.predict(t, K)[1] != B[t]]
        self.model_bytes_naive = sum((k + 1) * len(self.lex[k]) for k in range(2, K + 1))

        # --- S 4.8 the backwards k=1 table:
        #     bwd[c][v] = count1[v][c] / sum_x count1[x][c]
        self.bwd = np.zeros((256, 256))
        col = np.zeros(256)
        for a, row in self.count1.items():
            for c in range(256):
                col[c] += row[c]
        for c in range(256):
            if col[c] == 0:
                continue
            for v, row in self.count1.items():
                if row[c]:
                    self.bwd[c, v] = row[c] / col[c]

        # forward k=1 as a 0/1 matrix: fwd[a][v] = 1 iff k1[a] == v
        self.fwd = np.zeros((256, 256))
        for a, v in self.k1.items():
            self.fwd[a, v] = 1.0

        self.marginal = np.array(self.count0, dtype=float) / N

    # --- S 4.7 placeholder composite prediction
    def predict(self, t, maxk=K):
        for k in range(min(maxk, K), 1, -1):
            if t >= k:
                w = bytes(B[t - k:t])
                if w in self.occ[k]:
                    return k, self.cons[k][w]
        if t >= 1 and B[t - 1] in self.k1:
            return 1, self.k1[B[t - 1]]
        return 0, self.k0

    # --- S 4.5 placeholder strengths
    def tok_strength(self, k, w):
        mx = max(self.occ[k].values())
        return int(round(255 * math.log(1 + self.occ[k][w]) / math.log(1 + mx)))


# ---------------------------------------------------------- S 4.8 settling

def settle(M, clamped, open_cells, present, iters=S_ITERS):
    """Plain iterative relaxation standing in for f (PROVISIONAL).

    clamped: {lag: byte}; open_cells: [lag]; present: set of lags that exist.

    Yields `iters` frames.  Frame i is the state after i relaxation steps,
    paired with the tokens firing in that state -- i.e. the ones that drive the
    next step.  Frame 0 is therefore the initial state, which is what makes the
    settling visible at all: this fixture converges after one step.
    """
    a = np.zeros((L_WIN, 256))
    for i in range(L_WIN):
        if i in clamped:
            a[i, clamped[i]] = 1.0
        elif i in open_cells:
            a[i] = M.marginal.copy()

    lexarr = {k: np.array([[w[j] for j in range(k)] for w in M.lex[k]], dtype=int)
              for k in range(2, K + 1)}
    consarr = {k: np.array([M.cons[k][w] for w in M.lex[k]], dtype=int)
               for k in range(2, K + 1)}

    for step in range(iters):
        fires = []                     # (k, o, index, fire)
        m = np.zeros((L_WIN, 256))
        for k in range(2, K + 1):
            Wk, Ck = lexarr[k], consarr[k]
            for o in range(1, L_WIN - k + 1):
                lags = [o + k - 1 - j for j in range(k)]
                if any(l not in present for l in lags):
                    continue
                f = np.ones(len(Wk))
                for j in range(k):
                    f = f * a[o + k - 1 - j, Wk[:, j]]
                hit = np.nonzero(f >= AND_THETA)[0]
                if hit.size == 0:
                    continue
                i = o - 1
                if i in open_cells:
                    np.add.at(m[i], Ck[hit], W_TOK * f[hit])
                for idx in hit:
                    fires.append((k, o, int(idx), float(f[idx])))
        yield a.copy(), fires
        if step == iters - 1:
            return
        for i in open_cells:
            if i + 1 in present:
                m[i] += W_FWD * (a[i + 1] @ M.fwd)
            if i - 1 >= 0 and i - 1 in present:
                m[i] += W_BWD * (a[i - 1] @ M.bwd)
            s = m[i].sum()
            a[i] = m[i] / s if s > 0 else M.marginal.copy()


# ================================================================ S 5 geometry

def X(i):
    return L_CHAIN - 1 - i


class Scene:
    """A frame's worth of marks, in the draw order of S 13."""

    ORDER = ["grid", "tail", "memchain", "bytes", "k0", "k1",
             "and", "tok", "cons", "ring", "label"]

    def __init__(self):
        self.nodes = []     # (x, y, z, color, radius, edgecolor, edgelw, layer)
        self.arrows = []    # (x0,y0,z0, x1,y1,z1, color, lw, ls, head, layer)
        self.texts = []     # (x, y, z, s, color, size, layer)
        self.dropped = 0

    def node(self, x, y, z, color, r, ec="none", elw=0.0, layer="bytes"):
        self.nodes.append((x, y, z, color, r, ec, elw, layer))

    def arrow(self, p, q, color, lw=0.8, ls="solid", head=True, layer="memchain"):
        self.arrows.append((p[0], p[1], p[2], q[0], q[1], q[2],
                            color, lw, ls, head, layer))

    def text(self, x, y, z, s, color, size=7.0, layer="label"):
        self.texts.append((x, y, z, s, color, size, layer))

    def count(self):
        return len(self.nodes) + len(self.arrows)


# --------------------------------------------------------- scene constructors

def add_chain(sc, pal, bytes_at, lit_lags, rings, tail=True):
    """bytes_at: {lag: byte}.  lit_lags: lags drawn as live byte events."""
    lags = sorted(bytes_at)
    for i in lags:
        v = bytes_at[i]
        inert = i >= L_WIN
        if inert and not tail:
            continue
        if not inert:
            col = ramp(pal, 1.0)
            r = 5.0
            sc.node(X(i), v, 0, col, r, pal["surface"], 0.9, "bytes")
        else:
            fade = min((i - L_WIN) / 4.0, 1.0)     # S 8.1 four-frame fade
            col = tuple(np.array(ramp(pal, 1.0)) * (1 - fade)
                        + np.array(hexrgb(pal["grid"])) * fade)
            sc.node(X(i), v, 0, col, 2.6 if fade >= 1 else 4.0, "none", 0.0, "tail")
    for i in lags:
        j = i + 1
        if j in bytes_at:
            layer = "tail" if (i >= L_WIN and j >= L_WIN) else "memchain"
            if layer == "tail" and not tail:
                continue
            # content flows right -> left: from X(i) to X(i)-1 == X(j)
            sc.arrow((X(i), bytes_at[i], 0), (X(j), bytes_at[i], 0),
                     pal["grid"], 0.7, "solid", True, layer)
    for i, kinds in rings.items():
        if i not in bytes_at:
            continue
        v = bytes_at[i]
        for n, kind in enumerate(kinds):
            col = pal["c2"] if kind == "k1" else pal["c3"]
            sc.node(X(i), v, 0, "none", 6.4 + 2.4 * n, col, 1.0, "ring")


def add_k0(sc, pal, counts, label=True):
    mx = max(counts) if max(counts) else 1
    for v in range(256):
        if counts[v] == 0:
            continue
        sc.arrow((128, v, 0), (128 + 4.0 * counts[v] / mx, v, 0),
                 pal["c2"], 1.2, "solid", False, "k0")
    if label:
        k0 = max(range(256), key=lambda v: (counts[v], -v))
        sc.node(128 + 4.0, k0, 0, pal["c2"], 4.2, pal["surface"], 0.8, "k0")
        sc.text(133.0, k0 - 30, 0, "k0 = %s" % pretty(k0), pal["c2"], 7.0)


def add_transducers(sc, pal, live_byte):
    sc.node(136, 160, 0, pal["muted"], 3.0, "none", 0.0, "label")
    sc.node(136, 96, 0, pal["muted"], 3.0, "none", 0.0, "label")
    sc.text(137.5, 186, 0, "stdin", pal["muted"], 6.0)
    sc.text(137.5, 70, 0, "stdout", pal["muted"], 6.0)
    if live_byte is not None:
        sc.arrow((136, 160, 0), (127.6, live_byte, 0), pal["muted"], 0.7,
                 "solid", True, "label")
        sc.arrow((127.6, live_byte, 0), (136, 96, 0), pal["muted"], 0.7,
                 "solid", True, "label")


def add_token(sc, pal, M, k, w, o, strength=None, flash=False, dashed=False,
              shrink=1.0):
    """S 5.3/S 5.4: one token instance at level k, offset o."""
    nx = X(0) - o - (k - 1) / 2.0
    ny = M.y_tok[(k, w)]
    nz = 6 * k
    s = M.tok_strength(k, w) if strength is None else strength
    r = (2.2 + 2.8 * math.sqrt(s / 255.0)) * shrink
    if flash:
        r *= 1.6
    sc.node(nx, ny, nz, pal["c3"], r, pal["surface"] if flash else "none",
            0.9 if flash else 0.0, "tok")
    ls = "dashed" if dashed else "solid"
    for j in range(k):
        mx = X(0) - o - k + 1 + j
        sc.arrow((mx, w[j], 0), (nx, ny, nz), pal["c3"], 0.45, ls, False, "and")
    cx, cv = X(0) + 1 - o, M.cons[k][w]
    sc.arrow((nx, ny, nz), (cx, cv, 0), pal["c3"], 0.75, ls, True, "cons")
    # S 12.5: the pattern's antecedent span ends left of its consequent
    assert (X(0) - o) < cx
    return nx, ny, nz


def add_k1_edge(sc, pal, M, a, o=1):
    if a not in M.k1:
        return
    ax, cx = X(0) - o, X(0) + 1 - o
    assert ax < cx                                  # S 12.5
    sc.arrow((ax, a, 0), (cx, M.k1[a], 0), pal["c2"], 1.1, "solid", True, "k1")


# ================================================================ S 6 drawing

def draw(scene, view, pal, size, caption, legend=True, provisional_frame=False,
         subcaption=None, path=None):
    w_px, h_px = size
    dpi = 150 if w_px >= 1900 else 100
    fig = plt.figure(figsize=(w_px / dpi, h_px / dpi), dpi=dpi,
                     facecolor=pal["surface"])
    scale = (w_px / dpi) / 9.6
    ax = fig.add_subplot(111, projection="3d", facecolor=pal["surface"])
    ax.set_position([LEFT_COL + 0.005, 0.105, 0.965 - LEFT_COL, 0.795])
    ax.computed_zorder = False

    xlim = XLIM_ISO if view == "iso" else XLIM_FULL
    ax.set_xlim(*xlim)
    ax.set_ylim(YLIM[0] * YS, YLIM[1] * YS)
    ax.set_zlim(*ZLIM)
    ax.set_box_aspect((xlim[1] - xlim[0],
                       (YLIM[1] - YLIM[0]) * YS,
                       ZLIM[1] - ZLIM[0]), zoom=1.18)
    elev, azim = view if isinstance(view, tuple) else CAMERAS[view]
    ax.view_init(elev=elev, azim=azim)

    for axis in (ax.xaxis, ax.yaxis, ax.zaxis):
        axis.set_pane_color(hexrgb(pal["surface"]) + (1.0,))
        axis._axinfo["grid"]["color"] = pal["grid"]
        axis._axinfo["grid"]["linewidth"] = 0.4
        axis.line.set_color(pal["grid"])
        axis.set_tick_params(colors=pal["muted"], labelsize=5.5 * scale)

    ax.set_xlabel("x — memory chain (dataset order) →", color=pal["muted"],
                  fontsize=6.5 * scale, labelpad=2 * scale)
    ax.set_ylabel("y — byte value", color=pal["muted"], fontsize=6.5 * scale,
                  labelpad=2 * scale)
    ax.set_zlabel("z — level k", color=pal["muted"], fontsize=6.5 * scale,
                  labelpad=2 * scale)
    ax.set_yticks([v * YS for v in (0, 64, 128, 192, 255)])
    ax.set_yticklabels(["0", "64", "128", "192", "255"])
    ax.set_zticks([0, 6] + [6 * k for k in range(2, K + 1)])
    ax.set_zticklabels(["z=0", "(empty)"] + ["k=%d" % k for k in range(2, K + 1)])

    layer_z = {name: 10 * n for n, name in enumerate(Scene.ORDER)}

    # arrows, grouped by style
    groups = {}
    for (x0, y0, z0, x1, y1, z1, col, lw, ls, head, layer) in scene.arrows:
        groups.setdefault((col, lw, ls, head, layer), []).append(
            (x0, y0 * YS, z0, (x1 - x0), (y1 - y0) * YS, (z1 - z0)))
    for (col, lw, ls, head, layer), items in groups.items():
        arr = np.array(items)
        ax.quiver(arr[:, 0], arr[:, 1], arr[:, 2], arr[:, 3], arr[:, 4], arr[:, 5],
                  color=col, linewidth=lw * scale, linestyles=ls,
                  arrow_length_ratio=0.32 if head else 0.0,
                  normalize=False, zorder=layer_z[layer])

    # nodes, grouped by edge style
    ngroups = {}
    for (x, y, z, col, r, ec, elw, layer) in scene.nodes:
        ngroups.setdefault((ec, elw, layer), ([], [], [], [], []))
        g = ngroups[(ec, elw, layer)]
        g[0].append(x); g[1].append(y * YS); g[2].append(z)
        g[3].append(col); g[4].append((2 * r * scale) ** 2)
    for (ec, elw, layer), g in ngroups.items():
        ax.scatter(g[0], g[1], g[2], s=g[4], c=g[3], edgecolors=ec,
                   linewidths=elw * scale, depthshade=False,
                   zorder=layer_z[layer])

    for (x, y, z, s, col, fs, layer) in scene.texts:
        ax.text(x, y * YS, z, s, color=col, fontsize=fs * scale,
                zorder=layer_z["label"], ha="left", va="center")

    # ---- S 6.6 standing annotations, legend, panel, caption
    fig.text(LEFT_COL, 0.982, caption, color=pal["ink"], fontsize=10.0 * scale,
             ha="left", va="top", family="sans-serif")
    if subcaption:
        fig.text(LEFT_COL, 0.940, subcaption, color=pal["muted"],
                 fontsize=6.8 * scale, ha="left", va="top", linespacing=1.5)

    if legend:
        rows = [("byte events — the byte ES,", "every mem_cell_i",
                 pal["c1"], pal["c1"]),
                ("k-models — the k0 profile,", "the k1 edges",
                 pal["c2"], pal["c2"]),
                ("tokens — nodes, AND-gate", "inputs, consequents",
                 pal["c3"], pal["c3"]),
                ("memchain — inert scaffolding,", "not a series",
                 pal["grid"], pal["muted"]),
                ("PROVISIONAL — dashed,", "see the panel below",
                 pal["warn"], pal["warn"])]
        y = 0.975
        for a, b, swatch, col in rows:
            fig.text(0.012, y, "■", color=swatch, fontsize=6.4 * scale,
                     va="top", family="sans-serif")
            fig.text(0.030, y, a, color=col, fontsize=6.4 * scale,
                     va="top", family="sans-serif")
            fig.text(0.030, y - 0.030, b, color=col, fontsize=6.4 * scale,
                     va="top", family="sans-serif")
            y -= 0.072

    fig.text(0.012, 0.600, "←  content — bytes age leftward\n"
                           "prediction — antecedent left\n"
                           "of consequent  →",
             color=pal["ink"], fontsize=6.4 * scale, va="top", linespacing=1.5)

    lines = ["PROVISIONAL — f and ω are not specified", ""]
    for name, what, why in PLACEHOLDERS:
        lines.append("%s\n   = %s\n   (%s)" % (name, what, why))
    fig.text(0.012, 0.500, "\n".join(lines), color=pal["warn"],
             fontsize=5.2 * scale, va="top", family="monospace", linespacing=1.35)

    if scene.dropped:
        fig.text(0.012, 0.175, "%d token instances dropped at the\n"
                 "artist cap of %d — lowest ω-score\nfirst (§13), never silently"
                 % (scene.dropped, ARTIST_CAP), color=pal["warn"],
                 fontsize=6.0 * scale, va="top", linespacing=1.5)

    fig.text(0.012, 0.085, "z = 6 is empty: k = 0 and k = 1 are\ninterior to "
                           "the byte ES, and are drawn\nin the z = 0 plane.",
             color=pal["muted"], fontsize=6.0 * scale, va="top",
             linespacing=1.5)

    if provisional_frame:
        fig.add_artist(Rectangle((0.004, 0.004), 0.992, 0.992,
                                 transform=fig.transFigure, fill=False,
                                 edgecolor=pal["warn"], linewidth=2.0 * scale,
                                 linestyle=(0, (4, 3)), zorder=1000))

    if path:
        fig.savefig(path, facecolor=pal["surface"], dpi=dpi)
    plt.close(fig)
    return path


def pretty(v):
    c = chr(v)
    return "0x%02x '%s'" % (v, c) if 0x20 <= v <= 0x7e else "0x%02x" % v


def ch(v):
    return chr(v) if 0x20 <= v <= 0x7e else "."


# ================================================================ S 8 phases

def rings_upto(M, t):
    """Trace rings accumulated through step t, keyed by lag."""
    r = {}
    for p in M.trace_k1:
        if p <= t:
            r.setdefault(t - p, []).append("k1")
    for p in M.trace_composite:
        if p <= t:
            r.setdefault(t - p, []).append("comp")
    return r


def phase_a_scene(M, pal, t):
    sc = Scene()
    bytes_at = {i: B[t - i] for i in range(0, min(t, L_CHAIN - 1) + 1)}
    add_chain(sc, pal, bytes_at, None, rings_upto(M, t), tail=True)
    counts = [0] * 256
    for p in range(t + 1):
        counts[B[p]] += 1
    add_k0(sc, pal, counts)
    add_transducers(sc, pal, B[t])
    if t >= 1:
        add_k1_edge(sc, pal, M, B[t - 1])
    fired = None
    for k in range(2, K + 1):
        if t < k:
            continue
        w = bytes(B[t - k:t])
        flash = M.first_t[k][w] == t
        nx, ny, nz = add_token(sc, pal, M, k, w, 1, flash=flash)
        if flash:
            fired = (k, w, nx, ny, nz)
    lvl, pred = M.predict(t)
    if fired:
        k, w, nx, ny, nz = fired
        sc.text(nx + 1.6, ny + 14, nz, "new at k=%d" % k, pal["c3"], 6.4)
    sc.text(X(0) + 0.4, B[t] + 32, 0, "e_0 = %s" % pretty(B[t]), pal["ink"], 7.2)
    cap = ("Phase A — scan   t = %3d   byte = %s   predicted %s from k = %d%s"
           % (t, pretty(B[t]), pretty(pred), lvl,
              "   SURPRISE" if pred != B[t] else ""))
    return sc, cap


def phase_b_scene(M, pal):
    """Every kept pattern at every level, at its first-occurrence offset."""
    sc = Scene()
    bytes_at = {i: B[X(i)] for i in range(L_CHAIN)}
    rings = {}
    for p in M.trace_k1:
        rings.setdefault(X(p), []).append("k1")
    for p in M.trace_composite:
        rings.setdefault(X(p), []).append("comp")
    add_chain(sc, pal, bytes_at, None, rings, tail=True)
    add_k0(sc, pal, list(M.count0))
    add_k1_edge(sc, pal, M, B[N - 2])

    # S 4.1 / S 5.3 the top-level singleton and its faint edges to the trace
    sc.node(64, 128, 60, pal["muted"], 5.0, pal["surface"], 0.8, "tok")
    sc.text(66, 128, 60, "enwik9_128", pal["muted"], 7.0)
    for p in M.trace_composite:
        sc.arrow((64, 128, 60), (p, B[p], 0), pal["muted"], 0.4, "solid",
                 False, "ring")

    # S 13: rank token instances by omega's placeholder score (S 4.6) and apply
    # the artist cap, dropping the lowest-scoring first and reporting the count.
    inst = [(M.occ[k][w], -r, k, w, N - M.first_t[k][w])
            for k in range(2, K + 1) for r, w in enumerate(M.lex[k])]
    inst.sort(key=lambda z: (z[0], z[1]))
    cost = lambda k: k + 2
    total = sc.count() + sum(cost(i[2]) for i in inst)
    while total > ARTIST_CAP and inst:
        total -= cost(inst.pop(0)[2])
        sc.dropped += 1
    for _, _, k, w, o in inst:
        add_token(sc, pal, M, k, w, o)

    cap = ("Phase B — the learned model at rest   %d patterns, k = 2…8"
           % sum(len(M.lex[k]) for k in range(2, K + 1)))
    return sc, cap


PHASE_C_T = 22
PHASE_C_CLAMPED_IDX = (18, 19, 22)
PHASE_C_OPEN_IDX = (20, 21)


def phase_c_setup(M):
    t = PHASE_C_T
    present = set(i for i in range(L_WIN) if t - i >= 0)
    open_cells = [t - i for i in PHASE_C_OPEN_IDX]
    clamped = {i: B[t - i] for i in present if (t - i) not in PHASE_C_OPEN_IDX}
    return t, present, sorted(open_cells), clamped


def phase_c_scene(M, pal, a, fires, it):
    t, present, open_cells, clamped = phase_c_setup(M)
    sc = Scene()
    bytes_at = {i: clamped[i] for i in clamped}
    add_chain(sc, pal, bytes_at, None, {}, tail=False)
    for i in sorted(clamped):
        j = i + 1
        if j in present:
            sc.arrow((X(i), clamped[i], 0), (X(j), clamped[i], 0),
                     pal["grid"], 0.7, "solid", True, "memchain")
    for n, i in enumerate(open_cells):
        top = float(a[i].max())
        for v in np.nonzero(a[i] > 0.004)[0]:
            u = float(a[i, v]) / top if top else 0.0
            sc.node(X(i), int(v), 0, ramp(pal, u), 1.6 + 3.4 * math.sqrt(u),
                    "none", 0.0, "bytes")
        am = int(np.argmax(a[i]))
        sc.node(X(i), am, 0, ramp(pal, 1.0), 5.0, pal["warn"], 1.0, "ring")
        sc.text(X(i) + 0.6, am + (62 if n else -62), 3.0 if n else 0.0,
                "open: lag %d = index %d → %s"
                % (i, PHASE_C_T - i, pretty(am)), pal["warn"], 6.4)

    shown = sorted(fires, key=lambda f: -f[3])[:220]
    for (k, o, idx, f) in shown:
        w = M.lex[k][idx]
        add_token(sc, pal, M, k, w, o, strength=int(255 * min(f, 1.0)),
                  dashed=True, shrink=0.62)
    s = "".join(ch(int(np.argmax(a[t - p]))) if p in PHASE_C_OPEN_IDX else ch(B[p])
                for p in range(18, 23))
    cap = ("Phase C — settling (PROVISIONAL, §4.8)   iteration %2d/%d   argmax = %r"
           % (it, S_ITERS - 1, s))
    sub = ("%d token instances above AND_THETA = %.2f across all offsets"
           " (showing %d)" % (len(fires), AND_THETA, len(shown)))
    return sc, cap, sub, s


def phase_d_scene(M, pal):
    sc = Scene()
    bytes_at = {i: B[X(i)] for i in range(L_CHAIN)}
    rings = {}
    for p in M.trace_k1:
        rings.setdefault(X(p), []).append("k1")
    for p in M.trace_composite:
        rings.setdefault(X(p), []).append("comp")
    add_chain(sc, pal, bytes_at, None, rings, tail=True)
    cap = ("Phase D — trace comparison   |trace_k1| = %d    "
           "|trace_composite| = %d    model_bytes_naive = %d"
           % (len(M.trace_k1), len(M.trace_composite), M.model_bytes_naive))
    sub = ("NOT a compression win: the model spends %d bytes of stored patterns "
           "to remove %d trace entries from a %d-byte input.\n"
           "At N = 128 almost every 8-gram is unique (%d distinct of %d); the "
           "placeholder rule is reciting the fixture back."
           % (M.model_bytes_naive, len(M.trace_k1) - len(M.trace_composite), N,
              len(M.lex[8]), N - 8))
    return sc, cap, sub


# ================================================================== S 9 output

GIF_SIZE = (960, 540)
STILL_SIZE = (1920, 1080)


def ensure(p):
    os.makedirs(p, exist_ok=True)
    return p


def shrink(path):
    """Quantize to a 256-colour adaptive palette if that is smaller."""
    try:
        im = Image.open(path).convert("RGB")
        q = im.quantize(colors=256, method=Image.MEDIANCUT, dither=Image.NONE)
        tmp = path + ".q"
        q.save(tmp, optimize=True)
        if os.path.getsize(tmp) < os.path.getsize(path):
            os.replace(tmp, path)
        else:
            os.remove(tmp)
    except Exception:
        pass


def make_gif(frames, out, ms):
    ims = []
    for f in frames:
        im = Image.open(f).convert("RGB")
        if im.size != GIF_SIZE:
            im = im.resize(GIF_SIZE, Image.LANCZOS)
        ims.append(im.quantize(colors=128, method=Image.MEDIANCUT,
                               dither=Image.NONE))
    ims[0].save(out, save_all=True, append_images=ims[1:], duration=ms,
                loop=0, optimize=True, disposal=2)


def main():
    out = ensure("out")
    frames_dir = ensure(os.path.join(out, "frames"))
    light_dir = ensure(os.path.join(out, "light"))

    M = Model()
    print("model built: k0=%s  k1_entries=%d  lex=%s"
          % (pretty(M.k0), len(M.k1), [len(M.lex[k]) for k in range(2, K + 1)]))
    check_acceptance(M)

    # ---------------- Phase A
    scan_frames = []
    for t in range(N):
        sc, cap = phase_a_scene(M, DARK, t)
        p = os.path.join(frames_dir, "scan-%03d.png" % t)
        draw(sc, "iso", DARK, GIF_SIZE, cap, path=p)
        shrink(p)
        scan_frames.append(p)
        if t % 16 == 0:
            print("  scan %d/%d" % (t, N))

    # ---------------- Phase B
    scb, capb = phase_b_scene(M, DARK)
    pb = os.path.join(frames_dir, "model-iso.png")
    draw(scb, "iso", DARK, GIF_SIZE, capb, path=pb)
    shrink(pb)
    print("  phase B: %d marks, %d token instances dropped"
          % (scb.count(), scb.dropped))

    # ---------------- Phase C
    t_c, present, open_cells, clamped = phase_c_setup(M)
    settle_frames, final_argmax = [], ""
    for it, (a, fires) in enumerate(settle(M, clamped, open_cells, present)):
        sc, cap, sub, s = phase_c_scene(M, DARK, a, fires, it)
        p = os.path.join(frames_dir, "settle-%02d.png" % it)
        draw(sc, "levels", DARK, GIF_SIZE, cap, subcaption=sub,
             provisional_frame=True, path=p)
        shrink(p)
        settle_frames.append(p)
        final_argmax = s
    print("  phase C final argmax = %r" % final_argmax)

    # ---------------- Phase D
    scd, capd, subd = phase_d_scene(M, DARK)

    # ---------------- Phase E
    orbit_frames = []
    for n in range(72):
        az = -60.0 + 5.0 * n
        p = os.path.join(frames_dir, "orbit-%02d.png" % n)
        draw(scb, (22.0, az), DARK, GIF_SIZE,
             capb + "   azimuth %+.0f°" % az, path=p)
        shrink(p)
        orbit_frames.append(p)
        if n % 12 == 0:
            print("  orbit %d/72" % n)

    # ---------------- GIFs
    make_gif(scan_frames + [pb] * 24, os.path.join(out, "p8v2-scan.gif"), 83)
    make_gif(settle_frames, os.path.join(out, "p8v2-settle.gif"), 83)
    make_gif(orbit_frames, os.path.join(out, "p8v2-orbit.gif"), 50)
    print("  gifs written")

    # ---------------- stills, dark and light
    stills = []

    def still(name, builder, cap_extra=""):
        for pal, d in ((DARK, out), (LIGHT, light_dir)):
            p = os.path.join(d, name)
            builder(pal, p)
            shrink(p)
        stills.append(name)

    still("still-01-first-window.png", lambda pal, p: _a(M, pal, 8, p))
    still("still-02-http.png", lambda pal, p: _a(M, pal, 22, p))
    still("still-03-scan-end.png", lambda pal, p: _a(M, pal, 127, p))
    still("still-04-model.png", lambda pal, p: _b(M, pal, "iso", p))
    settle_states = list(settle(M, clamped, open_cells, present))
    still("still-05-settle-00.png", lambda pal, p: _c(M, pal, settle_states, 0, p))
    still("still-06-settle-04.png", lambda pal, p: _c(M, pal, settle_states, 4, p))
    still("still-07-settle-23.png", lambda pal, p: _c(M, pal, settle_states, 23, p))
    still("still-08-levels.png", lambda pal, p: _b(M, pal, "levels", p))
    still("still-09-top.png", lambda pal, p: _b(M, pal, "top", p))
    still("still-10-traces.png", lambda pal, p: _d(M, pal, p))
    print("  stills written")

    # ---------------- S 10 stats.txt
    stats = build_stats(M, final_argmax, scb.dropped)
    with open(os.path.join(out, "stats.txt"), "w") as f:
        f.write(stats)

    # ---------------- S 9.1 viewer
    html = build_html(out, light_dir, stills, stats, M, scb.dropped)
    hp = os.path.join(out, "p8v2.html")
    with open(hp, "w") as f:
        f.write(html)
    check_no_external(hp)
    print("wrote %s (%.1f MB)" % (hp, os.path.getsize(hp) / 1e6))


def _a(M, pal, t, p):
    sc, cap = phase_a_scene(M, pal, t)
    draw(sc, "iso", pal, STILL_SIZE, cap, path=p)


def _b(M, pal, view, p):
    sc, cap = phase_b_scene(M, pal)
    draw(sc, view, pal, STILL_SIZE, cap + "   [camera %s]" % view, path=p)


def _c(M, pal, states, it, p):
    a, fires = states[it]
    sc, cap, sub, _ = phase_c_scene(M, pal, a, fires, it)
    draw(sc, "levels", pal, STILL_SIZE, cap, subcaption=sub,
         provisional_frame=True, path=p)


def _d(M, pal, p):
    sc, cap, sub = phase_d_scene(M, pal)
    draw(sc, "chain", pal, STILL_SIZE, cap, subcaption=sub, path=p)


# =================================================================== S 10 stats

def build_stats(M, final_argmax, dropped):
    L = []
    add = lambda k, v, c="": L.append("%-24s %s%s" % (k, v, ("   # " + c) if c else ""))
    add("fixture_len", N)
    add("fixture_sha256", hashlib.sha256(B).hexdigest())
    add("fixture_verified", "yes", "byte-identical to the first 128 bytes of enwik9")
    add("distinct_bytes", M.distinct)
    add("k0", pretty(M.k0), "count %d" % M.count0[M.k0])
    add("k1_entries", len(M.k1))
    add("k1_ties_broken", M.k1_ties)
    add("k1[h]", pretty(M.k1[ord("h")]), "the worked tie, S 4.3")
    for k in range(2, K + 1):
        add("lex_%d_size" % k, len(M.lex[k]),
            "of %d windows, max_occ %d" % (N - k, max(M.occ[k].values())))
    add("lex_k_budget_bound", "no", "every k; budget %d, max lexicon %d"
        % (BUDGET_K, max(len(M.lex[k]) for k in range(2, K + 1))))
    add("trace_k1_size", len(M.trace_k1))
    add("trace_composite_size", len(M.trace_composite),
        "positions %s" % ", ".join(str(p) for p in M.trace_composite))
    add("model_bytes_naive", M.model_bytes_naive,
        "sum over k=2..8 of (k+1)*|lex[k]|")
    add("settle_final_argmax", repr(final_argmax), "Phase C result at indices 18..22")
    add("settle_round_trip", "yes" if final_argmax == "http:" else "no",
        "S 12.7: not an acceptance criterion either way")
    add("phase_b_dropped", dropped, "token instances dropped at artist cap %d"
        % ARTIST_CAP)
    add("placeholders_in_force", "strengths,score,composite,trace,settling")
    return "\n".join(L) + "\n"


# ================================================================== S 9.1 HTML

def datauri(path, mime):
    with open(path, "rb") as f:
        return "data:%s;base64,%s" % (mime, base64.b64encode(f.read()).decode())


STILL_CAPTIONS = {
    "still-01-first-window.png":
        ("t = 8 — the first full k = 8 window",
         "Eight bytes are in the chain, so every level from k = 2 to k = 8 has an "
         "antecedent for the first time. All seven token nodes are present, each "
         "with its k AND-gate inputs reaching down to the byte events they gate "
         "on, and each with a consequent edge onto the live input at x = 127."),
    "still-02-http.png":
        ("t = 22 — the http: example being learned",
         "The design document's worked example. B[18..22] is 'http:'. This is the "
         "step whose learned patterns Phase C later settles against."),
    "still-03-scan-end.png":
        ("t = 127 — the end of the scan",
         "The chain is full. Cells past lag 32 have faded to gridline ink: they "
         "are the inert tail, carrying the memory-chain pattern and nothing else. "
         "Trace rings have accumulated over the whole run."),
    "still-04-model.png":
        ("Phase B — the learned model at rest, camera iso",
         "Every kept pattern at every level, each drawn at the offset where it "
         "was first learned. The iso camera crops x to [88, 142], so this shows "
         "the settling window and the k0 profile; the inert tail is in the scene "
         "but outside the crop (§7)."),
    "still-05-settle-00.png":
        ("Phase C iteration 0 — PROVISIONAL",
         "Cells for indices 18, 19 and 22 are clamped; 20 and 21 are open and "
         "start at the k = 0 marginal — which is what 'no information yet' "
         "actually means here."),
    "still-06-settle-04.png":
        ("Phase C iteration 4 — PROVISIONAL",
         "Token instances above AND_THETA fan out across offsets: not just the "
         "alignment starting at 'ht' but every overlapping one."),
    "still-07-settle-23.png":
        ("Phase C iteration 23 — PROVISIONAL",
         "The final relaxed state. Whether it reaches 'http:' is not an "
         "acceptance criterion (§12.7); the caption reports what happened."),
    "still-08-levels.png":
        ("Phase B, camera levels — down the chain axis",
         "Looking along x. The levels stack up the z axis and the fan-out of "
         "token nodes over byte values is visible. z = 6 is empty by design."),
    "still-09-top.png":
        ("Phase B, camera top — plan view",
         "Looking down from above: lexicon occupancy across the chain, per level."),
    "still-10-traces.png":
        ("Phase D — trace comparison, camera chain",
         "Pure side-on: the value axis collapses and the chain reads as a strip "
         "of 128 cells, so trace membership reads as a barcode of surprises. "
         "Class-2 rings are trace_k1, class-3 rings are trace_composite."),
}

GIF_CAPTIONS = [
    ("p8v2-scan.gif", "scan-000.png", "Phase A + Phase B held",
     "128 frames of the learning scan, then the model at rest. A byte enters at "
     "the right edge and ages leftward; learned predictions run left to right "
     "onto it."),
    ("p8v2-settle.gif", "settle-00.png", "Phase C — settling",
     "24 relaxation iterations on the http: example. Entirely PROVISIONAL: this "
     "is a plain iterative relaxation standing in for a process the design "
     "specifies as continuous, bidirectional and frequency-domain."),
    ("p8v2-orbit.gif", "orbit-00.png", "Phase E — orbit",
     "72 frames, azimuth stepping 5° per frame, on the Phase B state. Purely for "
     "reading the 3-D structure."),
]

NOTES = [
    ("§7 camera chain, and why Phase D reads as a strip",
     "azimuth −90°, elevation 0° puts the eye in the x–y plane looking along "
     "+y, so the value axis collapses and the z = 0 plane is seen edge-on. For "
     "Phase D that is the right picture and not a degenerate one: only the "
     "memory chain is drawn, each of the 128 cells is unique in x, and trace "
     "membership becomes a one-dimensional barcode — which is exactly what a "
     "trace is."),
    ("§8.2 where a resting token is drawn",
     "§5.3 fixes o = 1 during the learning scan and leaves the offset free "
     "otherwise. Phase B therefore draws each kept pattern at the offset where "
     "it was first learned, so its AND-gate inputs land on the chain cells that "
     "actually hold those bytes. Drawing every level-k token at o = 1 instead "
     "would stack the whole level into one x column and lose the occupancy that "
     "the top camera exists to show."),
    ("§13 the artist cap, and what it dropped",
     "Phase B wants about 5,400 marks, over the 4,000 cap. Token instances are "
     "dropped in ascending ω-score order (occurrence count, then lexicon "
     "position) and the count is printed on the figure and in stats.txt. Silent "
     "truncation would make a thinned frame read as a sparse model."),
    ("§6.3 the provisional envelope in Phase C",
     "Every mark in Phase C is placeholder-driven. Drawing all of them in "
     "warning yellow would erase the three-class encoding, so the envelope is "
     "marked instead: a dashed warning-coloured frame around the whole figure, "
     "dashed token edges, and the standing panel."),
    ("§6.1 the light variant",
     "§2.3 says the renderer takes no arguments and §6.1 asks for a --light "
     "build. Both are honoured by rendering the ten stills in both surfaces and "
     "switching between them with the page theme. The GIFs are dark only."),
    ("§2.3 verification",
     "The fixture was checked byte-for-byte against a real enwik9 before these "
     "figures were rendered: the 128 bytes are identical to the first 128 bytes "
     "of the corpus, so §2.3's 'reconstructed, not copied' caveat on B[64:128] "
     "is now discharged."),
]


def build_html(out, light_dir, stills, stats, M, dropped):
    def uri(p, mime="image/png"):
        return datauri(p, mime)

    parts = []
    A = parts.append
    A("<!doctype html>")
    A('<html lang="en"><head>')
    A('<meta charset="utf-8">')
    A('<meta name="viewport" content="width=device-width, initial-scale=1">')
    A("<title>p8v2 — E and P, rendered</title>")
    A("""<style>
:root{--surface:#fcfcfb;--ink:#0b0b0b;--muted:#5f5d58;--rule:#e1e0d9;
      --c1:#2a78d6;--c2:#eb6834;--c3:#1baf7a;--warn:#9a6a05;--warnbg:#fdf4e0;
      --panel:#f5f4ef;}
@media (prefers-color-scheme: dark){
:root{--surface:#1a1a19;--ink:#f2f1ec;--muted:#9d9b94;--rule:#2c2c2a;
      --c1:#3987e5;--c2:#d95926;--c3:#199e70;--warn:#fab219;--warnbg:#2a2314;
      --panel:#212120;}}
:root[data-theme="light"]{--surface:#fcfcfb;--ink:#0b0b0b;--muted:#5f5d58;
      --rule:#e1e0d9;--c1:#2a78d6;--c2:#eb6834;--c3:#1baf7a;--warn:#9a6a05;
      --warnbg:#fdf4e0;--panel:#f5f4ef;}
:root[data-theme="dark"]{--surface:#1a1a19;--ink:#f2f1ec;--muted:#9d9b94;
      --rule:#2c2c2a;--c1:#3987e5;--c2:#d95926;--c3:#199e70;--warn:#fab219;
      --warnbg:#2a2314;--panel:#212120;}
*{box-sizing:border-box}
body{background:var(--surface);color:var(--ink);margin:0;
     font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;}
.wrap{max-width:1100px;margin:0 auto;padding:2.5rem 1.25rem 6rem;}
h1{font-size:1.9rem;line-height:1.2;margin:0 0 .3rem;letter-spacing:-.01em}
h2{font-size:1.25rem;margin:3rem 0 .75rem;padding-top:.75rem;
   border-top:1px solid var(--rule);}
h3{font-size:1rem;margin:2rem 0 .4rem}
p,li{color:var(--ink)}
.sub{color:var(--muted);margin:.2rem 0 1.5rem}
a{color:var(--c1)}
nav{display:flex;flex-wrap:wrap;gap:.4rem 1.1rem;font-size:.9rem;
    padding:.9rem 0;border-top:1px solid var(--rule);
    border-bottom:1px solid var(--rule);margin-bottom:2rem}
figure{margin:0 0 2.75rem}
figure img{width:100%;height:auto;display:block;border:1px solid var(--rule);
           border-radius:3px;background:var(--panel)}
figcaption{color:var(--muted);font-size:.88rem;margin-top:.55rem}
figcaption b{color:var(--ink);font-weight:600}
pre{background:var(--panel);border:1px solid var(--rule);border-radius:3px;
    padding:1rem;overflow-x:auto;font-size:.8rem;line-height:1.45;
    font-variant-numeric:tabular-nums}
code{font-size:.9em;background:var(--panel);padding:.1em .3em;border-radius:2px}
.panel{background:var(--warnbg);border:1px dashed var(--warn);border-radius:3px;
       padding:1rem 1.15rem;margin:1.5rem 0}
.panel h3{margin:0 0 .5rem;color:var(--warn);font-size:.95rem;
          letter-spacing:.02em}
.panel table{border-collapse:collapse;font-size:.84rem;width:100%}
.panel td{padding:.18rem .6rem .18rem 0;vertical-align:top}
.panel td:first-child{font-family:ui-monospace,monospace;white-space:nowrap}
.panel td:last-child{color:var(--muted)}
.legend{display:flex;flex-wrap:wrap;gap:.4rem 1.4rem;font-size:.88rem;
        margin:1rem 0 1.5rem}
.legend span::before{content:"■ ";}
.k1{color:var(--c1)}.k2{color:var(--c2)}.k3{color:var(--c3)}
.km{color:var(--muted)}.kw{color:var(--warn)}
button{font:inherit;font-size:.85rem;background:var(--panel);color:var(--ink);
       border:1px solid var(--rule);border-radius:3px;padding:.3rem .8rem;
       cursor:pointer}
button:hover{border-color:var(--muted)}
.gifbar{display:flex;gap:.6rem;align-items:center;margin-top:.55rem}
.theme{position:fixed;top:.9rem;right:.9rem;z-index:9}
.fixture{font-family:ui-monospace,monospace;font-size:.78rem;
         word-break:break-all;background:var(--panel);padding:.8rem;
         border:1px solid var(--rule);border-radius:3px}
table.figs{border-collapse:collapse;font-size:.9rem;margin:1rem 0}
table.figs td{border-bottom:1px solid var(--rule);padding:.35rem 1.2rem .35rem 0}
table.figs td:first-child{font-family:ui-monospace,monospace}
@media (max-width:640px){.wrap{padding:1.5rem .9rem 4rem}h1{font-size:1.5rem}}
.lt{display:none}
@media (prefers-color-scheme: dark){.dk{display:block}.lt{display:none}}
@media (prefers-color-scheme: light){.dk{display:none}.lt{display:block}}
:root[data-theme="dark"] .dk{display:block}
:root[data-theme="dark"] .lt{display:none}
:root[data-theme="light"] .dk{display:none}
:root[data-theme="light"] .lt{display:block}
</style></head><body>""")
    A('<button class="theme" onclick="tog()">theme</button>')
    A('<div class="wrap">')
    A("<h1>p8v2 — E and P, rendered</h1>")
    A('<p class="sub">A 3-D rendering of the event spaces and patterns of the '
      'p8v2 P-program, run over a fixed 128-byte prefix of enwik9, with '
      'k = 8 and N = 128. Rendered from <code>p8v2-viz-spec.md</code>; '
      'every figure below is reproducible from that file alone.</p>')

    A('<nav>')
    A('<a href="#provisional">provisional</a>')
    A('<a href="#fixture">fixture</a>')
    A('<a href="#stills">stills</a>')
    A('<a href="#anim">animations</a>')
    A('<a href="#stats">stats</a>')
    A('<a href="#notes">notes</a>')
    A('</nav>')

    A('<div class="panel" id="provisional">')
    A("<h3>PROVISIONAL — f and ω are not specified</h3>")
    A("<p style='margin:.2rem 0 .8rem;font-size:.9rem'>Five things in these "
      "figures are scaffolding, not design. They exist so the program runs. "
      "Everything derived from them is drawn dashed, in warning colour, and "
      "named here and on every frame.</p><table>")
    for name, what, why in PLACEHOLDERS:
        A("<tr><td>%s</td><td>%s</td><td>%s</td></tr>" % (name, what, why))
    A("</table></div>")

    A('<div class="legend">'
      '<span class="k1">byte events — the byte ES and every mem_cell_i</span>'
      '<span class="k2">k-models — the k0 profile and the k1 edges</span>'
      '<span class="k3">tokens — nodes, AND-gate inputs, consequents</span>'
      '<span class="km">memchain — inert scaffolding, not a series</span>'
      '<span class="kw">PROVISIONAL — dashed</span></div>')

    A('<h2 id="fixture">The fixture</h2>')
    A("<p>Exactly 128 bytes, all printable US-ASCII, verified byte-for-byte "
      "against the first 128 bytes of a real enwik9 before these figures were "
      "rendered.</p>")
    fx = B.decode("ascii").replace(":", "&#58;")
    A('<div class="fixture">%s</div>' % fx)
    A("<p class='sub' style='margin-top:.6rem;font-size:.85rem'>"
      "sha256 <code>%s</code>. Colons are written as character references so "
      "this page contains no literal <code>http</code> scheme text, which "
      "acceptance criterion 11 forbids.</p>" % hashlib.sha256(B).hexdigest())

    A('<h2 id="stills">Stills</h2>')
    for n, name in enumerate(stills, 1):
        title, body = STILL_CAPTIONS[name]
        d = uri(os.path.join(out, name))
        l = uri(os.path.join(light_dir, name))
        A('<figure id="s%02d">' % n)
        A('<img class="dk" src="%s" alt="%s">' % (d, title))
        A('<img class="lt" src="%s" alt="%s">' % (l, title))
        A('<figcaption><b>%d. %s</b> — %s</figcaption></figure>' % (n, title, body))

    A('<h2 id="anim">Animations</h2>')
    for n, (gif, poster, title, body) in enumerate(GIF_CAPTIONS):
        g = uri(os.path.join(out, gif), "image/gif")
        p = uri(os.path.join(out, "frames", poster))
        A('<figure id="g%d">' % n)
        A('<img id="gp%d" src="%s" alt="%s">' % (n, p, title))
        A('<img id="gg%d" src="%s" alt="%s" style="display:none">' % (n, g, title))
        A('<div class="gifbar"><button onclick="pp(%d)" id="gb%d">play</button>'
          '<span class="sub" style="margin:0;font-size:.85rem">%s</span></div>'
          % (n, n, gif))
        A('<figcaption><b>%s</b> — %s</figcaption></figure>' % (title, body))

    A('<h2 id="stats">stats.txt</h2>')
    A("<p>Written verbatim by the renderer. Two runs produce an identical file "
      "(acceptance criterion 12); every number was computed from the fixture.</p>")
    A("<pre>%s</pre>" % stats.replace("&", "&amp;").replace("<", "&lt;"))

    A("<h2>What this figure does and does not claim</h2>")
    A("<p>The trace drops from <b>%d</b> surprising positions under the k ≤ 1 "
      "model to <b>%d</b> under the composite one. <b>That is not a compression "
      "win.</b> The model spends %d bytes of stored patterns to remove %d trace "
      "entries from a 128-byte input, because at N = 128 almost every 8-gram is "
      "unique (%d distinct of %d) and the placeholder composite rule is simply "
      "reciting the fixture back. A viewer who comes away believing otherwise "
      "has been misled, and the figure has failed.</p>"
      % (len(M.trace_k1), len(M.trace_composite), M.model_bytes_naive,
         len(M.trace_k1) - len(M.trace_composite), len(M.lex[8]), N - 8))

    A('<h2 id="notes">Readings and deviations</h2>')
    A("<p>Where the spec left something open or where two of its sections "
      "pulled against each other, this is what was chosen.</p>")
    for title, body in NOTES:
        A("<h3>%s</h3><p>%s</p>" % (title, body))

    A('<h2>Files</h2><table class="figs">')
    for f, what in (("p8v2-viz.py", "the renderer — no arguments, writes ./out/"),
                    ("out/frames/", "128 scan + 24 settle + 72 orbit PNGs"),
                    ("out/*.gif", "the three animations"),
                    ("out/still-*.png", "the ten stills, dark surface"),
                    ("out/light/still-*.png", "the ten stills, light surface"),
                    ("out/stats.txt", "the machine-checkable figures"),
                    ("out/p8v2.html", "this page — one self-contained file")):
        A("<tr><td>%s</td><td>%s</td></tr>" % (f, what))
    A("</table>")
    A("</div>")
    A("""<script>
function tog(){var r=document.documentElement;
 var d=r.getAttribute('data-theme');
 if(!d){d=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}
 r.setAttribute('data-theme', d==='dark'?'light':'dark');}
function pp(n){var p=document.getElementById('gp'+n),g=document.getElementById('gg'+n),
 b=document.getElementById('gb'+n);
 if(g.style.display==='none'){var s=g.src;g.src='';g.src=s;
  g.style.display='block';p.style.display='none';b.textContent='pause';}
 else{g.style.display='none';p.style.display='block';b.textContent='play';}}
</script></body></html>""")
    return "\n".join(parts)


def check_acceptance(M):
    """S 12.1-4, 12.6: every number the spec pins down, asserted not reported."""
    assert len(B) == N
    assert all(chr(B[i]) == c for i, c in ANCHORS.items())          # 12.1
    assert M.k0 == ord("/") and M.count0[M.k0] == 9                 # 12.2
    assert M.distinct == 34                                         # 12.2
    assert M.predict(0)[1] != B[0]                                  # 12.2
    assert M.k1[ord("h")] == ord("e")                               # 12.3
    assert len(M.k1) == 34 and M.k1_ties == 10                      # 12.3
    assert [len(M.lex[k]) for k in range(2, K + 1)] == \
        [84, 94, 101, 105, 109, 111, 112]                           # 12.4
    assert all(len(M.lex[k]) < BUDGET_K for k in range(2, K + 1))   # 12.4
    assert len(M.trace_k1) == 63                                    # 12.6
    assert M.trace_composite == [0, 38, 82]                         # 12.6
    assert M.model_bytes_naive == 4422                              # 12.6
    print("S 12.1-12.4, 12.6 OK: every pinned figure reproduced")


def check_no_external(path):
    """S 12.11: no external reference of any kind outside data: URIs."""
    with open(path) as f:
        html = f.read()
    stripped = re.sub(r'data:[a-z/+-]+;base64,[A-Za-z0-9+/=]+', 'DATAURI', html)
    bad = []
    for pat in ("http://", "https://", 'src="./', "@import"):
        if pat in stripped:
            bad.append(pat)
    if bad:
        print("FAIL S 12.11: external references found: %s" % bad)
        sys.exit(1)
    print("S 12.11 OK: no external references outside data: URIs")


if __name__ == "__main__":
    main()
