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

Spec: #p8v2_gen3_viz_goal_20260810, answering #p8v2_gen3 (../cmpr-src).  The
generation-2 instrument is docs/pprog/build-p8v2-e64 and is FROZEN: MJC answered
choice G with g2, a new page per generation, because rebuilding in place is not a
version -- the generation-1 page was rebuilt for generation 2 and no longer exists
to be compared with.  Naming follows #viz_standard, "Versioning a viewer".

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

  docs/pprog/build-p8v2-gen3-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-gen3-e64")
SAMPLE = "64"

# GENERATION 2 (#pprog_p8v2_gen2_report_20260806).  The baseline is generation 1's
# v002, and every generation-2 variant differs from it on exactly one axis; there is
# no v012, because "A3 off the new baseline" IS the vector 311111, which is v003, and
# the report runs that rather than minting a second id for the same vector.
#
# The variant set, the baseline and every axis label below are READ FROM axes.json,
# not restated here: the report puts them there precisely so this builder stops
# carrying a hand-copied dict that drifts from the design.
GEN = 3
POSDIR = "gen%d-pos" % GEN
VARIANTS = ["wordsv2-v%03d" % i for i in (2, 21, 22, 23)]

# 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_gen3_goal_20260810",
              "pprog_p8v2_gen3_report_20260810", "f-p8", "f-p8-deficit",
              "f-p8-period", "f-p8-indegree", "hutter_publication_handoff",
              "variant_protocol"]

LINKS = [("gen3-pos", "gen3-pos"), ("gen2-pos", "gen2-pos"), ("models", "models"),
         ("p8v2-words.md", "p8v2-words.md"), ("gen3.tsv", "gen3.tsv"),
         ("gen2.tsv", "gen2.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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-e64: the k=1 family did not name every byte")

    # k1w IS NOT CHECKED AGAINST THE MODEL, because there is nothing to check it
    # against: the k=1 section is 256 bytes of argmax and stores no weight at all.
    # The query layer reports every one of these patterns at strength 1 while the
    # settling shell applies them at 255 -- see "One pattern, two strengths" on the
    # page. 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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-e64: %s names the bytes differently from the other "
                     "models" % v)
        elif d["sn"]["k1w"] != k1w:
            sys.exit("build-p8v2-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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-gen3-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": [4, 5, 6]}
    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-gen3-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)))
    baseline = axes_doc["baseline"]["variant"]
    if baseline not in VARIANTS:
        sys.exit("build-p8v2-gen3-e64: axes.json says the baseline is %s, which 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"] != axes_doc["baseline"]["axes"]:
        sys.exit("build-p8v2-gen3-e64: %s carries axes %s but axes.json calls the baseline %s"
                 % (baseline, data[baseline]["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-gen3-e64: generation 3 varies axis A only, so every variant must "
                 "carry the same learned model; these do not: %r" % shares)

    # THE COUNT MATRIX, BORROWED, AND WHY THAT IS LEGITIMATE HERE.
    #
    # A4 and A5 set a pattern's rate from ITS OWN SUPPORT, and the support of a k=1
    # pattern is c1[x * 256 + argmax], a value in the 65536-byte learned count matrix
    # that is NOT SERIALIZED in a generation-3 model (its backward section is empty).
    # #f-p8-deficit says so in as many words: "Support is read from the learned tables,
    # which under B1 are in memory but NOT in the model file."  So the rule cannot be
    # computed from a generation-3 model alone, and a page that ran it anyway would be
    # inventing the numbers.
    #
    # It is recoverable exactly, because generation 2's v013 stores that matrix and axis
    # A changes only settling: v013's k=1 table and token section are BYTE-IDENTICAL to
    # the baseline's, which is checked below rather than assumed, so it is the same
    # learned model and its matrix is these variants' matrix.  Borrowing across a
    # DIFFERENT learned model is what the generation-2 page refuses for B2, and the same
    # test decides both.
    owner = "wordsv2-v013"
    c1mod = read_model(os.path.join(mdir, owner + ".m"))
    if learned(c1mod) != learned(base) or not c1mod["lpp"]:
        sys.exit("build-p8v2-gen3-e64: %s is not the same learned model as %s, so its count "
                 "matrix is not these variants' matrix and A4/A5 cannot be driven. Do not "
                 "borrow it." % (owner, baseline))
    c1 = c1mod["lpp"]

    # 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 gen3.tsv RATHER THAN FROM axes.json.
    # axes.json's settling_columns.numbers carries generation-1 and generation-2 and was
    # not extended to 3, so the page's per-axis tables would render empty.  The measured
    # facts are cmpr-src's either way; this reads them from the TSV they publish instead
    # of from the summary that has not caught up.  Raised for them 2026-08-10.
    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-gen3-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 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 = sn_merge(data)

    # THE k=1 STRENGTH IS PINNED TO THIS GENERATION'S RECORD.  Generation 4
    # extirpated the query layer's literal 1: cmpr --patterns now reports the stated
    # k1_strength (8).  This page is FROZEN and its prose is written around the 1 the
    # generation's own query layer printed, so the live reading is replaced by the
    # recorded one rather than baked -- a frozen page renders an old generation out
    # of tools that keep moving (#viz_surfaces, the freeze lesson).
    sn["k1w"] = [1] * 256
    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-gen3-e64: could not dump #%s\n" % b)

    tpl = open(os.path.join(HERE, "p8v2-gen3-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-gen3-e64: no <style> block in the instrument template to "
                 "share with the notes page")
    ntpl = open(os.path.join(HERE, "p8v2-gen3-e64-notes.tpl.html")).read()
    if "<!--STYLE-->" not in ntpl:
        sys.exit("build-p8v2-gen3-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-gen3-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))
