#!/usr/bin/env python3
"""Build the published GENERATION 4 e64 settling instrument.

Spec: #p8v2_gen3_viz_goal_20260810 (the standing instrument spec) applied to
generation 4, #pprog_p8v2_gen4_goal_20260815 and its report (../cmpr-src).  The
generation-3 instrument is docs/pprog/build-p8v2-gen3-e64 and is FROZEN; one page
per generation, per #viz_standard "Versioning a viewer".

PUBLICATION IS ../hutter's.  This script runs no compression: it reads the four
retained e64 models and the four .pos dumps, both cmpr-src's, and bakes what the
page needs into one self-contained HTML file.

  docs/pprog/build-p8v2-gen4-e64 [--cmpr-src PATH]
"""

import json
import os
import re
import struct
import subprocess
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "p8v2-gen4-e64")
SAMPLE = "64"

# GENERATION 4 (#pprog_p8v2_gen4_report_20260815).  v024 (711111) is the generation's
# baseline -- the A7 pair off v002's vector -- and v025 (721111) varies axis B off it.
# v002 and v013 are the bridges: gen4.tsv reruns both so the generations stay
# comparable, and v013's stored count matrix is what A4/A5 read their supports from.
#
# Axis labels and alternative prose are READ FROM axes.json, not restated here.
GEN = 4
POSDIR = "gen%d-pos" % GEN
VARIANTS = ["wordsv2-v%03d" % i for i in (2, 13, 24, 25)]

# model layout, from #pp_wordsv2: 48-byte header, then the k=1 argmax table, the
# backward LPP, the token section (4 bytes per rule), the gap section, the trace.
#
# THE k=0 BACKGROUND IS GONE as of 2026-08-06 (#pp_wordsv2). It used to occupy 256
# bytes at offset 48 and every offset after the header has moved down by 256. A model
# built before that date is 256 bytes longer and read_model rejects it on the size
# identity rather than misreading the argmax table as a background.
HDR = 48
K1 = 48
BWD0 = 304

BLOCKS = ["p8v2_gen3_viz_goal_20260810", "viz_standard",
          "p8v2_latd_probability_ask_20260815"]                # ours
# The generation-2 report and the goal it answers are published beside the page rather
# than paraphrased on it (#viz_standard: prefer publishing the source document, and a
# block id is not a definition).  The generation-1 report stays: the page cites it for
# what the axes looked like before the k=0 removal.
SRC_BLOCKS = ["p8v2_gen3", "pprog_p8v2_gen4_goal_20260815",
              "pprog_p8v2_gen4_report_20260815", "f-p8", "f-p8-cap8",
              "f-p8-deficit", "f-p8-period", "f-p8-indegree",
              "hutter_publication_handoff", "variant_protocol"]

LINKS = [("gen4-pos", "gen4-pos"), ("models", "models"),
         ("p8v2-words.md", "p8v2-words.md"), ("gen4.tsv", "gen4.tsv"),
         ("axes.json", "axes.json")]

# LSA.md is at the ROOT of cmpr-src, not under tests/pprog, so it does not go through
# LINKS above.  The page's "Is any of this probability?" section quotes it for what an
# LSA value means -- a log count with a stochastic, uncorrelated error -- which is the
# link in the chain that explains why a pattern's stored weight and the count the
# concordance shows are allowed to disagree.  Publish the source, do not paraphrase it
# (#viz_standard).
ROOT_LINKS = [("LSA.md", "LSA.md")]


def read_model(path):
    d = open(path, "rb").read()
    if d[:4] != b"P8V2":
        sys.exit("build-p8v2-gen4-e64: %s is not a P8V2 model" % path)
    n, m, tc, tb, g, sc, bwd, rounds = struct.unpack("<8I", d[4:36])
    axes = d[36:42].decode("ascii")
    want = 48 + 256 + bwd + tb + g + sc
    if len(d) != want:
        sys.exit("build-p8v2-gen4-e64: %s is %d bytes, its header says %d. A model built "
                 "before the k=0 removal of 2026-08-06 is 256 bytes longer; regenerate "
                 "it with cmpr-src's tests/pprog/acceptance-p8v2." % (path, len(d), want))
    k1 = list(d[K1:K1 + 256])
    lpp = list(d[BWD0:BWD0 + bwd]) if bwd else None
    toff = BWD0 + bwd
    toks = []
    for i in range(tc):
        a, b, c, w = d[toff + 4 * i: toff + 4 * i + 4]
        toks.append([a, b, c, w])
    return dict(axes=axes, M=m, TC=tc, ROUNDS=rounds, k1=k1, lpp=lpp,
                toks=toks, size=len(d), bwd=bwd)


BYTE_RE = re.compile(r"0x([0-9A-F]{2})|(.)", re.S)


def bytes_in(name):
    """The byte values a printed event name spells.

    cmpr writes a byte either literally or as 0xNN.  Nothing marks which, so
    "0x20" is in principle a literal '0','x','2','0' -- every name parsed here is
    checked against the model bytes, so a wrong reading fails the build instead of
    reaching the page.
    """
    out = []
    for m in BYTE_RE.finditer(name):
        out.append(int(m.group(1), 16) if m.group(1) else ord(m.group(2)))
    return out


def sn_patterns(cmpr, cmpr_src, model_path, mod):
    """The SN atoms for the patterns this page draws, verbatim from the query layer.

    "SN means that every pattern is interpretable" (#p8v2_gen3, item 2 of
    #p8v2_gen3_viz_goal_20260810).  The page used to name a pattern as a pair of
    byte values, which is the implementation's view of it and not a pattern.

    THIS SELECTS, IT DOES NOT DUMP.  --patterns emits the whole structural line --
    66152 lines for a generation-2 model at e64, of which 32385 groups are the
    memory chain, which lies outside the window of indeterminacy and is neither
    drawn nor stepped.  Two families are drawn and only those are kept: the learned
    k=1 markov (input to input) and the k=2 token rules (2-token to input).

    IN A DUMP THE UNINDENTED LINE IS THE FROM EVENT and the line indented under it
    is the TO event with its strength.  The memory chain settles the direction:
    only one reading makes "1 time steps ago" -> "2 time steps ago" a shift.

    Every atom is cross-checked against the model bytes, in both directions: the
    k=1 family must arrive in from-byte order 0..255 with each to-byte equal to
    k1[a], and the token family must be exactly the model's token section.

    WHAT IS RETURNED IS THE EVENT NAMES, NOT THE PATTERNS.  A pattern is a pair of
    events and a strength; the events are the part the query layer names and the
    pairing is already in the model tables the page carries.  Baking both texts of
    every pattern of every variant instead cost 208 kB of duplicated string for ten
    models that share 256 event names between them.
    """
    p = subprocess.run([cmpr, "--run", "wordsv2", "--ofra",
                        os.path.join("tests", "pprog", "p8v2-words.md"),
                        "--patterns", "--model", model_path],
                       cwd=cmpr_src, capture_output=True, text=True)
    if p.returncode != 0:
        sys.exit("build-p8v2-gen4-e64: the query layer failed on %s:\n%s"
                 % (model_path, p.stderr.strip()))

    k1, tok, frm = [], [], None
    for line in p.stdout.split("\n"):
        if not line.strip():
            continue
        if not line.startswith("  "):
            frm = line.strip()
            continue
        to = line.strip()
        m = re.match(r'^(".*\.") (\d+)\.$', to)
        if not m:
            sys.exit("build-p8v2-gen4-e64: cannot read an SN line: %r" % to)
        to_ev, w = m.group(1), int(m.group(2))
        f_in = re.match(r'^"The input byte is (.*)\."$', frm)
        f_tk = re.match(r'^"The 2-token is "(.*)"\."$', frm)
        t_in = re.match(r'^"The input byte is (.*)\."$', to_ev)
        if f_in and t_in:
            k1.append([bytes_in(f_in.group(1)), bytes_in(t_in.group(1)), w, frm, to_ev])
        elif f_tk and t_in:
            tok.append([bytes_in(f_tk.group(1)), bytes_in(t_in.group(1)), w, frm, to_ev])

    if len(k1) != 256:
        sys.exit("build-p8v2-gen4-e64: the k=1 family has %d atoms, expected 256, in %s"
                 % (len(k1), model_path))
    event, k1w = [None] * 256, [0] * 256
    for a, (fb, tb_, w, fa, ta) in enumerate(k1):
        k1w[a] = w
        if fb != [a] or len(tb_) != 1 or tb_[0] != mod["k1"][a]:
            sys.exit("build-p8v2-gen4-e64: SN atom %d of the k=1 family reads %r -> %r, but the "
                     "model says %d -> %d. Either the dump is not in from-byte order or a "
                     "byte name was misread." % (a, fa, ta, a, mod["k1"][a]))
        event[a] = fa
        if event[tb_[0]] not in (None, ta):
            sys.exit("build-p8v2-gen4-e64: byte %d is named both %r and %r"
                     % (tb_[0], event[tb_[0]], ta))
        event[tb_[0]] = ta

    tok_out, seen = {}, []
    for ab, c, w, fa, ta in tok:
        if len(ab) != 2 or len(c) != 1:
            sys.exit("build-p8v2-gen4-e64: a token atom does not spell two bytes: %r" % fa)
        seen.append([ab[0], ab[1], c[0], w])
        tok_out["%d,%d" % (ab[0], ab[1])] = fa
    if sorted(seen) != sorted(mod["toks"]):
        sys.exit("build-p8v2-gen4-e64: the token atoms in %s are not the model's token section "
                 "(%d atoms against %d rules)" % (model_path, len(seen), len(mod["toks"])))

    if None in event:
        sys.exit("build-p8v2-gen4-e64: the k=1 family did not name every byte")

    # k1w CANNOT BE CHECKED AGAINST THE MODEL -- the k=1 section is 256 bytes of
    # argmax and stores no weight -- but since generation 4 it is checked against
    # axes.json's stated k1_strength in main(): the strength is a READING, and the
    # reading is now single-sourced. The token weights, which the model does store,
    # agree exactly.
    return {"event": event, "token": tok_out, "k1w": k1w}


def check_latd(cmpr, cmpr_src, model_path, mod, sn, sample):
    """Check the page's own LATD scan against `cmpr --latd`, pattern by pattern.

    THE PAGE REIMPLEMENTS LATD IN JAVASCRIPT, and it has to: the expansion has to
    follow the selection, and shipping 309 baked answers would be a copy of a fact
    the query layer already owns.  But #viz_standard's rule for a builder is that it
    IMPORTS the upstream derivation rather than reimplementing it, so that the page
    cannot drift from the numbers -- and a reimplementation is exactly a drift risk.

    The resolution is to reimplement it and then PROVE the copy: the same scan the
    JS does, in Python, against `cmpr --run wordsv2 --latd` for every pattern the
    browser lists.  A disagreement fails the build.  The scan is cheap on both sides
    (about 4 ms per query, 309 queries) and it makes the page's claim -- that this is
    the support set and not an estimate of it -- checkable rather than asserted.

    Returns what was checked, so the page can say so instead of the reader taking it
    on trust.
    """
    M = len(sample)
    checked, skipped = 0, []

    def scan(pred, n):
        """positions i with pred(i) true and a successor at i+n -- the JS scan"""
        return [(i, sample[i + n]) for i in range(M - n) if pred(i)]

    want = []
    for a in range(256):
        want.append((sn["event"][a], scan(lambda i, a=a: sample[i] == a, 1)))
    for t in mod["toks"]:
        key = "%d,%d" % (t[0], t[1])
        want.append((sn["token"][key],
                     scan(lambda i, t=t: i + 1 < M and sample[i] == t[0]
                          and sample[i + 1] == t[1], 2)))

    for atom, sup in want:
        # --latd names a pattern by its ANTECEDENT SENTENCE exactly as --patterns
        # prints it, and that is what sn_patterns kept.
        p = subprocess.run([cmpr, "--run", "wordsv2", "--ofra",
                            os.path.join("tests", "pprog", "p8v2-words.md"),
                            "--model", model_path, "--latd", atom.strip('"')],
                           cwd=cmpr_src, capture_output=True, text=True)
        if p.returncode != 0:
            # An unkept 2-token is a documented refusal, not a failure; anything else is.
            if "no k=2 rule" in p.stderr:
                skipped.append(atom)
                continue
            sys.exit("build-p8v2-gen4-e64: --latd failed on %s:\n%s"
                     % (atom, p.stderr.strip()))
        m = re.search(r"^  positions:(.*)$", p.stdout, re.M)
        if m is None:
            sys.exit("build-p8v2-gen4-e64: --latd printed no positions line for %s" % atom)
        got = [int(x) for x in m.group(1).split()]
        if got != [i for i, _ in sup]:
            sys.exit("build-p8v2-gen4-e64: the page's LATD scan disagrees with the query "
                     "layer on %s: --latd says %r, the scan says %r. The scan is the "
                     "page's own and the query layer is the authority; fix the scan."
                     % (atom, got, [i for i, _ in sup]))
        # and the successor counts, which are what the concordance's disagreement
        # column is read off
        c = {}
        for _, s in sup:
            c[s] = c.get(s, 0) + 1
        m = re.search(r"^  observed successors:(.*)$", p.stdout, re.M)
        f = (m.group(1).split() if m else [])
        # Compare BYTE VALUES, not the printed names: the query layer writes a space as
        # 0x20 and a full stop as '.', and reconstructing that convention here would be
        # a second place for it to be wrong. bytes_in is the reader that already exists.
        cli = {}
        for i in range(0, len(f) - 1, 2):
            b = bytes_in(f[i])
            if len(b) != 1:
                sys.exit("build-p8v2-gen4-e64: --latd named a successor %r that is not one "
                         "byte, expanding %s" % (f[i], atom))
            cli[b[0]] = int(f[i + 1])
        ours = dict(c)
        if cli != ours:
            sys.exit("build-p8v2-gen4-e64: the page's successor counts disagree with the "
                     "query layer on %s: --latd says %r, the scan says %r" % (atom, cli, ours))
        checked += 1

    return {"checked": checked, "skipped": len(skipped), "model": os.path.basename(model_path)}


def sn_merge(data):
    """One set of event names for the page.

    The ten models were each queried and each verified against its own bytes; the
    NAMES are a property of the event system, not of a model, so they merge into
    one table and disagreement is a build failure rather than something the page
    picks between.
    """
    event, token, k1w = None, {}, None
    for v, d in sorted(data.items()):
        if event is None:
            event, k1w = d["sn"]["event"], d["sn"]["k1w"]
        elif d["sn"]["event"] != event:
            sys.exit("build-p8v2-gen4-e64: %s names the bytes differently from the other "
                     "models" % v)
        elif d["sn"]["k1w"] != k1w:
            sys.exit("build-p8v2-gen4-e64: %s reports different k=1 pattern strengths. The "
                     "page carries one set; give it one per variant instead." % v)
        for k, atom in d["sn"]["token"].items():
            if token.setdefault(k, atom) != atom:
                sys.exit("build-p8v2-gen4-e64: token %s is named both %r and %r"
                         % (k, token[k], atom))
    return {"event": event, "token": token, "k1w": k1w}


def read_pos(path):
    rows = []
    for line in open(path):
        if line.startswith("#") or not line.strip():
            continue
        f = line.rstrip("\n").split("\t")
        rows.append(dict(pos=int(f[0]), byte=int(f[1]), rec=int(f[2]),
                         causal=int(f[3]), tok_fired=int(f[4]),
                         tok_a=int(f[5]), tok_b=int(f[6]), tok_c=int(f[7]),
                         settled=int(f[8]), s1=int(f[9]), w1=int(f[10]),
                         s2=int(f[11]), w2=int(f[12]), conv=int(f[13])))
    return rows


def main(argv):
    cmpr_src = os.path.abspath(os.path.join(HERE, "..", "..", "..", "cmpr-src"))
    if "--cmpr-src" in argv:
        cmpr_src = os.path.abspath(argv[argv.index("--cmpr-src") + 1])
    pprog = os.path.join(cmpr_src, "tests", "pprog")
    mdir = os.path.join(pprog, "models", "p8v2", "enwik9", SAMPLE)
    if not os.path.isdir(mdir):
        sys.exit("build-p8v2-gen4-e64: no %s -- run acceptance-p8v2 in cmpr-src first" % mdir)

    os.makedirs(OUT, exist_ok=True)
    rel = os.path.relpath(pprog, OUT)
    relroot = os.path.relpath(cmpr_src, OUT)
    for base, relbase, links, need in ((pprog, rel, LINKS, False),
                                       (cmpr_src, relroot, ROOT_LINKS, True)):
        for name, target in links:
            link = os.path.join(OUT, name)
            if not os.path.exists(os.path.join(base, target)):
                # The pprog artifacts are optional -- a missing TSV is reported later and
                # the page says so.  LSA.md is not: the probability section links it as
                # its source, and a section that quotes a document nobody can open is
                # the paraphrase #viz_standard is written against.
                if need:
                    sys.exit("build-p8v2-gen4-e64: no %s to publish beside the page"
                             % os.path.join(base, target))
                continue
            if os.path.islink(link) or os.path.exists(link):
                os.remove(link)
            os.symlink(os.path.join(relbase, target), link)

    # The axis document is the page's source for every label, every alternative and
    # every definition.  It is emitted by cmpr-src's tests/pprog/p8v2-axes.py and gated
    # by tests/pprog/acceptance-p8v2-axes, which fails if any alternative's prose is not
    # the text of its OFRA block byte for byte.  Read it; do not restate it.
    axes_path = os.path.join(pprog, "axes.json")
    if not os.path.exists(axes_path):
        sys.exit("build-p8v2-gen4-e64: no %s -- run cmpr-src's tests/pprog/p8v2-axes.py first"
                 % axes_path)
    axes_doc = json.load(open(axes_path))
    if axes_doc.get("schema") != "p8v2-axes/1":
        sys.exit("build-p8v2-gen4-e64: axes.json schema is %r, expected 'p8v2-axes/1'"
                 % axes_doc.get("schema"))
    # THE CONCESSION IS GONE: axes.json said generation 2 while carrying A4, A5 and A6, so
    # this builder checked only that the alternatives were present.  cmpr-src corrected the
    # field on 2026-08-11 and added `generations`, which lists every generation the file
    # describes -- the right shape, because a frozen page renders an older generation out
    # of the same living file.  Both checks now run: the generation is one the file
    # describes, AND the alternatives this page draws are in it.
    gens = axes_doc.get("generations") or [axes_doc.get("generation")]
    if GEN not in gens:
        sys.exit("build-p8v2-gen4-e64: axes.json describes generations %r, not %d, so it "
                 "cannot label this page's runs. Regenerate it with cmpr-src's "
                 "tests/pprog/p8v2-axes.py." % (gens, GEN))
    need = {"A": [7]}
    have = {a["letter"]: [alt["digit"] for alt in a["alternatives"]] for a in axes_doc["axes"]}
    for letter, digits in need.items():
        missing = [d for d in digits if d not in have.get(letter, [])]
        if missing:
            sys.exit("build-p8v2-gen4-e64: axes.json has no alternative %s%s. Regenerate it "
                     "with cmpr-src's tests/pprog/p8v2-axes.py."
                     % (letter, ",".join(str(m) for m in missing)))
    # axes.json's baseline field names v002/211111 -- the vector every generation's
    # digits are read against.  The GENERATION'S baseline is v024, the A7 pair, which
    # the same field's note states; both facts are checked below against the models.
    baseline = "wordsv2-v024"
    if baseline not in VARIANTS:
        sys.exit("build-p8v2-gen4-e64: the generation-4 baseline %s is not in the "
                 "variant set" % baseline)

    cmpr = os.environ.get("CMPR") or os.path.join(cmpr_src, "cmpr", "dist", "cmpr")
    if not os.access(cmpr, os.X_OK):
        cmpr = "cmpr"

    data = {}
    for v in VARIANTS:
        mod = read_model(os.path.join(mdir, v + ".m"))
        mod["pos"] = read_pos(os.path.join(pprog, POSDIR, "%s.e64.pos" % v))
        mod["sn"] = sn_patterns(cmpr, cmpr_src, os.path.join(mdir, v + ".m"), mod)
        data[v] = mod

    # Every variant's axis vector must be the one axes.json gives it, or the page would
    # attach a label to a run that is not that run.
    byvec = {}
    for a in axes_doc["axes"]:
        for alt in a["alternatives"]:
            byvec.setdefault(a["letter"], {})[alt["digit"]] = alt
    if data[baseline]["axes"] != "711111":
        sys.exit("build-p8v2-gen4-e64: %s carries axes %s, expected 711111 (the A7 pair)"
                 % (baseline, data[baseline]["axes"]))
    if data["wordsv2-v002"]["axes"] != axes_doc["baseline"]["axes"]:
        sys.exit("build-p8v2-gen4-e64: v002 carries axes %s but axes.json calls the "
                 "reference vector %s"
                 % (data["wordsv2-v002"]["axes"], axes_doc["baseline"]["axes"]))

    base = data[baseline]
    M = base["M"]
    sample = [r["byte"] for r in base["pos"]]
    for v, d in data.items():                       # the sample is one sample
        assert [r["byte"] for r in d["pos"]] == sample, "%s: different bytes" % v
        assert d["M"] == M

    # Which variants share the baseline's learned model?  The prereq says B2's stored
    # LPP is only valid for those, and that E relearns k=1.  Check rather than assume.
    def learned(d):
        return (d["k1"], d["toks"])
    shares = sorted(v for v, d in data.items() if learned(d) == learned(base))
    if shares != sorted(data):
        sys.exit("build-p8v2-gen4-e64: generation 4 varies axes A and B only, neither of "
                 "which changes learning, so every variant must carry the same learned "
                 "model; these do not: %r" % shares)

    # THE COUNT MATRIX, AND WHY IT IS ON THE PAGE LEGITIMATELY THIS GENERATION.
    #
    # A4 and A5 (still controls here) set a pattern's rate from ITS OWN SUPPORT, and B2
    # settles with the stored weights: both read the 65536-byte learned count matrix,
    # which a B1 model does not serialize.  In generation 4 the matrix is IN THE RUN
    # SET: v013 and v025 both store it under B2.  The learned-model identity is already
    # checked above; here the two stored matrices must also be identical to each other,
    # or "the" matrix would be a choice the page silently made.
    owner = "wordsv2-v013"
    c1mod = data[owner]
    if not c1mod["lpp"]:
        sys.exit("build-p8v2-gen4-e64: %s stores no LPP; the B2 presets and A4/A5 "
                 "cannot be driven" % owner)
    if data["wordsv2-v025"]["lpp"] != c1mod["lpp"]:
        sys.exit("build-p8v2-gen4-e64: v013 and v025 store different count matrices "
                 "over the same learned model; refusing to pick one")
    c1 = c1mod["lpp"]

    # THE CONSTANTS THE PAGE READS (generation 4's c5): k1_strength and the per-
    # alternative fall-offs live in axes.json's constants section, and the template
    # reads them from the baked axes_doc rather than holding a copy.  Absent constants
    # would make the page invent f's numbers, so they are checked here.
    cst = axes_doc.get("constants") or {}
    prog = cst.get("program") or {}
    pa = cst.get("per_alternative") or {}
    if "k1_strength" not in prog or \
       "falloff" not in (pa.get("f-p8-cap8") or {}) or \
       "falloff" not in (pa.get("f-p8-add") or {}):
        sys.exit("build-p8v2-gen4-e64: axes.json carries no constants for the pair "
                 "(program.k1_strength, per_alternative f-p8-cap8/f-p8-add falloff). "
                 "Regenerate it with cmpr-src's tests/pprog/p8v2-axes.py.")

    # sup1[x] is the learned support of x's own argmax rule, and smax the reference
    # support -- both exactly as #f-p8-deficit builds them, including its floor of 1.
    sup1 = [c1[x * 256 + base["k1"][x]] for x in range(256)]
    tokw = [t[3] for t in base["toks"]]
    smax_model = max([1] + sup1 + tokw)

    # THE SETTLING DIAGNOSTICS, READ FROM gen4.tsv.  The TSV is the generation's own
    # published record and carries every column the page quotes; axes.json's
    # settling_columns.numbers historically lagged the newest generation, so the TSV
    # has been the source since the generation-3 page and stays it.
    tsv = os.path.join(pprog, "gen%d.tsv" % GEN)
    settling = []
    if os.path.exists(tsv):
        with open(tsv) as fh:
            head = fh.readline().rstrip("\n").split("\t")
            for line in fh:
                r = dict(zip(head, line.rstrip("\n").split("\t")))
                if r.get("sample") == "e64" and r.get("variant") in data:
                    settling.append({k: r.get(k) for k in
                                     ("variant", "axes", "sample", "settled_ok",
                                      "mean_sweeps", "settle_apps", "roundtrip",
                                      "p_trace", "tokens_kept")})
    if len(settling) != len(data):
        sys.stderr.write("build-p8v2-gen4-e64: gen%d.tsv has e64 rows for %d of the %d "
                         "variants; the per-axis table will say so rather than guess\n"
                         % (GEN, len(settling), len(data)))

    # THE EXTIRPATION, CHECKED: since generation 4 the query layer reports every k=1
    # pattern at the stated strength (constants.program.k1_strength), not a literal 1.
    # A different value here means the installed cmpr predates generation 4 -- the
    # acceptance-p8v2 CMPR trap, seen from this side.
    sn0 = sn_merge(data)
    k1s = prog["k1_strength"]["value"]
    if any(w != k1s for w in sn0["k1w"]):
        sys.exit("build-p8v2-gen4-e64: the query layer reports k=1 strengths %r, but "
                 "axes.json states k1_strength = %d. The cmpr being run predates the "
                 "generation-4 extirpation; point CMPR at cmpr-src's dist build."
                 % (sorted(set(sn0["k1w"])), k1s))

    # THE PAGE'S LATD SCAN, PROVED AGAINST THE QUERY LAYER.  Checked once, on the
    # baseline: the four variants share one learned model byte for byte, which is
    # asserted above, so one check covers the set.
    sn = sn0
    latd = check_latd(cmpr, cmpr_src, os.path.join(mdir, baseline + ".m"), base, sn, sample)

    payload = {
        "M": M,
        "latd_check": latd,
        "settling": settling,
        "W": M,                    # one window at e64: ws=0, we=M, so W = M = 64
        "gen": GEN,
        "baseline": baseline,
        "baseline_axes": axes_doc["baseline"]["axes"],
        "sample": sample,
        "shares_baseline_model": shares,
        "lpp_owner": owner,
        "lpp": c1,
        "sup1": sup1,              # the learned support of each byte's own argmax rule
        "smax_model": smax_model,  # the reference support, as #f-p8-deficit computes it
        "axes_doc": axes_doc,      # labels, alternatives, definitions, worked examples
        "sn": sn,                  # the event names, verbatim from the query layer
        "variants": {},
    }
    for v, d in data.items():
        payload["variants"][v] = {
            "axes": d["axes"], "rounds": d["ROUNDS"], "size": d["size"],
            "k1": d["k1"], "toks": d["toks"],
            "rec": [r["rec"] for r in d["pos"]],
            "causal": [r["causal"] for r in d["pos"]],
            "endpoint": [[r["settled"], r["s1"], r["w1"], r["s2"], r["w2"]]
                         for r in d["pos"]],
            "conv": d["pos"][0]["conv"],           # per-window, all rows repeat it
        }

    # block dumps beside the page
    for b, cwd in ([(x, cmpr_src) for x in SRC_BLOCKS] +
                   [(x, os.path.join(HERE, "..", "..")) for x in BLOCKS]):
        p = subprocess.run([cmpr, "--print-block", "#" + b], cwd=cwd,
                           capture_output=True, text=True)
        if p.returncode == 0 and p.stdout.strip():
            open(os.path.join(OUT, b + ".txt"), "w").write(p.stdout)
        else:
            sys.stderr.write("build-p8v2-gen4-e64: could not dump #%s\n" % b)

    tpl = open(os.path.join(HERE, "p8v2-gen4-e64.tpl.html")).read()
    page = tpl.replace("/*DATA*/null", json.dumps(payload, separators=(",", ":")))
    open(os.path.join(OUT, "index.html"), "w").write(page)

    # THE NOTES PAGE, AND WHY IT IS A SECOND PAGE. Everything discursive -- what
    # generation 3 changed, where the support is borrowed from, what is still open --
    # used to sit between the reader and the instrument on one long page. MJC, 2026-08-15:
    # the instrument is where the effort goes, not the prose. So the commentary moved
    # here and links back INTO the instrument by fragment (v/p/s/t), which is the deep
    # link #p8v2_gen3_viz_goal_20260810 item 4c asked for.
    #
    # IT SHARES THE INSTRUMENT'S STYLESHEET RATHER THAN CARRYING A COPY. Two hand-kept
    # copies of a stylesheet is the gen1-viz.py fork in miniature (#viz_surfaces), so
    # the <style> block is lifted out of the instrument template at build time. Both
    # pages are still self-contained: nothing is fetched, the CSS is inlined in each.
    m = re.search(r"<style>.*?</style>", tpl, re.S)
    if not m:
        sys.exit("build-p8v2-gen4-e64: no <style> block in the instrument template to "
                 "share with the notes page")
    ntpl = open(os.path.join(HERE, "p8v2-gen4-e64-notes.tpl.html")).read()
    if "<!--STYLE-->" not in ntpl:
        sys.exit("build-p8v2-gen4-e64: the notes template has no <!--STYLE--> slot")
    open(os.path.join(OUT, "notes.html"), "w").write(
        ntpl.replace("<!--STYLE-->", m.group(0)))
    sys.stderr.write("build-p8v2-gen4-e64: wrote %s (generation %d, baseline %s, %d variants, "
                     "M=%d, W=%d, count matrix borrowed from %s, smax=%d)\n"
                     % (OUT, GEN, baseline, len(data), M, payload["W"], owner, smax_model))
    return 0


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