#!/usr/bin/env python3
"""Build the Tersler website: static HTML, no external dependencies.

  python3 site/build.py [--out site/out]

Reads the repo's Markdown docs, the benchmark data in bench/data/ and every
source file, and writes a self-contained site (HTML + one CSS file, charts as
inline SVG, no JavaScript) plus a tarball of the repository. Python 3.8+,
standard library only.
"""
import argparse, csv, datetime, glob, html, io, json, os, re, shutil, tarfile
from collections import defaultdict

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SITE = os.path.join(ROOT, "site")
CSS_VER = __import__("hashlib").sha1(open(os.path.join(SITE, "style.css"), "rb").read()).hexdigest()[:8]

# repo path -> (page, nav title); order = navigation order
PAGES = [
    ("README.md", "index.html", "Overview"),
    ("docs/recreate.md", "rebuild.html", "Rebuild guide"),
    (None, "benchmarks.html", "Benchmarks"),
    ("docs/bom.md", "parts.html", "Parts"),
    ("docs/hardware-notes.md", "hardware.html", "Hardware notes"),
    ("image/README.md", "disk-image.html", "Disk image"),
    ("docs/keeping-up.md", "keeping-up.html", "Keeping up"),
    ("docs/changelog.md", "changelog.html", "Changelog"),
    ("docs/research.md", "research.html", "Research log"),
    ("docs/reference-video-recipe.md", "reference-build.html", "Reference build"),
    ("TODO.md", "todo.html", "TODO"),
    (None, "source/index.html", "Source files"),
]
PAGE_OF = {src: page for src, page, _ in PAGES if src}
PAGE_OF["bench/README.md"] = "benchmarks.html"
# not published (vendor PDFs, build output, local state)
EXCLUDE_DIRS = {".git", "__pycache__", "image/out", "site/out", "docs/reference", ".claude"}
EXCLUDE_LINK = {"docs/reference": "rebuild.html#reference-documents"}
TEXT_EXT = {".md", ".sh", ".py", ".conf", ".example", ".service", ".list", ".patch", ".yaml",
            ".scad", ".txt", ".csv", ".jsonl", ".json", ".css", ""}


# ---------------------------------------------------------------- repo files

def repo_files():
    out = []
    for d, dirs, files in os.walk(ROOT):
        rel = os.path.relpath(d, ROOT).replace(os.sep, "/")
        rel = "" if rel == "." else rel
        dirs[:] = sorted(x for x in dirs if (rel + "/" + x).lstrip("/") not in EXCLUDE_DIRS and x not in EXCLUDE_DIRS)
        for f in sorted(files):
            if f.endswith(".pyc"):
                continue
            out.append((rel + "/" + f).lstrip("/"))
    return out


def is_text(path):
    _, ext = os.path.splitext(path)
    if ext not in TEXT_EXT:
        return False
    try:
        open(os.path.join(ROOT, path), encoding="utf-8").read()
        return True
    except UnicodeDecodeError:
        return False


# ---------------------------------------------------------------- markdown

def slug(text):
    text = re.sub(r"<[^>]+>", "", text).lower()
    text = re.sub(r"[^\w\- ]", "", text)
    return text.replace(" ", "-")


class Md:
    """Markdown subset used by the docs: headings, paragraphs, nested lists
    and task lists, fenced code, tables, blockquotes, rules, inline code,
    bold, italics, links, autolinks and images."""

    def __init__(self, src_path, page, files):
        self.src_dir = os.path.dirname(src_path)
        self.page = page
        self.files = files
        self.toc = []

    # ---- links
    def href(self, url):
        if re.match(r"^[a-z]+:", url) or url.startswith("#"):
            return url
        path, _, frag = url.partition("#")
        target = os.path.normpath(os.path.join(self.src_dir, path)).replace(os.sep, "/").rstrip("/")
        for k, v in EXCLUDE_LINK.items():
            if target == k or target.startswith(k + "/"):
                return rel_link(self.page, v)
        if target in PAGE_OF:
            dest = PAGE_OF[target]
        elif target in self.files:
            dest = "source/" + target + ".html"
        elif any(f.startswith(target + "/") for f in self.files):
            dest = "source/index.html#" + slug(target.replace("/", "-"))
            frag = ""
        else:
            return url
        return rel_link(self.page, dest) + ("#" + frag if frag else "")

    # ---- inline
    def inline(self, s):
        codes = []

        def keep(m):
            codes.append("<code>" + html.escape(m.group(2)) + "</code>")
            return "\x00%d\x00" % (len(codes) - 1)
        s = re.sub(r"(`+)(.+?)\1", keep, s)
        s = html.escape(s, quote=False)
        s = re.sub(r"&lt;(https?://[^\s&]+)&gt;", lambda m: '<a href="%s">%s</a>' % (m.group(1), m.group(1)), s)
        s = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)\)",
                   lambda m: '<img src="%s" alt="%s">' % (html.escape(self.raw_href(m.group(2))), m.group(1)), s)
        s = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)",
                   lambda m: '<a href="%s">%s</a>' % (html.escape(self.href(html.unescape(m.group(2)))), m.group(1)), s)
        s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
        s = re.sub(r"(?<![\w*])\*(?![\s*])(.+?)(?<![\s*])\*(?![\w*])", r"<em>\1</em>", s)
        s = re.sub(r"(?<![\w_])_(?![\s_])(.+?)(?<![\s_])_(?![\w_])", r"<em>\1</em>", s)
        return re.sub(r"\x00(\d+)\x00", lambda m: codes[int(m.group(1))], s)

    def raw_href(self, url):
        if re.match(r"^[a-z]+:", url):
            return url
        target = os.path.normpath(os.path.join(self.src_dir, url)).replace(os.sep, "/")
        return rel_link(self.page, "files/" + target)

    # ---- blocks
    LIST_RE = re.compile(r"^( *)([-*+]|\d+[.)]) +(.*)$")
    FENCE_RE = re.compile(r"^( *)(```+|~~~+)(.*)$")

    def starts_block(self, line):
        return bool(re.match(r"^ {0,3}(#{1,6} |```|~~~|> |\|)", line) or self.LIST_RE.match(line)
                    or re.match(r"^ {0,3}(---+|\*\*\*+)\s*$", line))

    def blocks(self, lines, tight=False):
        out, i = [], 0
        while i < len(lines):
            line = lines[i]
            if not line.strip():
                i += 1
                continue
            m = self.FENCE_RE.match(line)
            if m:
                ind, fence, lang = len(m.group(1)), m.group(2), m.group(3).strip()
                i += 1
                body = []
                while i < len(lines) and not lines[i].strip().startswith(fence):
                    body.append(lines[i][ind:] if lines[i][:ind].strip() == "" else lines[i])
                    i += 1
                i += 1
                cls = ' class="lang-%s"' % html.escape(lang) if lang else ""
                out.append("<pre><code%s>%s</code></pre>" % (cls, html.escape("\n".join(body))))
                continue
            m = re.match(r"^ {0,3}(#{1,6}) +(.*?)#*\s*$", line)
            if m:
                lvl, text = len(m.group(1)), self.inline(m.group(2))
                sid = slug(text)
                self.toc.append((lvl, sid, re.sub(r"<[^>]+>", "", text)))
                out.append('<h%d id="%s"><a class="anchor" href="#%s">#</a>%s</h%d>' % (lvl, sid, sid, text, lvl))
                i += 1
                continue
            if re.match(r"^ {0,3}(---+|\*\*\*+)\s*$", line):
                out.append("<hr>")
                i += 1
                continue
            if line.lstrip().startswith("|") and i + 1 < len(lines) and re.match(r"^\s*\|[\s:|-]+\|\s*$", lines[i + 1]):
                rows = []
                while i < len(lines) and lines[i].lstrip().startswith("|"):
                    rows.append(lines[i])
                    i += 1
                out.append(self.table(rows))
                continue
            if line.lstrip().startswith(">"):
                body = []
                while i < len(lines) and lines[i].strip() and lines[i].lstrip().startswith(">"):
                    body.append(re.sub(r"^\s*> ?", "", lines[i]))
                    i += 1
                out.append("<blockquote>%s</blockquote>" % self.blocks(body))
                continue
            m = self.LIST_RE.match(line)
            if m:
                i = self.listblock(lines, i, out)
                continue
            para = [line.strip()]
            i += 1
            while i < len(lines) and lines[i].strip() and not self.starts_block(lines[i]):
                para.append(lines[i].strip())
                i += 1
            text = self.inline(" ".join(para))
            out.append(text if tight else "<p>%s</p>" % text)
        return "\n".join(out)

    def listblock(self, lines, i, out):
        m = m0 = self.LIST_RE.match(lines[i])
        base = len(m.group(1))
        ordered = m.group(2)[0].isdigit()
        items = []
        while i < len(lines):
            m = self.LIST_RE.match(lines[i])
            if not (m and len(m.group(1)) == base and m.group(2)[0].isdigit() == ordered):
                break
            content_ind = len(m.group(1)) + len(m.group(2)) + 1
            item = [m.group(3)]
            i += 1
            while i < len(lines):
                l = lines[i]
                ind = len(l) - len(l.lstrip())
                if not l.strip():
                    # blank: continues only if the next non-blank line is indented into the item
                    j = i
                    while j < len(lines) and not lines[j].strip():
                        j += 1
                    if j < len(lines) and len(lines[j]) - len(lines[j].lstrip()) > base:
                        item.append("")
                        i += 1
                        continue
                    break
                if ind <= base and (self.LIST_RE.match(l) or self.starts_block(l)):
                    break
                item.append(l[min(ind, content_ind):] if ind > base else l)
                i += 1
            items.append(item)
        n = int(re.match(r"\d*", m0.group(2)).group(0) or 1)
        start = ' start="%d"' % n if ordered and n != 1 else ""
        tag = "ol" if ordered else "ul"
        lis = []
        for item in items:
            tight = "" not in item
            box = ""
            mm = re.match(r"^\[([ xX])\] (.*)$", item[0])
            if mm:
                box = '<span class="box%s">%s</span> ' % (" done" if mm.group(1) != " " else "",
                                                         "✓" if mm.group(1) != " " else "")
                item = [mm.group(2)] + item[1:]
            lis.append("<li%s>%s%s</li>" % (' class="task"' if box else "", box, self.blocks(item, tight)))
        out.append("<%s%s>\n%s\n</%s>" % (tag, start, "\n".join(lis), tag))
        return i

    def table(self, rows):
        def cells(row):
            row = row.strip().strip("|")
            parts, cur, code = [], "", False
            for ch in row:
                if ch == "`":
                    code = not code
                if ch == "|" and not code:
                    parts.append(cur)
                    cur = ""
                else:
                    cur += ch
            parts.append(cur)
            return [p.strip() for p in parts]
        head, aligns = cells(rows[0]), cells(rows[1])
        al = []
        for a in aligns:
            al.append(' class="r"' if a.endswith(":") and not a.startswith(":") else "")
        h = "".join("<th>%s</th>" % self.inline(c) for c in head)
        body = []
        for r in rows[2:]:
            cs = cells(r)
            body.append("<tr>%s</tr>" % "".join("<td%s>%s</td>" % (al[k] if k < len(al) else "", self.inline(c))
                                                for k, c in enumerate(cs)))
        return '<div class="tw"><table><thead><tr>%s</tr></thead><tbody>\n%s\n</tbody></table></div>' % (h, "\n".join(body))

    def render(self, text):
        return self.blocks(text.replace("\t", "    ").split("\n"))


def rel_link(from_page, to_page):
    up = from_page.count("/")
    return "../" * up + to_page


# ---------------------------------------------------------------- page shell

def shell(page, title, body, toc=None, desc=""):
    nav = []
    for _, p, t in PAGES:
        cur = ' aria-current="page"' if p == page else ""
        nav.append('<a href="%s"%s>%s</a>' % (rel_link(page, p), cur, t))
    nav.append('<a href="%s">Download (.tar.gz)</a>' % rel_link(page, "tersler.tar.gz"))
    side = ""
    if toc:
        items = "".join('<a class="l%d" href="#%s">%s</a>' % (l, s, html.escape(t)) for l, s, t in toc if l in (2, 3))
        if items:
            side = '<nav class="toc"><div class="toch">On this page</div>%s</nav>' % items
    return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s · Tersler</title>
<meta name="description" content="%s">
<link rel="stylesheet" href="%s?v=%s">
</head>
<body>
<header class="top"><a class="brand" href="%s">Tersler</a><span class="tag">The little junk rig that could</span></header>
<div class="wrap">
<nav class="side">%s</nav>
<main>
%s
</main>
%s
</div>
<footer>Built %s from the Tersler repository · <a href="%s">source</a> · no cookies, no scripts, no external requests</footer>
</body>
</html>
""" % (html.escape(title), html.escape(desc), rel_link(page, "style.css"), CSS_VER, rel_link(page, "index.html"),
       "\n".join(nav), body, side, datetime.date.today().isoformat(), rel_link(page, "source/index.html"))


# ---------------------------------------------------------------- charts (inline SVG)

def bar_chart(groups, series, values, ymax=100, unit="%", height=260, label_fmt="{:.0f}"):
    """groups: x labels; series: legend names; values[s][g]."""
    W, H, L, B, T = 720, height, 40, 34, 14
    gw = (W - L - 10) / len(groups)
    bw = min(26, (gw - 14) / len(series))
    p = ['<svg class="chart" viewBox="0 0 %d %d" role="img">' % (W, H)]
    for k in range(0, 5):
        v = ymax * k / 4
        y = H - B - (H - B - T) * k / 4
        p.append('<line class="grid" x1="%d" x2="%d" y1="%.1f" y2="%.1f"/>' % (L, W - 10, y, y))
        p.append('<text class="ax" x="%d" y="%.1f" text-anchor="end">%s</text>' % (L - 6, y + 4, label_fmt.format(v)))
    for g, name in enumerate(groups):
        x0 = L + g * gw + (gw - bw * len(series)) / 2
        for s in range(len(series)):
            v = values[s][g]
            if v is None:
                continue
            h = (H - B - T) * min(v, ymax) / ymax
            x = x0 + s * bw
            p.append('<rect class="s%d" x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="2"><title>%s, %s: %s%s</title></rect>'
                     % (s, x + 1, H - B - h, bw - 2, h, html.escape(series[s]), html.escape(name), label_fmt.format(v), unit))
            p.append('<text class="val" x="%.1f" y="%.1f" text-anchor="middle">%s</text>' % (x + bw / 2, H - B - h - 4, label_fmt.format(v)))
        p.append('<text class="ax" x="%.1f" y="%d" text-anchor="middle">%s</text>' % (L + g * gw + gw / 2, H - B + 20, html.escape(name)))
    p.append("</svg>")
    leg = "".join('<span><i class="s%d"></i>%s</span>' % (s, html.escape(n)) for s, n in enumerate(series))
    return '<figure>%s<figcaption class="legend">%s</figcaption></figure>' % ("".join(p), leg)


def line_chart(series, ymin, ymax, xmax, hlines=(), height=220, xunit="min"):
    W, H, L, B, T = 720, height, 40, 30, 10
    sx = lambda x: L + (W - L - 10) * x / xmax
    sy = lambda y: H - B - (H - B - T) * (y - ymin) / (ymax - ymin)
    p = ['<svg class="chart" viewBox="0 0 %d %d" role="img">' % (W, H)]
    for k in range(5):
        v = ymin + (ymax - ymin) * k / 4
        p.append('<line class="grid" x1="%d" x2="%d" y1="%.1f" y2="%.1f"/>' % (L, W - 10, sy(v), sy(v)))
        p.append('<text class="ax" x="%d" y="%.1f" text-anchor="end">%.0f</text>' % (L - 6, sy(v) + 4, v))
    for k in range(6):
        x = xmax * k / 5
        p.append('<text class="ax" x="%.1f" y="%d" text-anchor="middle">%.0f %s</text>' % (sx(x), H - 8, x, xunit))
    for v, name in hlines:
        p.append('<line class="limit" x1="%d" x2="%d" y1="%.1f" y2="%.1f"/><text class="ax lim" x="%d" y="%.1f">%s</text>'
                 % (L, W - 10, sy(v), sy(v), L + 4, sy(v) - 4, html.escape(name)))
    for s, (name, pts) in enumerate(series):
        d = " ".join("%.1f,%.1f" % (sx(x), sy(y)) for x, y in pts)
        p.append('<polyline class="ln s%d" points="%s"><title>%s</title></polyline>' % (s, d, html.escape(name)))
    p.append("</svg>")
    leg = "".join('<span><i class="s%d"></i>%s</span>' % (s, html.escape(n)) for s, (n, _) in enumerate(series))
    return '<figure>%s<figcaption class="legend">%s</figcaption></figure>' % ("".join(p), leg)


STEP_NAMES = {"root_turn1": "root, turn 1", "root_turn2": "root, turn 2", "sub0": "subagent 1", "sub1": "subagent 2",
              "sub2": "subagent 3", "a_cold": "a) cold", "b_append_turn": "b) turn appended",
              "c_edit_middle": "c) middle edited", "d_other_slot_same_prefix": "d) other slot, same prefix"}


def num(v):
    try:
        return float(v)
    except ValueError:
        return 0.0


def table(head, rows, right=()):
    h = "".join("<th%s>%s</th>" % (' class="r"' if k in right else "", c) for k, c in enumerate(head))
    b = "\n".join("<tr>%s</tr>" % "".join("<td%s>%s</td>" % (' class="r"' if k in right else "", c) for k, c in enumerate(r)) for r in rows)
    return '<div class="tw"><table><thead><tr>%s</tr></thead><tbody>%s</tbody></table></div>' % (h, b)


# ---------------------------------------------------------------- benchmark data

def lean_bench(notes):
    runs = []
    for fn, (model, setup) in notes.items():
        path = os.path.join(ROOT, "bench/data/lean_bench", fn)
        if not os.path.exists(path):
            continue
        items = [json.loads(l) for l in open(path) if l.strip()]
        by = defaultdict(list)
        for r in items:
            by[(r["category"], r["level"])].append(r["correct"])
        cats = {}
        for cat in ("math", "logic", "code"):
            lv = [sum(by[(cat, l)]) / len(by[(cat, l)]) for l in (1, 2, 3) if by[(cat, l)]]
            n = [r for r in items if r["category"] == cat]
            cats[cat] = {"acc": sum(r["correct"] for r in n) / len(n), "bal": sum(lv) / len(lv), "n": len(n)}
        fam = defaultdict(list)
        for r in items:
            fam[(r["category"], r["family"])].append(r)
        runs.append({"file": fn, "model": model, "setup": setup, "items": items, "cats": cats, "fam": fam,
                     "overall": sum(c["bal"] for c in cats.values()) / 3,
                     "trunc": sum(1 for r in items if r["finish_reason"] == "length"),
                     "hours": sum(r["seconds"] or 0 for r in items) / 3600,
                     "tok": sum(r["completion_tokens"] or 0 for r in items),
                     "cap": "32k" if "32k" in fn else "16k"})
    return runs


def benchmarks_page(notes, md_intro):
    page = "benchmarks.html"
    toc = [(2, "lean_bench", "lean_bench (quality)"), (2, "decode", "Decode speed (tersler-bench)"),
           (2, "speed", "Prefill and decode (speed test)"), (2, "multi-session", "Multi-session tests"), (2, "tools", "Tool calling"), (2, "thermals", "Thermals (load tests)"),
           (2, "raw-data", "Raw data")]
    out = ['<h1>Benchmarks</h1>',
           '<p class="lead">All numbers come from the files in <a href="%s">bench/data/</a> and are recomputed on every site build.</p>'
           % rel_link(page, "source/index.html#bench-data")]

    # ---- lean_bench
    runs = lean_bench(notes["lean_bench"])
    runs32 = [r for r in runs if r["cap"] == "32k"]
    out.append('<h2 id="lean_bench"><a class="anchor" href="#lean_bench">#</a>lean_bench (quality)</h2>')
    out.append("<p>158 generated items (72 math, 42 logic, 44 code) at three difficulty levels, seed 1, "
               "temperature 1.0, top_p 0.95, top_k 20, thinking on, one request at a time. "
               "The overall score is the mean over the three categories of the level-balanced accuracy. "
               "One seed at temperature 1.0 means roughly ±10–15 points per category.</p>")
    names = ["%s (%s)" % (r["model"], r["setup"].split(",")[0]) for r in runs32]
    vals = [[r["cats"][c]["acc"] * 100 for c in ("math", "logic", "code")] + [r["overall"] * 100] for r in runs32]
    out.append(bar_chart(["Math", "Logic", "Code", "Overall"], names, vals))
    rows = []
    for r in sorted(runs, key=lambda r: -r["overall"]):
        rows.append(["<strong>%s</strong><br><small>%s</small>" % (html.escape(r["model"]), html.escape(r["setup"])),
                     "%.1f%%" % (r["cats"]["math"]["acc"] * 100), "%.1f%%" % (r["cats"]["logic"]["acc"] * 100),
                     "%.1f%%" % (r["cats"]["code"]["acc"] * 100), str(r["trunc"]),
                     "%.1f h" % r["hours"], "<strong>%.3f</strong>" % r["overall"]])
    out.append(table(["Model and setup", "Math", "Logic", "Code", "Cut off", "Time", "Overall"], rows, right=(1, 2, 3, 4, 5, 6)))
    out.append("<p><em>Cut off</em> = the answer hit the token cap before it finished; it counts as wrong. "
               "Time is wall time on one machine, so it is not comparable between Tersler and moppel.</p>")
    # per family
    fams = sorted({k for r in runs32 for k in r["fam"]})
    rows = []
    for cat, fam in fams:
        row = ["%s/%s" % (cat, fam)]
        for r in runs32:
            it = r["fam"].get((cat, fam), [])
            ok = sum(x["correct"] for x in it)
            cut = sum(1 for x in it if x["finish_reason"] == "length")
            cls = "good" if ok == len(it) else ("bad" if ok * 2 < len(it) else "")
            row.append('<span class="%s">%d/%d</span>%s' % (cls, ok, len(it), " <small>(%d cut)</small>" % cut if cut else ""))
        rows.append(row)
    out.append("<details><summary>Per task family (32k cap)</summary>%s</details>"
               % table(["Family"] + [html.escape(r["model"]) for r in runs32], rows, right=tuple(range(1, len(runs32) + 1))))

    # ---- decode
    out.append('<h2 id="decode"><a class="anchor" href="#decode">#</a>Decode speed (tersler-bench)</h2>')
    out.append(md_intro)
    rows = list(csv.DictReader(open(os.path.join(ROOT, "bench/data/results.csv"))))
    groups = []  # one bench run = consecutive rows, one per prompt, same build and power
    for r in rows:
        g = groups[-1] if groups else None
        if not g or r["prompt"] in g or any(x["commit"] != r["commit"] or x["power_limit_w"] != r["power_limit_w"] for x in g.values()):
            groups.append({})
        groups[-1][r["prompt"]] = r
    trs = []
    for pr in groups:
        r0 = min(pr.values(), key=lambda x: x["date"])
        commit, pw = r0["commit"], r0["power_limit_w"]
        note = next((v for k, v in notes["results"].items() if r0["date"].startswith(k)), "")
        def cell(p, f, fmt="%.1f"):
            return fmt % float(pr[p][f]) if p in pr else "—"
        acc = [float(pr[p]["draft_accept"]) for p in pr if pr[p]["draft_accept"]]
        first = min(x["date"] for x in pr.values())
        trs.append(["<span class=\"nw\">%s</span>" % first[5:16].replace("T", " "), "<code>%s</code>" % commit, "%.0f W" % float(pw),
                    cell("puzzle", "tg_tok_s"), cell("document", "tg_tok_s"), cell("code", "tg_tok_s"),
                    cell("document", "pp_tok_s", "%.0f"), "%.2f" % (sum(acc) / len(acc)) if acc else "—", "<small>%s</small>" % html.escape(note)])
    out.append(table(["Run (2026)", "Build", "Power", "tg puzzle", "tg doc", "tg code", "pp doc", "Draft accept", "Note"],
                     trs, right=(3, 4, 5, 6, 7)))
    out.append("<p>tg = decode tok/s, pp = prompt processing tok/s (~5k-token document). Model: Qwen3.8 27B UD-Q6_K "
               "with MTP speculative decoding, both cards with <code>-sm tensor</code>. Medians of 3 runs, 512 tokens, thinking on.</p>")

    # ---- speed test
    path = os.path.join(ROOT, "bench/data/speed.jsonl")
    if os.path.exists(path):
        recs = [json.loads(l) for l in open(path) if l.strip()]
        sn = notes.get("speed", {})
        out.append('<h2 id="speed"><a class="anchor" href="#speed">#</a>Prefill and decode (speed test)</h2>')
        out.append('<p>From <a href="%s">scripts/speed-test.py</a>: cold prompts (nothing cached), thinking off, up to 512 tokens, '
                   'timed on the client. Prefill = prompt tokens / time to first token. Two runs per size.</p>'
                   % rel_link(page, "source/scripts/speed-test.py.html"))
        trs = []
        for lab in dict.fromkeys(r["label"] for r in recs):
            for size in dict.fromkeys(r["size"] for r in recs if r["label"] == lab):
                rs = [r for r in recs if r["label"] == lab and r["size"] == size]
                pt = max(r["prompt_tokens"] for r in rs)
                trs.append([html.escape(sn.get(lab, lab)), "{:,}".format(pt),
                            " / ".join("%.0f" % r["pp_tok_s"] for r in rs) if size else "—",
                            " / ".join("%.1f" % r["tg_tok_s"] for r in rs if r["tg_tok_s"])])
        out.append(table(["Engine and setup", "Prompt tokens", "Prefill tok/s", "Decode tok/s"], trs, right=(1, 2, 3)))

    # ---- multi-session
    out.append('<h2 id="multi-session"><a class="anchor" href="#multi-session">#</a>Multi-session tests</h2>')
    out.append('<p>From <a href="%s">scripts/concurrency-test.py</a>; background in the '
               '<a href="research.html#multi-session-test-2026-11-07">research log</a>.</p>' % rel_link(page, "source/scripts/concurrency-test.py.html"))
    recs = [json.loads(l) for l in open(os.path.join(ROOT, "bench/data/concurrency.jsonl")) if l.strip()]
    cn = notes["concurrency"]
    conc = [r for r in recs if r["test"] == "concurrency"]
    trs = [[html.escape(cn.get(r["label"], r["label"])), str(r["n"]), "%.1f" % r["total_tok_s"],
            " · ".join("%.1f" % x for x in r["per_req_tg"]), "%.2f" % r["accept"] if r.get("accept") else "—"] for r in conc]
    out.append("<h3>Parallel requests</h3>")
    out.append(table(["Server config", "Parallel", "Total tok/s", "Per request tok/s", "Draft accept"], trs, right=(1, 2, 3, 4)))
    for test, title, desc in (("swarm", "Swarm: root agent + 3 subagents sharing a 29k-token system prompt",
                               "prompt_n = tokens the server had to process; cached = tokens reused from the cache."),
                              ("replay", "Replay: prompt reuse in an agent loop",
                               "a) cold ~26k-token prompt, b) one more turn appended, c) a tool output edited in the middle, "
                               "d) the same system prompt in another slot (a subagent).")):
        labels = []
        for r in recs:
            if r["test"] == test and r["label"] not in labels:
                labels.append(r["label"])
        steps = []
        for r in recs:
            if r["test"] == test and r["step"] not in steps:
                steps.append(r["step"])
        trs = []
        for lab in labels:
            row = [html.escape(cn.get(lab, lab))]
            for s in steps:
                r = next((x for x in recs if x["test"] == test and x["label"] == lab and x["step"] == s), None)
                row.append("%s s<br><small>%s new<br>%s cached</small>" % (r["wall"], "{:,}".format(r["prompt_n"]), "{:,}".format(r["cache_n"])) if r else "—")
            trs.append(row)
        out.append("<h3>%s</h3><p>%s</p>" % (html.escape(title), desc))
        out.append(table(["Server config"] + [html.escape(STEP_NAMES.get(x, x)) for x in steps], trs, right=tuple(range(1, len(steps) + 1))))

    # ---- tool calling
    path = os.path.join(ROOT, "bench/data/tools.jsonl")
    if os.path.exists(path):
        recs = [json.loads(l) for l in open(path) if l.strip()]
        out.append('<h2 id="tools"><a class="anchor" href="#tools">#</a>Tool calling</h2>')
        out.append('<p>From <a href="%s">scripts/tool-test.py</a>: short conversations with tool definitions, thinking on, '
                   'each case three times. The money case is a rent total with late fees (expected 801.69 EUR), with a '
                   'calculator tool offered.</p>' % rel_link(page, "source/scripts/tool-test.py.html"))
        labs = list(dict.fromkeys(r["label"] for r in recs))
        cases = list(dict.fromkeys(r["case"] for r in recs))
        trs = []
        for c in cases:
            row = [c.replace("_", " ")]
            for lab in labs:
                rs = [r for r in recs if r["label"] == lab and r["case"] == c]
                ok = sum(r["ok"] for r in rs)
                row.append('<span class="%s">%d/%d</span>' % ("good" if ok == len(rs) else "bad", ok, len(rs)) if rs else "—")
            trs.append(row)
        out.append(table(["Case"] + [html.escape(notes.get("tools", {}).get(l, l)) for l in labs], trs,
                         right=tuple(range(1, len(labs) + 1))))

    # ---- thermals
    out.append('<h2 id="thermals"><a class="anchor" href="#thermals">#</a>Thermals (load tests)</h2>')
    out.append("<p>From <code>tersler-loadtest</code>: sustained decode with 4096-token answers. The P100 slows down at "
               "82 °C and shuts down at 85 °C. The fan follows the hotter card.</p>")
    trs = []
    for f in sorted(glob.glob(os.path.join(ROOT, "bench/data/loadtest-*.csv"))):
        data = list(csv.DictReader(open(f)))
        g = defaultdict(list)
        for r in data:
            g[r["gpu"]].append((int(r["time_s"]) / 60, float(r["temp_c"]), float(r["power_w"]), num(r["pwm"]), num(r["fan_rpm"])))
        xmax = max(p[0] for v in g.values() for p in v) or 1
        name = os.path.basename(f)[9:-4]
        when = "%s-%s-%s %s:%s" % (name[:4], name[4:6], name[6:8], name[9:11], name[11:13])
        out.append("<h3>%s</h3>" % when)
        out.append(line_chart([("GPU %s °C" % k, [(p[0], p[1]) for p in v]) for k, v in sorted(g.items())],
                              30, 90, xmax, hlines=[(82, "82 °C slowdown")]))
        trs.append([when, "%.0f min" % xmax] +
                   ["%.0f / %.0f °C" % tuple(max(p[1] for p in g[k]) for k in sorted(g))] +
                   ["%.0f / %.0f W" % tuple(sum(p[2] for p in g[k]) / len(g[k]) for k in sorted(g))] +
                   ["%d rpm" % max(p[4] for v in g.values() for p in v) if any(p[4] for v in g.values() for p in v) else "—"])
    out.append(table(["Run", "Length", "Max temp (GPU 0 / 1)", "Avg power", "Max fan"], trs, right=(1, 2, 3, 4)))

    # ---- raw
    out.append('<h2 id="raw-data"><a class="anchor" href="#raw-data">#</a>Raw data</h2><ul>')
    for f in sorted(glob.glob(os.path.join(ROOT, "bench/data/**/*.*"), recursive=True)):
        rel = os.path.relpath(f, ROOT).replace(os.sep, "/")
        out.append('<li><a href="files/%s">%s</a></li>' % (rel, rel))
    out.append("</ul>")
    return shell(page, "Benchmarks", "\n".join(out), toc, "Tersler benchmark results"), runs


# ---------------------------------------------------------------- main

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", default=os.path.join(SITE, "out"))
    out = ap.parse_args().out
    if os.path.isdir(out):
        shutil.rmtree(out)
    os.makedirs(out)
    files = [f for f in repo_files() if not f.startswith("site/out/")]
    notes = json.load(open(os.path.join(ROOT, "bench/data/notes.json")))

    def write(page, text):
        p = os.path.join(out, page)
        os.makedirs(os.path.dirname(p), exist_ok=True)
        with open(p, "w", encoding="utf-8") as f:
            f.write(text)

    # raw files
    for f in files:
        dst = os.path.join(out, "files", f)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        shutil.copy2(os.path.join(ROOT, f), dst)
    shutil.copy2(os.path.join(SITE, "style.css"), os.path.join(out, "style.css"))
    shutil.copytree(os.path.join(SITE, "img"), os.path.join(out, "img"))

    # benchmarks
    md = Md("bench/README.md", "benchmarks.html", files)
    intro = md.render(re.sub(r"^# .*\n", "", open(os.path.join(ROOT, "bench/README.md")).read()))
    bench_html, runs = benchmarks_page(notes, intro)
    write("benchmarks.html", bench_html)

    # doc pages
    for src, page, title in PAGES:
        if not src:
            continue
        md = Md(src, page, files)
        body = md.render(open(os.path.join(ROOT, src), encoding="utf-8").read())
        if page == "index.html":
            body = overview(runs) + body
        if page == "hardware.html":
            imgs = [f for f in files if f.startswith("shroud/") and f.endswith(".png")]
            body += '<h2 id="duct-renders">Duct renders</h2><div class="gallery">%s</div>' % "".join(
                '<a href="files/%s"><img src="files/%s" alt="%s" loading="lazy"></a>' % (f, f, os.path.basename(f)) for f in imgs)
            md.toc.append((2, "duct-renders", "Duct renders"))
        write(page, shell(page, title, body, md.toc if page != "index.html" else None, title + " · Tersler"))

    # source browser
    tree = defaultdict(list)
    for f in files:
        tree[os.path.dirname(f)].append(f)
    parts = ['<h1>Source files</h1><p class="lead">Everything in the repository except build output. '
             'Download it all as <a href="../tersler.tar.gz">tersler.tar.gz</a>. '
             'Start with the <a href="../rebuild.html">rebuild guide</a>.</p>']
    toc = []
    for d in sorted(tree):
        name = d or "(top level)"
        sid = slug(d.replace("/", "-")) if d else "top"
        toc.append((2, sid, name))
        rows = []
        for f in tree[d]:
            size = os.path.getsize(os.path.join(ROOT, f))
            if is_text(f):
                link = '<a href="%s.html">%s</a>' % (f, os.path.basename(f))
            else:
                link = '<a href="../files/%s">%s</a>' % (f, os.path.basename(f))
            rows.append([link, "%.1f kB" % (size / 1024)])
        parts.append('<h2 id="%s"><a class="anchor" href="#%s">#</a>%s/</h2>' % (sid, sid, html.escape(name)))
        parts.append(table(["File", "Size"], rows, right=(1,)))
    write("source/index.html", shell("source/index.html", "Source files", "\n".join(parts), toc))
    for f in files:
        if not is_text(f):
            continue
        page = "source/" + f + ".html"
        text = open(os.path.join(ROOT, f), encoding="utf-8").read()
        lines = text.split("\n")
        if lines and lines[-1] == "":
            lines.pop()
        body = '<p class="crumb"><a href="%s">Source files</a> / %s · <a href="%s">raw</a>%s</p><h1>%s</h1>' % (
            rel_link(page, "source/index.html"), html.escape(os.path.dirname(f) or "."), rel_link(page, "files/" + f),
            ' · <a href="%s">rendered</a>' % rel_link(page, PAGE_OF[f]) if f in PAGE_OF else "", html.escape(os.path.basename(f)))
        body += '<pre class="src">%s</pre>' % "".join('<span class="ln">%s\n</span>' % html.escape(l) for l in lines)
        write(page, shell(page, f, body))

    # tarball of the repo (same file set)
    with tarfile.open(os.path.join(out, "tersler.tar.gz"), "w:gz") as tar:
        for f in files:
            tar.add(os.path.join(ROOT, f), arcname="tersler/" + f, recursive=False)
    n = sum(1 for _ in glob.glob(os.path.join(out, "**/*.html"), recursive=True))
    print("site: %s (%d pages, %d files)" % (out, n, len(files)))


def overview(runs):
    # the serving setup: Flash-Next on Strata (Tersler run only; the same model also ran on another box)
    best = {}
    for r in sorted(runs, key=lambda r: r["overall"]):
        best[(r["model"], r["setup"].startswith("Tersler"))] = r
    speed = [json.loads(l) for l in open(os.path.join(ROOT, "bench/data/speed.jsonl")) if l.strip()]
    speed = [r for r in speed if r["label"].startswith("strata")]
    short = [r["tg_tok_s"] for r in speed if r["size"] == 0]
    deep = [r for r in speed if r["size"] == max(x["size"] for x in speed)]
    total = re.search(r"\*\*Total\*\*.*?€([\d.]+)", open(os.path.join(ROOT, "docs/bom.md")).read())
    cards = [("€%.0f" % float(total.group(1)) if total else "—", "all parts, incl. 2 P100 cards and 64 GB RAM")]
    if short:
        cards.append(("%.0f–%.0f tok/s" % (min(short), max(short)), "Qwen3.8 Flash-Next IQ3_S decode on Strata, MTP"))
    if deep:
        n = sum(r["prompt_tokens"] for r in deep) / len(deep)
        cards.append(("%.0f tok/s" % (sum(r["pp_tok_s"] for r in deep) / len(deep)),
                      "prefill of a %.0fk-token prompt; decode there %.0f tok/s" % (n / 1000, sum(r["tg_tok_s"] for r in deep) / len(deep))))
    cards += [("262k", "context, int8 KV; recall held at 204k tokens"), ("Text + images", "vision encoder on the CPU"),
              ("≤ 75 °C", "under sustained load, 150 W per card")]
    for (m, eng) in (("Qwen3.8 Flash-Next IQ3_S", "Strata"), ("Qwen3.8 27B Q6_K", "llama.cpp, the alternative")):
        r = best.get((m, True))
        if r:
            cards.append(("%.3f" % r["overall"], "lean_bench, %s (%s)" % (m.replace("Qwen3.8 ", ""), eng)))
    return '<div class="hero"><figure><img src="img/ermahgerd-p100.png" alt="Ermahgerd meme: a girl holding two Tesla P100 cards, captioned ERMAHGERD, TERSLER P-HERNDRERDS!" width="804" height="1208"></figure><div class="cards">%s</div></div><p class="lead"><a href="rebuild.html">Rebuild guide →</a> · <a href="benchmarks.html">All benchmarks →</a></p>' % "".join(
        '<div class="card"><b>%s</b><span>%s</span></div>' % (html.escape(a), html.escape(b)) for a, b in cards)


if __name__ == "__main__":
    main()
