"""../report/index.html from points.csv, collide.csv and the figures (rerun at any time).

    env -u PYTHONPATH .venv/bin/python scripts/report.py
"""
import csv
import html
import time
from collections import defaultdict
from pathlib import Path

import numpy as np

from common import CUT_BYTES, CUT_LEFT, LAYERS, M, MIN_AREA, N, NT, RULE, SAMPLES

REPORT = Path(__file__).resolve().parent.parent / "report"
NOTES = (Path(__file__).resolve().parent / "notes.html")
CSS = """
:root{--bg:#fafaf8;--fg:#1d1d1b;--mut:#6b6b66;--line:#ddd9d0;--acc:#2f6f8f;--bad:#b4442a;--good:#2e7d4f;--head:#efece5;--box:#f1efe9}
@media (prefers-color-scheme:dark){:root{--bg:#1a1a19;--fg:#f0efec;--mut:#a8a79f;--line:#3a3935;--acc:#6aa9c9;--bad:#e57a62;--good:#6cc08e;--head:#262624;--box:#232321}}
body{background:var(--bg);color:var(--fg);font:15px/1.55 system-ui,sans-serif;margin:0 auto;max-width:1180px;padding:24px 16px}
h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:36px 0 8px;border-bottom:1px solid var(--line);padding-bottom:4px}
h3{font-size:16px;margin:22px 0 6px}.mut{color:var(--mut)}
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums;font-size:12.5px}
th,td{padding:4px 7px;border-bottom:1px solid var(--line);text-align:right}th{background:var(--head)}
td.l,th.l{text-align:left}td.good{color:var(--good);font-weight:600}td.bad{color:var(--bad);font-weight:600}
tr.ours td{background:var(--box)}.wrap{overflow-x:auto}code{background:var(--head);padding:1px 4px;border-radius:3px}
img{max-width:100%;border:1px solid var(--line);background:#fff;margin:6px 0}
.box{border-left:4px solid var(--acc);padding:6px 12px;background:var(--box);margin:10px 0}
tr.filters th{background:var(--bg);padding:2px 3px}tr.filters input{width:100%;min-width:3em;box-sizing:border-box;font:inherit;
font-size:11.5px;padding:2px 4px;border:1px solid var(--line);border-radius:3px;background:var(--bg);color:var(--fg)}
"""
FILTER_JS = (Path(__file__).resolve().parent / "table_filter.js").read_text()


def load(name):
    p = REPORT / name
    return list(csv.DictReader(p.open())) if p.exists() else []


def gmean(v):
    v = np.asarray(v, float)
    return float(np.exp(np.log(v).mean())) if len(v) else float("nan")


def fmt(v, d=3):
    return f"{v:,.{d}g}" if isinstance(v, float) else html.escape(str(v))


def points_table(R, kind, n):
    by = defaultdict(list)
    objs = sorted({r["object"] for r in R}, key=[r["object"] for r in R].index)
    for r in R:
        if r["points"] == kind and int(r["n"]) == n:
            by[r["method"]].append(r)
    ref = gmean([float(r["ns_per_point"]) for r in by.get("Open3D occupancy (Embree)", [])]) if by.get("Open3D occupancy (Embree)") else None
    head = "".join(f"<th>{o}<br>ns/pt</th>" for o in objs)
    out = [f"<div class='wrap'><table><tr><th class='l'>method</th><th>device</th>{head}<th>geo-mean<br>ns/pt</th>"
           f"<th>× vs<br>Open3D</th><th>build ms<br>(mean)</th><th>wrong vs<br>mesh %</th><th>wrong vs<br>decoded %</th></tr>"]
    for m, rs in sorted(by.items(), key=lambda kv: gmean([float(r["ns_per_point"]) for r in kv[1]])):
        per = {r["object"]: float(r["ns_per_point"]) for r in rs}
        g = gmean(list(per.values()))
        cells = "".join(f"<td>{per[o]:,.3g}</td>" if o in per else "<td></td>" for o in objs)
        sp = f"{ref / g:.1f}" if ref else ""
        errd = [float(r["err_decoded"]) for r in rs if r["err_decoded"] != ""]
        out.append(f"<tr class='{'ours' if m.startswith('FCP') else ''}'><td class='l'>{m}</td><td>{rs[0]['device']}</td>{cells}"
                   f"<td><b>{g:,.3g}</b></td><td>{sp}</td><td>{np.mean([float(r['build_ms']) for r in rs]):,.3g}</td>"
                   f"<td>{100 * np.mean([float(r['err_mesh']) for r in rs]):.2f}</td>"
                   f"<td>{100 * np.mean(errd):.2f}</td></tr>" if errd else
                   f"<tr class='{'ours' if m.startswith('FCP') else ''}'><td class='l'>{m}</td><td>{rs[0]['device']}</td>{cells}"
                   f"<td><b>{g:,.3g}</b></td><td>{sp}</td><td>{np.mean([float(r['build_ms']) for r in rs]):,.3g}</td>"
                   f"<td>{100 * np.mean([float(r['err_mesh']) for r in rs]):.2f}</td><td></td></tr>")
    out.append("</table></div>")
    return "\n".join(out)


def collide_table(R):
    pairs = sorted({r["pair"] for r in R}, key=[r["pair"] for r in R].index)
    by = defaultdict(dict)
    for r in R:
        by[r["method"]][r["pair"]] = r
    ref = gmean([float(r["us_per_pose"]) for r in by.get("Coal (BVH, tri-tri)", {}).values()])
    head = "".join(f"<th>{p}<br>µs/pose</th>" for p in pairs)
    out = [f"<div class='wrap'><table><tr><th class='l'>method</th><th>device</th>{head}<th>geo-mean<br>µs/pose</th>"
           f"<th>× vs<br>Coal</th><th>build ms<br>(mean)</th><th>accuracy<br>vs mesh %</th><th>missed</th><th>false</th>"
           f"<th>accuracy vs<br>decoded %</th></tr>"]
    for m, d in sorted(by.items(), key=lambda kv: gmean([float(r["us_per_pose"]) for r in kv[1].values()])):
        rs = list(d.values())
        g = gmean([float(r["us_per_pose"]) for r in rs])
        cells = "".join(f"<td>{float(d[p]['us_per_pose']):,.3g}</td>" if p in d else "<td></td>" for p in pairs)
        out.append(f"<tr class='{'ours' if m.startswith('FCP') else ''}'><td class='l'>{m}</td><td>{rs[0]['device']}</td>{cells}"
                   f"<td><b>{g:,.3g}</b></td><td>{ref / g:.1f}</td><td>{np.mean([float(r['build_ms']) for r in rs]):,.4g}</td>"
                   f"<td>{100 * np.mean([float(r['acc']) for r in rs]):.2f}</td><td>{sum(int(r['fn']) for r in rs)}</td>"
                   f"<td>{sum(int(r['fp']) for r in rs)}</td><td>{100 * np.mean([float(r['acc_decoded']) for r in rs]):.2f}</td></tr>")
    out.append("</table></div>")
    info = [f"{p}: {by[next(iter(by))][p]['faces_A']} / {by[next(iter(by))][p]['faces_B']} faces, "
            f"{100 * float(by[next(iter(by))][p]['collide_frac']):.0f} % of {by[next(iter(by))][p]['poses']} poses collide" for p in pairs]
    return "\n".join(out) + "<p class='mut'>" + "; ".join(info) + "</p>"


def section(n, tag, title):
    P, C = load(f"points{tag}.csv"), load(f"collide{tag}.csv")
    img = REPORT / "img"
    out = [f"<h2>{n}. {title}</h2>"] if (P or C) else []
    if tag and (REPORT / "complex_notes.html").exists():
        out.append((REPORT / "complex_notes.html").read_text())
    if P:
        out += [f"<h3>{n}a. Inside-outside point queries</h3>", f"<img src='img/points_speed{tag}.png'>",
                "<h3>Near the surface (±2 % of the diagonal), 1M points</h3>", points_table(P, "near", 1_000_000),
                "<h3>Uniform in the box padded by 10 %, 1M points</h3>", points_table(P, "uniform", 1_000_000),
                "<h3>Small batches: near the surface, 10k points</h3>", points_table(P, "near", 10_000),
                "<h3>Where FCP is wrong (near-surface points in a thin slab; red = FCP disagrees with the mesh)</h3>",
                f"<img src='img/points_slices{tag}.png'>"]
    if C:
        out += [f"<h3>{n}b. Mesh-mesh collision</h3>", f"<img src='img/collide_speed{tag}.png'>", collide_table(C),
                "<h3>How wrong are the misses</h3>", f"<img src='img/collide_misses{tag}.png'>",
                "<h3>Example poses (decoded meshes for display; blue = A fixed, orange = B placed)</h3>"]
        pairs = dict.fromkeys(r["pair"] for r in C)
        out += [f"<img src='img/collide_poses{tag}_{p}.png'>" for p in pairs if (img / f"collide_poses{tag}_{p}.png").exists()]
    return out


def sweep_section(n):
    R = load("sweep.csv")
    if not R:
        return []
    out = [f"<h2>{n}. Settings: one encoder setting for every object</h2>"]
    if (REPORT / "sweep_notes.html").exists():
        out.append((REPORT / "sweep_notes.html").read_text())
    out.append("<img src='img/sweep.png'>")
    last = {}
    for r in R:
        last[(r["object"], r["layers"], r["N"], r["min_area"], r["nt"])] = r
    rows = [r for r in last.values() if int(r["nt"]) == NT]
    out.append(f"<h3>Every setting at NT {NT} (the cut descriptor: all but {CUT_LEFT:g} of the energy, at most "
               f"{CUT_BYTES / 1e6:g} MB)</h3><p class='mut'>Wrong = of 1M points near the surface (±2 % diag) or 1M uniform in the "
               "box + 10 %, against the mesh. Filter boxes under each header take text or <code>&gt;x</code>, "
               "<code>&lt;=x</code>, <code>!0</code>; click a header to sort.</p>")
    out.append("<div class='wrap'><table><tr><th class='l'>object</th><th>layers</th><th>N</th><th>min area</th><th>MB</th><th>parts</th>"
               "<th>near wrong<br>&gt; 0.1 %</th><th>&gt; 0.2 %</th><th>&gt; 0.3 %</th><th>uniform<br>&gt; 0.1 %</th>"
               "<th>near wrong<br>all, %</th><th>max err<br>% diag</th><th>near<br>ns/pt</th><th>uniform<br>ns/pt</th>"
               "<th>encode s</th><th>encode<br>peak GB</th></tr>")
    for r in rows:
        chosen = int(r["layers"]) == LAYERS and int(r["N"]) == N and float(r["min_area"]) == MIN_AREA
        out.append(f"<tr class='{'ours' if chosen else ''}'><td class='l'>{r['object']}</td><td>{r['layers']}</td><td>{r['N']}</td>"
                   f"<td>{float(r['min_area']):g}</td><td>{int(r['bytes']) / 1e6:.2f}</td><td>{r['parts']}</td>"
                   f"<td>{r['near_n0.1']}</td><td>{r['near_n0.2']}</td><td>{r['near_n0.3']}</td><td>{r['uniform_n0.1']}</td>"
                   f"<td>{float(r['near_wrong_pct']):.3f}</td><td>{float(r['near_max']):.3f}</td><td>{float(r['near_ns']):.1f}</td>"
                   f"<td>{float(r['uniform_ns']):.1f}</td><td>{float(r['encode_s']):.0f}</td><td>{float(r['encode_peak_gb']):.1f}</td></tr>")
    out.append("</table></div>")
    C = load("rule_cmp.csv")
    if C:
        out.append(f"<h3>The query: ring vertices NT and the z rule, at {LAYERS} layers, N {N}</h3><p class='mut'><i>table</i>: inside at "
                   "the two neighbouring layer centres decides where they agree, else each part's two rings blended at the query "
                   "height; <i>nearest</i>: the ring set of the nearest layer centre, one parity per part (height error at most half a "
                   "layer).</p>")
        out.append("<div class='wrap'><table><tr><th class='l'>object</th><th>NT</th><th class='l'>rule</th><th>near wrong<br>&gt; 0.1 %</th>"
                   "<th>&gt; 0.2 %</th><th>uniform<br>&gt; 0.1 %</th><th>near wrong<br>all, %</th><th>near<br>ns/pt</th><th>uniform<br>ns/pt</th></tr>")
        for r in C:
            chosen = int(r["nt"]) == NT and r["rule"] == RULE
            out.append(f"<tr class='{'ours' if chosen else ''}'><td class='l'>{r['object']}</td><td>{r['nt']}</td><td class='l'>{r['rule']}</td>"
                       f"<td>{r['near_n0.1']}</td><td>{r['near_n0.2']}</td><td>{r['uniform_n0.1']}</td>"
                       f"<td>{float(r['near_wrong_pct']):.3f}</td><td>{float(r['near_ns']):.1f}</td><td>{float(r['uniform_ns']):.1f}</td></tr>")
        out.append("</table></div>")
    return out


def errors_section(n):
    imgs = sorted((REPORT / "slices").glob("v2_*.png")) if (REPORT / "slices").exists() else []
    if not imgs:
        return []
    out = [f"<h2>{n}. Where the remaining errors are</h2>"]
    if (REPORT / "errors_notes.html").exists():
        out.append((REPORT / "errors_notes.html").read_text())
    for p in sorted(imgs, key=lambda p: (not p.name.startswith("v2_3d"), p.name)):
        out.append(f"<h3>{p.stem.replace('v2_', '').replace('_', ' ')}</h3><img src='slices/{p.name}'>")
    return out


def found_section(n):
    if not (REPORT / "found_notes.html").exists():
        return []
    return [f"<h2>{n}. Found on the way</h2>", (REPORT / "found_notes.html").read_text()]


def bound_section(n):
    P, C = load("bound_points.csv"), load("bound_collide.csv")
    if not (P and C):
        return []
    out = [f"<h2>{n}. How far from the surface can an answer be wrong?</h2>"]
    if (REPORT / "bound_notes.html").exists():
        out.append((REPORT / "bound_notes.html").read_text())
    out.append("<img src='img/bound.png'>")
    keys = sorted({(int(r["layers"]), int(r["N"]), int(r["nt"])) for r in P})
    out.append("<div class='wrap'><table><tr><th>layers</th><th>N</th><th>NT</th><th>max MB</th><th>points ε<br>% diag</th>"
               "<th>points ε<br>w/o gear</th><th>missed contact ε<br>sampling 1</th><th>sampling 2</th><th>sampling 4</th>"
               "<th>missed ε s1<br>w/o gear</th><th>false hit ε<br>sampling 1</th><th>near<br>ns/pt (median)</th>"
               "<th>µs/pose s1<br>(median)</th></tr>")
    for l, nn, nt in keys:
        f = lambda R, **k: [r for r in R if int(r["layers"]) == l and int(r["N"]) == nn and int(r["nt"]) == nt
                            and all(str(r[a]) == str(b) for a, b in k.items())]
        p_ = f(P)
        mx = lambda R, k: max([float(r[k]) for r in R] or [0])
        c1, c2, c4 = f(C, sampling=1), f(C, sampling=2), f(C, sampling=4)
        chosen = (l, nn, nt) == (LAYERS, N, NT)
        out.append(f"<tr class='{'ours' if chosen else ''}'><td>{l}</td><td>{nn}</td><td>{nt}</td>"
                   f"<td>{mx(p_, 'bytes') / 1e6:.2f}</td><td>{mx(p_, 'max_err'):.2f}</td>"
                   f"<td>{mx([r for r in p_ if r['object'] != 'gear'], 'max_err'):.2f}</td>"
                   f"<td>{mx(c1, 'fn_depth_max'):.2f}</td><td>{mx(c2, 'fn_depth_max'):.2f}</td><td>{mx(c4, 'fn_depth_max'):.2f}</td>"
                   f"<td>{mx([r for r in c1 if r['pair'] != 'gear-gear'], 'fn_depth_max'):.2f}</td>"
                   f"<td>{mx(c1, 'fp_sep_max'):.2f}</td><td>{np.median([float(r['near_ns']) for r in p_]):.1f}</td>"
                   f"<td>{np.median([float(r['us_per_pose']) for r in c1]):.2f}</td></tr>")
    out.append("</table></div><p class='mut'>Sampling s: collision samples every s/128 of the height and every s·NT/128-th "
               "ring vertex. Raw: <code>bound_points.csv</code>, <code>bound_collide.csv</code> (per object / pair); "
               "<code>scripts/bound.py</code>.</p>")
    return out


def layers_section(n, alt="_L512", alt_name="512 layers"):
    """The same benchmarks at another layer count (FCP only; the baselines do not depend on it)."""
    rows = []
    for tag, title in (("", "everyday"), ("_complex", "complex")):
        A, B = load(f"points{tag}.csv"), load(f"points{tag}{alt}.csv")
        CA, CB = load(f"collide{tag}.csv"), load(f"collide{tag}{alt}.csv")
        if not (A and B and CA and CB):
            continue
        for kind in ("near", "uniform"):
            for meth in ("FCP table (CUDA)", "FCP table (CPU)"):
                ga = [r for r in A if r["method"] == meth and r["points"] == kind and int(r["n"]) == 1_000_000]
                gb = [r for r in B if r["method"] == meth and r["points"] == kind and int(r["n"]) == 1_000_000]
                rows.append((title, f"points {kind}, {meth.replace('FCP table ', '')}", "ns/pt",
                             gmean([float(r["ns_per_point"]) for r in ga]), gmean([float(r["ns_per_point"]) for r in gb]),
                             100 * np.mean([float(r["err_mesh"]) for r in ga]), 100 * np.mean([float(r["err_mesh"]) for r in gb])))
        for meth in sorted({r["method"] for r in CA if r["method"].startswith("FCP")}):
            ga, gb = [r for r in CA if r["method"] == meth], [r for r in CB if r["method"] == meth]
            rows.append((title, f"collision, {meth.replace('FCP stride ', 'sampling ')}", "µs/pose",
                         gmean([float(r["us_per_pose"]) for r in ga]), gmean([float(r["us_per_pose"]) for r in gb]),
                         100 * np.mean([float(r["acc"]) for r in ga]), 100 * np.mean([float(r["acc"]) for r in gb])))
    if not rows:
        return []
    out = [f"<h2>{n}. {alt_name} against {LAYERS} layers</h2>"]
    if (REPORT / "layers_notes.html").exists():
        out.append((REPORT / "layers_notes.html").read_text())
    out.append(f"<div class='wrap'><table><tr><th class='l'>set</th><th class='l'>FCP query</th><th>unit</th>"
               f"<th>time<br>{LAYERS} layers</th><th>time<br>{alt_name}</th><th>wrong % / accuracy %<br>{LAYERS} layers</th>"
               f"<th>wrong % / accuracy %<br>{alt_name}</th></tr>")
    for set_, q, unit, ta, tb, ea, eb in rows:
        out.append(f"<tr><td class='l'>{set_}</td><td class='l'>{q}</td><td>{unit}</td><td>{ta:.2f}</td><td>{tb:.2f}</td>"
                   f"<td>{ea:.3f}</td><td>{eb:.3f}</td></tr>")
    out.append("</table></div><p class='mut'>Points: wrong % of 1M points against the mesh (all distances); collision: agreement "
               f"with the exact mesh test. Raw: <code>points*{alt}.csv</code>, <code>collide*{alt}.csv</code>.</p>")
    return out


def summary_section():
    """The top of the report: one short table (GPU study and CPU household study, FCP against the best mesh method), the
    household figure and a short summary. Numbers from the CSVs."""
    import shutil

    P = [r for t in ("", "_complex") for r in load(f"points{t}.csv") if int(r["n"]) == 1_000_000]
    C = [r for t in ("", "_complex") for r in load(f"collide{t}.csv")]
    B, BC = load("bound_points.csv"), load("bound_collide.csv")
    H = Path.home() / "code/data/meshes/fcp_report/sections/collision"
    if not (P and C and B and BC and (H / "data.csv").exists()):
        return []
    hh = {int(r["layers"]): {k: float(v) for k, v in r.items()} for r in csv.DictReader((H / "data.csv").open())}
    shutil.copy(H / "fig.png", REPORT / "img" / "household.png")
    g = lambda R, m, k, **f: gmean([float(r[k]) for r in R if r["method"] == m and all(r[a] == b for a, b in f.items())])
    near = dict(points="near")
    fcp_n, emb_n = g(P, "FCP table (CUDA)", "ns_per_point", **near), g(P, "Open3D occupancy (Embree)", "ns_per_point", **near)
    fcp_c, fcl_c = g(C, "FCP stride 1x1 (CUDA)", "us_per_pose"), g(C, "FCL (BVH, tri-tri)", "us_per_pose")
    fcp_c2 = g(C, "FCP stride 2x2 (CUDA)", "us_per_pose")
    acc = lambda m: 100 * np.mean([float(r["acc"]) for r in C if r["method"] == m])
    sel = lambda R, **f: [r for r in R if all(int(r[a]) == b for a, b in f.items())]
    b_pts = max(float(r["max_err"]) for r in sel(B, layers=LAYERS, N=N, nt=NT))
    c1 = sel(BC, layers=LAYERS, N=N, nt=NT, sampling=1)
    b_miss, b_false = max(float(r["fn_depth_max"]) for r in c1), max(float(r["fp_sep_max"]) for r in c1)
    b_miss_ng = max(float(r["fn_depth_max"]) for r in c1 if r["pair"] != "gear-gear")
    mb = max(float(r["bytes"]) for r in sel(B, layers=LAYERS, N=N, nt=NT)) / 1e6
    h = hh[256]
    rows = [
        ("Inside-outside, GPU", f"11 objects, {LAYERS} layers", f"{fcp_n:.1f} ns/pt", f"Embree {emb_n:.1f} ns/pt",
         f"{emb_n / fcp_n:.1f}×", f"{b_pts:.2f} %", "", f"≤ {mb:.1f} MB"),
        ("Collision, GPU", f"11 pairs, {LAYERS} layers", f"{fcp_c:.1f} µs/pose", f"FCL {fcl_c:.1f} µs/pose", f"{fcl_c / fcp_c:.1f}×",
         f"miss {b_miss:.2f} % ({b_miss_ng:.2f} % w/o gear), false {b_false:.2f} %", f"{acc('FCP stride 1x1 (CUDA)'):.2f} %", ""),
        ("Collision, GPU, every other sample", "", f"{fcp_c2:.1f} µs/pose", f"FCL {fcl_c:.1f} µs/pose", f"{fcl_c / fcp_c2:.1f}×", "",
         f"{acc('FCP stride 2x2 (CUDA)'):.2f} %", ""),
        ("Inside-outside, CPU (v5.5)", "12 objects (household, figurines, spring, knot), 256 layers", f"{h['points_fcp_ns']:.1f} ns/pt (12 thr)",
         f"Embree {h['points_embree_ns']:.1f} ns/pt", f"{h['points_embree_ns'] / h['points_fcp_ns']:.1f}×", f"{h['points_bound_pct']:.2f} %", "",
         f"≤ {h['size_mb_max']:.1f} MB"),
        ("Collision, CPU (v5.5)", "11 pairs, 256 layers", f"{h['collide_fcp_us']:.1f} µs/pose (12 thr)",
         f"FCL {h['collide_fcl_us']:.1f} µs/pose (1 thr)", f"{h['collide_fcl_us'] / h['collide_fcp_us']:.1f}×",
         f"miss {h['collide_miss_bound_pct']:.2f} %, false {h['collide_false_bound_pct']:.2f} %", f"{h['collide_acc_pct']:.2f} %", ""),
        ("Collision, CPU, every other sample", "", f"{h['collide_s2_fcp_us']:.1f} µs/pose (12 thr)", f"FCL {h['collide_fcl_us']:.1f} µs/pose (1 thr)",
         f"{h['collide_fcl_us'] / h['collide_s2_fcp_us']:.1f}×", f"miss {h['collide_s2_miss_bound_pct']:.2f} %", f"{h['collide_s2_acc_pct']:.2f} %", ""),
    ]
    out = ["<h2>Summary</h2>",
           "<div class='wrap'><table><tr><th class='l'>query</th><th class='l'>set</th><th>FCP</th><th>best mesh method</th>"
           "<th>FCP speed-up</th><th>error bound (% diag)</th><th>correct</th><th>size</th></tr>"]
    for r in rows:
        out.append("<tr class='ours'>" + "".join(f"<td class='{'l' if i < 2 else ''}'>{v}</td>" for i, v in enumerate(r)) + "</tr>")
    out.append("</table></div>")
    out.append(
        f"<div class='box'><b>In short.</b> Straight from the descriptor, without decoding, FCP answers inside-outside in "
        f"{fcp_n:.0f} ns per point on the GPU, {emb_n / fcp_n:.1f}× faster than Embree, and {h['points_embree_ns'] / h['points_fcp_ns']:.1f}× "
        f"faster on the CPU; any point farther than {max(b_pts, h['points_bound_pct']):.2f} % of the diagonal from the surface is "
        f"classified right. Collision is approximate with a measured bound: on the GPU it is {fcl_c / fcp_c:.1f}× faster than FCL "
        f"at {acc('FCP stride 1x1 (CUDA)'):.2f} % correct, no contact deeper than {b_miss:.2f} % of the diagonal missed and "
        + ("no false contact at all" if b_false == 0 else f"no false contact wider than {b_false:.2f} %") + ". On the CPU it matches FCL only with every other sample "
        f"({h['collide_fcl_us'] / h['collide_s2_fcp_us']:.1f}×, misses up to {h['collide_s2_miss_bound_pct']:.2f} %); "
        f"at the densest sampling it is slower ({h['collide_fcl_us'] / h['collide_fcp_us']:.1f}×), so the collision speed-up is a GPU result. "
        f"Descriptors are 0.1–{max(mb, h['size_mb_max']):.1f} MB. The bounds are empirical (17 distinct objects, 2M points each; 17 distinct pairs × 2000 poses), "
        f"and the worst cases come from sharp corners (the gear) and pieces that start or end between slices.</div>")
    out.append("<img src='img/household.png' alt='Error bound and CPU time against the layers, household objects'>"
               "<p class='mut'>CPU, 12 objects: household, two figurines, the spring and the knot (v5.5): error bound (left axis) and time (right axis, log) against the "
               "layers; (a) points against Embree, (b) collision against FCL. The GPU results are in sections 2–4.</p>")
    return out


def err_section():
    R = load("err_sources.csv")
    if not R:
        return []
    out = ["<h2>3. Where the error comes from, and how far it goes down</h2>"]
    if (REPORT / "err_notes.html").exists():
        out.append((REPORT / "err_notes.html").read_text())
    out += ["<img src='img/err_sources_eps0.1.png'>", "<img src='img/err_sources_eps0.5.png'>"]
    for o in dict.fromkeys(r["object"] for r in R):
        out.append(f"<h3>{o}</h3><div class='wrap'><table><tr><th class='l'>config</th><th>layers</th><th>N</th><th>NT</th>"
                   "<th>atoms</th><th>parts</th><th>KiB</th><th>near<br>wrong %</th><th>beyond<br>0.01 %</th><th>beyond<br>0.1 %</th>"
                   "<th>beyond<br>0.5 %</th><th>uniform<br>wrong %</th><th>near<br>ns/pt</th><th>uniform<br>ns/pt</th></tr>")
        for r in (r for r in R if r["object"] == o):
            f = lambda k, d=3: f"{float(r[k]):.{d}f}"
            good = float(r["near_wrong_pct_eps0.1"]) <= 0.01
            out.append(f"<tr class='{'ours' if r['config'] == 'base' else ''}'><td class='l'>{r['config']}</td><td>{r['layers']}</td>"
                       f"<td>{r['N']}</td><td>{r['nt']}</td><td>{r['atoms']}</td><td>{r['parts']}</td><td>{int(r['nbytes']) / 1024:.0f}</td>"
                       f"<td>{f('near_wrong_pct')}</td><td>{f('near_wrong_pct_eps0.01')}</td>"
                       f"<td class='{'good' if good else ''}'>{f('near_wrong_pct_eps0.1', 4)}</td><td>{f('near_wrong_pct_eps0.5', 4)}</td>"
                       f"<td>{f('uniform_wrong_pct', 4)}</td><td>{f('near_ns', 1)}</td><td>{f('uniform_ns', 1)}</td></tr>")
        out.append("</table></div>")
    return out


def v54_section():
    A, B = load("err_sources_v531.csv"), load("err_sources_v54.csv")
    if not (A and B):
        return []
    out = ["<h2>4. v5.4's rate-distortion partition (parts=\"rd\") against v5.31's tracked-fit</h2>"]
    if (REPORT / "v54_notes.html").exists():
        out.append((REPORT / "v54_notes.html").read_text())
    out.append("<div class='wrap'><table><tr><th class='l'>object</th><th class='l'>config</th><th>parts<br>v5.31 / v5.4</th>"
               "<th>near wrong %<br>v5.31 / v5.4</th><th>beyond 0.1 %<br>v5.31 / v5.4</th><th>beyond 0.5 %<br>v5.31 / v5.4</th>"
               "<th>uniform %<br>v5.31 / v5.4</th><th>near ns/pt<br>v5.31 / v5.4</th></tr>")
    b = {(r["object"], r["config"]): r for r in B}
    for r in A:
        q = b.get((r["object"], r["config"]))
        if not q:
            continue
        def cell(k, d=3, lower=True):
            x, y = float(r[k]), float(q[k])
            c = "good" if y < x * 0.9 else "bad" if y > x * 1.1 else ""
            return f"<td class='{c}'>{x:.{d}f} / {y:.{d}f}</td>"
        cfg = "128 layers, N 16, 6144" if r["config"] == "base" else "512 layers, N 64, 12288"
        out.append(f"<tr><td class='l'>{r['object']}</td><td class='l'>{cfg}</td><td>{r['parts']} / {q['parts']}</td>"
                   + cell("near_wrong_pct") + cell("near_wrong_pct_eps0.1", 4) + cell("near_wrong_pct_eps0.5", 4)
                   + cell("uniform_wrong_pct", 4) + cell("near_ns", 1) + "</tr>")
    out.append("</table></div><p class='mut'>green / red: v5.4 more than 10 % better / worse. Raw: <code>err_sources_v531.csv</code>, "
               "<code>err_sources_v54.csv</code>, <code>hole_ends.log</code>.</p>")
    return out


def main():
    notes = NOTES.read_text() if NOTES.exists() else ""
    body = [f"<h1>FCP: collision and inside-outside queries straight from the descriptor</h1>",
            f"<p class='mut'>fcp_2910_collision, v2 · package v5.4 (<code>parts=\"tracked-fit\"</code>, branch caps-cutfix) · "
            f"GTX 1650 (4 GB), 12 CPU threads · {LAYERS} layers, N {N}, M {M}, {SAMPLES} samples per ring, min island area "
            f"{MIN_AREA:g}, cut at {CUT_LEFT:g} energy left or {CUT_BYTES / 1e6:g} MB · query: {NT} ring vertices, rule "
            f"<i>{RULE}</i> · generated {time.strftime('%Y-%m-%d %H:%M')} · "
            f"<a href='v1/index.html'>v1 (1024 layers, N 64, 12288 atoms; with the dog and elephant)</a></p>", notes,
            "<img src='img/summary.png'>"]
    body[2:2] = summary_section()
    body += sweep_section(1)
    body += section(2, "", "Everyday objects")
    body += section(3, "_complex", "Concave, interleaved and perforated shapes")
    body += bound_section(4)
    lay = layers_section(5)
    body += lay
    body += errors_section(5 + bool(lay))
    body += found_section(6 + bool(lay))
    body.append("<h2>Files</h2><p class='mut'>Scripts in <code>../scripts</code>: <code>fcpq.py</code> (the descriptor queries), "
                "<code>sweep.py</code> and <code>rule_cmp.py</code> (section 1), <code>bound.py</code> (section 4), <code>points_bench.py</code> and "
                "<code>collide_bench.py</code> (<code>FCP_SET=complex</code> for section 3), <code>slice_errors.py</code> and "
                "<code>render3d.py</code> (section 5), <code>lowmem.py</code> (the encoder's selection without its padded array), "
                "<code>common.py</code> (the setting), <code>make_shapes.py</code>, <code>renders.py</code>, <code>report.py</code>. "
                "Raw numbers: <code>sweep.csv</code>, <code>rule_cmp.csv</code>, <code>points*.csv</code>, <code>collide*.csv</code>; "
                "logs alongside. Earlier: <a href='v1/index.html'>v1/</a>, <a href='a6144/index.html'>a6144/</a>, "
                "<a href='a2048/index.html'>a2048/</a>.</p>")
    (REPORT / "index.html").write_text(f"<!doctype html><html lang='en'><head><meta charset='utf-8'>"
                                       f"<meta name='viewport' content='width=device-width,initial-scale=1'>"
                                       f"<title>FCP collision queries</title><style>{CSS}</style></head><body>"
                                       + "\n".join(body) + f"<script>{FILTER_JS}</script></body></html>")

if __name__ == "__main__":
    main()
