"""The synthesis page: ../report/synthesis.html, from ../report/ablation.csv (ablation.py) and the gallery (gallery.py).
The inventory and the conclusions are written below (INVENTORY, the text in page()); every number comes from the CSV.

    env -u PYTHONPATH PYTHONWARNINGS=ignore .venv/bin/python ~/code/data/meshes/fcp_2710_tests/scripts/synth.py
"""
import csv
import html
import sys
import time
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).parent))
from parts_report import CSS, OBJ, REPORT  # noqa: E402

CLOUDS = [(t, n) for n in ("10000", "2048") for t in ("clean", "noise0p2", "noise0p6", "noise1", "pn0p12", "pn0p6", "pn1p2",
                                                     "dgrad", "dstripe")]
CFGS = ["package", "pointtest", "A", "A+pointtest", "hybrid", "hybrid+pointtest", "hybrid-gated", "hybrid-gated+pointtest",
        "thinwall", "thinwall+pointtest", "eps", "local", "gwn", "gwn+pointtest", "gwn-tree"]

# stage, approach, source, what it does, evidence, verdict, effect on rings / parts
INVENTORY = [
    ("orientation", "robust plane directions + spanning-tree patches + leak-proof flood vote (surf)",
     "docs/pointcloud-slicer.md, DECISIONS 25", "per-point normals and signs without given normals",
     "10k Chamfer/mesh 6.1 → 1.6, 2048 13.9 → 4.9 (9 objects)", "in the package (v5.2)", "rings: the basis of everything"),
    ("orientation", "3D flood alone; winding-number vote; sign-consistency pass", "pointcloud-slicer.md; pc_progress.py",
     "global inside/outside", "flood 53–64 % right alone; winding worse (bowl 15.9, mug 34.7×); consistency mug 40 → 45 wrong",
     "rejected (flood kept as a vote)", "—"),
    ("field / topology", "band slicer (dilate, fill)", "fousd-3d README, ShapeNet diagnosis", "robust under noise",
     "lids on bowl/mug/wine glass; 17 of 20 worst ShapeNet objects; at 0.6 % noise 17–18 parts (surf 406–452)",
     "kept for noisy clouds and registration", "parts few, rings wrong on thin walls"),
    ("field / topology", "signed slicer + MST orientation, 5 % fallback to band; ADAPT local spacing",
     "overnight LOG, t3_pcpnet LOG", "signed pixels; local reach on variable density",
     "slice IoU 0.904 → 0.954; variable density parts 642 → 24 (ADAPT)", "superseded by surf (ADAPT has no surf equivalent)",
     "parts: ADAPT is the only density fix ever measured"),
    ("field / topology", "RIMLS within 1.25 spacings, flood beyond, marching squares (surf)", "DECISIONS 25",
     "the implicit surface read at the layers", "the 10k / 2048 gains above; noise: 452–831 parts at 0.6–2 %",
     "default; fails on noise and density (surf_issues.md)", "rings ragged (hooks); thin walls fragment"),
    ("field / topology", "surf constants × f with the minimum loop × f² (noise)", "HANDOVER_surf_noise_v52.md",
     "wider support for noisy clouds", "100k 0.6 %: 406 → 13 parts (f = 6); but deletes limbs, and clean sparse clouds want f = 1",
     "open: scale by an estimated σ, not by the spacing", "parts ↓↓ under noise, rings lose thin features"),
    ("field / topology", "Poisson; rolling ball / alpha shape; shrink-wrap; 2D balloon", "pointcloud-slicer.md; MORNING.md",
     "other surfaces", "Poisson val 0.0213 vs 0.0126, not watertight on 41 %; ball slips or bridges; wrap fixes topology",
     "rejected", "—"),
    ("field / topology", "thin-wall vote / centroid rule", "overnight exp_04, exp_16", "open walls under 2 spacings",
     "62 of 211 objects worse / walls open but 7–10 hole parts", "rejected", "parts ↑"),
    ("field / topology", "B: contours from the points' own sheets (no field)", "this session, scripts/ringfit.py",
     "topology from a neighbour graph split by normals", "3–10× the package's error: lost layers, wild loops, open crescents",
     "rejected as built; its face separation by normals is kept (in the hybrid)", "—"),
    ("ring geometry", "refit rings to the points (harmonics by held-out layers)", "pointcloud-slicer.md (refit.py)",
     "positions only", "fixes positions where the start is right; diverges at gaps", "rejected then ('rings are not the place')",
     "rings ✓, topology ✗"),
    ("ring geometry", "A: slicer rings refitted to their own points, N from the point count, plane-corrected slab, loops cut",
     "this session, scripts/ringfit.py", "removes hooks / extra length", "see the ablation: A", "see below", "rings ✓"),
    ("ring geometry", "fewer harmonics N", "noisy_clouds.md; this session (N sweep)", "regularise by truncation",
     "no change in parts at 10k or 200k (the wobble is sample sliding, all harmonics)", "rejected", "—"),
    ("ring geometry", "decode → keep N' harmonics → re-encode (refit pass)", "noisy_clouds.md §4", "denoise by a second pass",
     "mesh noise: bowl 0.171 → 0.085 (0.6 %), parts 101 → 81", "works on mesh noise; never tried on clouds", "rings ✓, parts ↓"),
    ("layer consistency", "surf-n / surf-rn (drop one-layer pieces, bridge gaps)", "pc_progress.py; surf.py",
     "neighbour-layer consistency on rings", "no gain at any window (IoU 0.889/0.888/0.887); parts = surf-r",
     "off (and the noise comparison was invalid: flags never reached the code)", "—"),
    ("layer consistency", "neighbour sweeps: rings started from the next layer and refined", "this session, scripts/neighbour.py",
     "carry a good ring through a broken layer", "helps thick organic shapes, hurts thin walls (mixing refined and slicer rings)",
     "superseded by the hybrid", "parts ±"),
    ("layer consistency", "hybrid: S (A) by default, U/D carried sets replace a whole layer if they pass; trusted rings kept",
     "this session, scripts/hybrid.py", "repair C-rings and fragments from good neighbours", "see the ablation: hybrid", "see below",
     "rings ✓, parts ↓ on thin walls"),
    ("layer consistency", "hybrid-gated: carried sets only on suspicious layers (untrusted ring or a sliver < 3 spacings)",
     "this session", "touch only broken layers", "see the ablation: hybrid-gated", "see below", "as hybrid, safer elsewhere"),
    ("parts / cuts", "tracked-fit with fit_tol = 0.01 (mesh) / one spacing (clouds)", "DECISIONS 22, 25",
     "cut where one height fit misses", "horse 10k 28 → 17, scans median 58 → 47", "in the package", "parts"),
    ("parts / cuts", "point test: cut only where the fitted ring fails to explain the points (two-sided, 2 spacings)",
     "this session, scripts/pointfit.py", "judge cuts on geometry, not coefficients", "dino 200k 40 → 28 (mesh 25); see the ablation",
     "see below", "parts ↓"),
    ("parts / cuts", "fit_tol up to 10; MIN_PIECE; continue-jumps; drop_fragments; keep largest solid + hole",
     "noisy_clouds.md; consolidation RESULTS", "other cut / merge rules",
     "noisy bowl 831 → 805 parts (97 % from tracking); drop_fragments −37 parts for +8–17 % Chamfer; lids",
     "rejected (continue-jumps: better Chamfer, 5× slower, superseded)", "parts vs accuracy"),
    ("noise", "speck removal (ring area < 4π s²), σ-scaled thresholds, routing to band by estimators", "surf_issues.md",
     "kill specks and bubbles under noise / variable density", "estimators catch 69 of 71 exploding PCPNet clouds",
     "proposed, not built", "parts ↓↓ under noise"),
    ("noise", "stop at the energy knee (~99.5 %)", "noisy_clouds.md §3", "denoising by truncation", "best Chamfer past the knee",
     "rule not yet chosen", "—"),
]


def load():
    with (REPORT / "ablation.csv").open() as f:
        rows = [r for r in csv.DictReader(f) if r["object"] in OBJ]  # the current test set (the loaf pan's rows stay in the CSV)
    for r in rows:
        for k in ("parts", "holes", "short", "ref_parts", "empty"):
            r[k] = int(r[k])
        for k in ("mean", "p95", "seconds"):
            r[k] = float(r[k])
    return rows


def summary(rows):
    """Per cloud type and configuration: parts against the mesh and error against the package."""
    out = []
    for tag, n in CLOUDS:
        sub = [r for r in rows if r["cloud"] == tag and r["points"] == n]
        if not sub:
            continue
        base = {r["object"]: r for r in sub if r["config"] == "package"}
        for cfg in CFGS:
            rs = [r for r in sub if r["config"] == cfg and r["parts"] >= 0]
            if not rs:
                continue
            ratio = np.array([r["parts"] / max(r["ref_parts"], 1) for r in rs])
            err = np.array([r["mean"] / base[r["object"]]["mean"] for r in rs if r["object"] in base and np.isfinite(r["mean"])])
            out.append(dict(cloud=f"{tag} {n}", cfg=cfg, n=len(rs), parts_ratio=float(np.median(ratio)),
                            within=int(np.sum(np.abs(ratio - 1) <= 0.25)), parts=int(sum(r["parts"] for r in rs)),
                            ref=int(sum(r["ref_parts"] for r in rs)), err=float(np.median(err)) if len(err) else float("nan"),
                            better=int(np.sum(err < 0.95)), worse=int(np.sum(err > 1.05)),
                            empty=int(sum(r["empty"] for r in rs)), sec=float(np.median([r["seconds"] for r in rs]))))
    return out


def fmt_summary(S):
    head = ("<tr><th>cloud</th><th>configuration</th><th>parts (all objects) / mesh</th><th>median parts ÷ mesh</th>"
            "<th>objects within ±25 % of the mesh's parts</th><th>median error ÷ package</th><th>error better / worse (±5 %)</th>"
            "<th>empty decodes</th><th>median encode s</th></tr>")
    body, last = [], None
    for s in S:
        sep = " style='border-top:2px solid #999'" if s["cloud"] != last else ""
        last = s["cloud"]
        body.append(f"<tr{sep}><td>{s['cloud']}</td><td>{s['cfg']}</td><td>{s['parts']} / {s['ref']}</td>"
                    f"<td>{s['parts_ratio']:.2f}</td><td>{s['within']} of {s['n']}</td><td>{s['err']:.3f}</td>"
                    f"<td>{s['better']} / {s['worse']}</td><td>{s['empty']}</td><td>{s['sec']:.1f}</td></tr>")
    return f"<table>{head}{''.join(body)}</table>"


def per_object(rows, tag, n):
    sub = [r for r in rows if r["cloud"] == tag and r["points"] == n]
    if not sub:
        return ""
    head = "<tr><th>object</th><th>mesh parts</th>" + "".join(f"<th>{c}</th>" for c in CFGS) + "</tr>"
    body = []
    for name in OBJ:
        rs = {r["config"]: r for r in sub if r["object"] == name}
        if not rs:
            continue
        ref = next(iter(rs.values()))["ref_parts"]
        ok = [c for c in CFGS if c in rs and np.isfinite(rs[c]["mean"])]
        best_e = min(rs[c]["mean"] for c in ok) if ok else None
        best_p = min((abs(rs[c]["parts"] - ref) for c in ok), default=None)
        cells = []
        for c in CFGS:
            if c not in rs:
                cells.append("<td></td>")
                continue
            r = rs[c]
            e = f"{1e3 * r['mean']:.2f}" if np.isfinite(r["mean"]) else "empty"
            pb = "<b>" if abs(r["parts"] - ref) == best_p else ""
            eb = "<b>" if best_e is not None and r["mean"] == best_e else ""
            cells.append(f"<td>{pb}{r['parts']}{'</b>' if pb else ''} · {eb}{e}{'</b>' if eb else ''}</td>")
        body.append(f"<tr><td>{name}</td><td>{ref}</td>{''.join(cells)}</tr>")
    return f"<h3>{tag} · {n} points</h3><table>{head}{''.join(body)}</table>"


def page(rows):
    S = summary(rows)
    inv = "".join(f"<tr><td>{html.escape(a)}</td><td>{html.escape(b)}</td><td>{html.escape(c)}</td><td>{html.escape(d)}</td>"
                  f"<td>{html.escape(e)}</td><td>{html.escape(f)}</td><td>{html.escape(g)}</td></tr>" for a, b, c, d, e, f, g in INVENTORY)
    gal = " · ".join(x for x in (
        "<a href='gallery_final/index.html'><b>final gallery: package, point test, eps, local</b></a>" if (REPORT / "gallery_final" / "index.html").exists() else "",
        "<a href='gallery/index.html'>full gallery: every method</a>" if (REPORT / "gallery" / "index.html").exists() else "") if x)
    objs = "".join(per_object(rows, t, n) for t, n in CLOUDS)
    return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Point-cloud slicing: synthesis</title>
<style>{CSS} td,th{{font-size:12.5px}} .box{{border-left:4px solid #2f6f8f;padding:6px 12px;background:#f1efe9;margin:10px 0}}</style></head><body>
<p><a href="index.html">← report</a></p>
<h1>Point-cloud slicing: what each approach is good for, and what to combine</h1>
<p class="mut">{time.strftime('%Y-%m-%d %H:%M')} · fcp v5.2 (CUDA) · bench prototypes in <code>scripts/</code> (package untouched) ·
numbers: <code>report/ablation.csv</code> ({len(rows)} rows), built by <code>scripts/ablation.py</code> and <code>scripts/synth.py</code> ·
gallery (parts and slices of every object): {gal or 'not built yet'}.</p>
{CONCLUSIONS}
<h2>1. Test set</h2>
<p>10 watertight meshes, upright on +z (<code>parts_report.OBJ</code>): battery (synthetic stepped box), dress (solid envelope), elephant,
two bowls (ObjectFolder, GSO), mug, dino, shoe, loaf pan (thin steel walls), horse. Noisy meshes (<a href="noisy.html">noisy.html</a>):
a smooth one-to-one 3D displacement field, σ = 0.2 / 0.6 / 1 % of the diagonal along the normal, topology kept (no face turned over,
no self-crossing section); the loaf pan's source is full of slivers, so its 0.6 / 1 % versions reach only 0.21 / 0.27 %. Clouds:
10 000 and 2 048 points (seeded): area-uniform from the clean and from each noisy mesh; per-point Gaussian noise of 0.12 / 0.6 /
1.2 % of the diagonal on clean samples (PCPNet's protocol, tags pn*); variable density on the clean mesh (dgrad: kept with
probability falling 10:1 along x; dstripe: stripes along y kept with probability 1 and 0.2): 180 clouds. The mesh route's parts on the mesh the
cloud came from are the parts reference; errors are against the clean mesh (mean of the symmetric nearest-point distance, 50k samples
per side, over the diagonal). 128 layers, 1024 atoms, up = +z.</p>
<h2>2. Configurations</h2>
<ul>
<li><b>package</b>: <code>fcp.encode</code> (surf slicer, tracked-fit, fit_tol "auto").</li>
<li><b>pointtest</b>: the package's slices; a track is cut only where one height fit fails to explain the layer's points (two-sided,
2 spacings, p95) (<code>pointfit.py</code>).</li>
<li><b>A</b>: the slicer's rings refitted to their own points (slab within a spacing, moved onto the plane along the surface; points by
face from the normals; N from the point count; self-loops cut) (<code>ringfit.py</code>).</li>
<li><b>hybrid</b>: A by default; on every layer the contours of the layer below (U) and above (D), carried and refitted, replace the
whole layer when they are backed by points, fit them, explain as many points, fill nothing new and keep every trusted slicer ring
(<code>hybrid.py</code>). <b>hybrid-gated</b>: U/D only where the slicer's layer is suspicious (an untrusted ring, or a solid sliver
under 3 spacings wide).</li>
<li>"+pointtest": the same slices, cut by the point test.</li>
<li><b>thinwall</b>: thin walls (a parallel face 0.4–1.5 spacings along the normal) added to the slicer's field as a band
(<code>thinwall.py</code>).</li>
<li><b>eps</b>: one scale ε = max(spacing, 4σ), σ the cloud's per-point noise given to the encoder (0 for clean, mesh-noise and
density clouds): the slicer's support, band and inlier distance × ε/spacing, the minimum loop × (ε/spacing)², cuts by the point test
(<code>eps.py</code>).</li>
</ul>
<h2>3. Summary per cloud type</h2>
{fmt_summary(S)}
<h2>4. Per object</h2>
<p class="mut">parts · mean error ×10⁻³ of the diagonal. Bold: parts closest to the mesh's, lowest error.</p>
{objs}
<h2>5. Inventory of approaches</h2>
<p class="mut">From <code>docs/pointcloud-slicer.md</code>, <code>docs/surf_issues.md</code>, <code>docs/noisy_clouds.md</code>,
<code>docs/HANDOVER_surf_noise_v52.md</code>, <code>docs/consolidation/RESULTS.md</code>, <code>api/DECISIONS.md</code> 22 / 25,
<code>api/OPEN.md</code> 9, the research repository (fousd-3d: overnight logs, ShapeNet diagnosis, pc-slicer prototypes) and this
session's prototypes.</p>
<table><tr><th>stage</th><th>approach</th><th>source</th><th>what it does</th><th>evidence</th><th>verdict</th><th>rings / parts</th></tr>{inv}</table>
<h2>6. This session's pages</h2>
<p><a href="index.html">parts at 200k</a> · <a href="parts_clean_10000.html">parts at 10k (all prototypes)</a> ·
<a href="splits_dino.html">the dino's cuts</a> · <a href="analysis_ringfit.html">A vs B</a> · <a href="middle_clean_10000.html">middle slices</a> ·
<a href="compare_loaf_pan_clean_10000.html">loaf pan</a> · <a href="compare_bowl_gso_clean_10000.html">GSO bowl</a> ·
<a href="compare_dino_clean_10000.html">dino</a> · <a href="noisy.html">noisy meshes</a></p>
</body></html>"""


CONCLUSIONS = """<div class='box'>
<p><b>Findings, from the 1 980 runs below (10 objects × 18 cloud types × 11 configurations) and the gallery.</b></p>
<p><b>The idea that unifies what works: the encoder works at one scale ε, the finest detail the input supports</b>, ε = max(point
spacing, 4σ) with the noise σ given as an encoder parameter (0 for a clean cloud). Surfaces closer than ε merge, loops smaller than
ε² vanish, and a part is cut only where one smooth height fit fails to explain the points (configuration <b>eps</b>:
<code>eps.py</code>). On a clean cloud ε is the spacing and this is the package's slicer with the point test.</p>
<ol>
<li><b>Per-point noise: eps turns the explosion into a description at the noise level.</b> 10k points, all objects, against the
mesh's 139 parts: 0.6 % noise 1 385 → 314 parts, error 0.85× the package's; 1.2 % noise 3 277 → 153 parts, error 0.57×. At 2 048
points and 1.2 %: 742 → 185 parts, error 1.17× (thin limbs are below ε there). Measured on the way: the slicer's support, band and
inlier distance must scale with ε and the minimum loop with ε² (ε or a fixed loop leave the specks); the cut margin and the
thin-wall rule must not (scaled by ε they delete geometry). C = 4 (<code>report/eps_*.txt</code>).</li>
<li><b>The point test is the cut rule</b> (also inside eps). Everywhere about 10 % fewer parts at the same error at 10k and within
5 % at 2 048; on some organic shapes it keeps pieces the mesh cuts (dress, dino at 10k).</li>
<li><b>Thin walls are an orientation failure.</b> A wall thinner than about a spacing is oriented as one sheet, so the inner face
reads the cavity as inside: the slicer's inside band sits beside the wall and its zero line breaks (<a href="field_loaf_pan_clean_10000.html">field
pages</a>). The thin-wall band (<code>thinwall.py</code>) fixes part of it at 10k (loaf pan 74 → 57 parts, error 16.6 → 9.9; shoe 30 →
26) and leaves thick objects unchanged, but it overthickens walls at 2 048 and the wall is not always closed
(<a href="thinwall.html">thinwall.html</a>). The gated hybrid repairs the same rings from neighbouring layers at 5× the cost.</li>
<li><b>Ring refitting (A, the hybrid) gives cleaner rings, not fewer parts,</b> and costs accuracy at 2 048 (8–14 %).</li>
<li><b>Variable density: the same idea per point.</b> No global scale fits a cloud whose density varies (a global ε from the
90th-percentile spacing roughly halves the parts, <code>report/eps_density.txt</code>). With ε measured per point,
ε<sub>i</sub> = max(local spacing<sub>i</sub>, 4σ), setting that point's RIMLS support, band, plane-fit inlier distance and the minimum
loop around it (configuration <b>local</b>, <code>surf_local.py</code>, CPU): gradient density at 10k 927 → 274 parts at 0.53× the
package's error, striped 1 362 → 637 at 0.85×, gradient at 2 048 564 → 264 at 0.88×; per-point noise as global ε (0.6 %: 315 parts
at 0.73×; 1.2 %: 168 at 0.67×); clean and mesh-noise clouds about the package's. One measured detail: the flood's balls follow the
local spacing, not ε (it seals sampling gaps, and noise opens none; scaling it by 4σ cost up to 50 % error at 1.2 % noise,
<code>report/local_flood.txt</code>). Weak spot: per-point noise at 2 048 points (0.6 %: 1.13× the package's error).</li>
<li><b>Noise estimation is not solved.</b> A local quadric fit reads thick shapes' noise (horse 0.55 % for 0.6 %) but takes a thin wall's
two faces for noise (clean mug 0.84 %), and at 2 048 everything reads high: σ is a parameter, not estimated
(<code>report/sigma_estimators.txt</code>).</li>
<li><b>2 048 points is past the sampling limit for this set:</b> about twice the mesh's parts in every configuration, some features gone.</li>
</ol>
<p><b>Since then: the GWN slicer beats every prototype above</b> (another agent's, <code>../../fcp_2810_gwn</code>; checked here:
its numbers reproduce exactly on 25 clouds, it uses no ground truth, the same clouds and metric; <code>report/gwn_check.txt</code>).
Inside / outside from the mollified generalized winding number of the oriented, area-weighted points, mollifier
ε<sub>i</sub> = max(0.5 local spacing<sub>i</sub>, 3σ): the same one-scale idea with a better field. Against the mesh's 134 parts:
clean 10k 135 parts at 0.96× the package's error (local 179, 1.01×); 0.6 % noise 284 at 0.60×; density stripes 273 at 0.38×; clean
2 048 181 at 0.88×. <b>GWN + point test</b> (configuration gwn+pointtest): 8–17 % fewer parts again at 1–6 % more error, but below
the mesh's count on clean clouds (median 0.79) and at 1.2 % noise (0.50): with GWN's clean rings the coefficient cut rule no longer
over-cuts, and the point test's 2-spacing margin (set for ragged rings) keeps pieces the mesh cuts. GWN alone is the closest to the
mesh's parts; the point test helps where GWN still over-cuts (sparse and variable-density clouds at 2 048); its margin for GWN
(about 1 spacing) is not measured yet (<code>report/gwn_combo_summary.txt</code>).</p>
<p><b>Test set change:</b> the ObjectFolder loaf pan (its mesh all slivers: its noisy versions reached only 0.21–0.27 % noise) is
replaced by <code>open_box</code> (<code>scripts/make_openbox.py</code>): the loaf pan's size, rounded corners, 6 mm walls and floor,
marching cubes of its signed distance (well-shaped triangles; the noisy versions reach 0.20 / 0.53 / 0.94 %). Its mesh route has 2
parts; clean 10k: package 37, local 25, GWN 19 (error 4.5 → 3.6 ×10⁻³); 0.6 % noise: 331 → 111. The tables count only the current
test set (the loaf pan's rows stay in the CSV).</p>
<p><b>Recommended, in the paper's words:</b> <i>"Every point carries the resolution the input supports there, ε = max(a fraction of
its local spacing, a multiple of the noise σ). Inside and outside come from the winding number of the oriented, area-weighted
points mollified at ε, so the field neither sees the density nor resolves anything finer than ε; loops smaller than ε² are dropped,
and a part is cut where one smooth fit along the height fails."</i> In the package: the GWN slicer (with a fast evaluation: it is a
direct sum, 1.6–3.5 s at 10k and 11–54 s at 50k), a <code>noise=</code> parameter, and the cut rule chosen for GWN's rings (the
package's, or the point test with its margin measured for them). Later: thin walls (the open box still has 19 parts against 2),
and an estimate of σ that does not read thin walls as noise.</p>
<p class="mut">The package is untouched; every prototype is in <code>scripts/</code>. A change to the package is a decision for
<code>api/DECISIONS.md</code>, with these tables.</p>
</div>"""


if __name__ == "__main__":
    rows = load()
    (REPORT / "synthesis.html").write_text(page(rows))
    print(len(rows), "rows ->", REPORT / "synthesis.html")
