"""Build a model-by-model input, fusion, and execution dashboard."""
from __future__ import annotations

import html
import json
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

from .data import ROOT

OUT = ROOT / "results/models/dashboard"


def thermal_preview() -> None:
    paired = sorted((ROOT / "data_processed/paired").glob("*_cad_ply_targets.npz"))
    rows = []
    for path in paired:
        with np.load(path, allow_pickle=False) as data:
            rows.append({
                "run": str(data["run_id"].item()),
                "values": np.stack([data["heater_top_c"], data["heater_bottom_c"]]),
                "imputed": np.stack([data["heater_top_imputed"], data["heater_bottom_imputed"]]),
            })
    values = np.concatenate([row["values"].ravel() for row in rows])
    finite = values[np.isfinite(values)]
    fig, axes = plt.subplots(len(rows), 1, figsize=(14, 2.6 * len(rows)), constrained_layout=True)
    axes = np.atleast_1d(axes)
    for ax, row in zip(axes, rows):
        image = ax.imshow(row["values"], aspect="auto", cmap="inferno", vmin=finite.min(), vmax=finite.max())
        for layer in range(2):
            for zone in range(17):
                value = row["values"][layer, zone]
                label = "—" if not np.isfinite(value) else f"{value:.0f}{'*' if row['imputed'][layer, zone] else ''}"
                color = "black" if np.isfinite(value) and value > np.nanmedian(finite) else "white"
                ax.text(zone, layer, label, ha="center", va="center", fontsize=8, color=color)
        ax.set_yticks([0, 1], ["upper", "lower"])
        ax.set_xticks(range(17), [f"{zone:02d}" for zone in range(1, 18)])
        ax.set_xlabel("heater zone"); ax.set_title(f"{row['run']} XLS heater input (°C; * = linearly interpolated)")
        fig.colorbar(image, ax=ax, label="°C", fraction=.025, pad=.015)
    fig.savefig(OUT / "thermal-process-input.png", dpi=150)
    plt.close(fig)


def _summaries(report: dict) -> dict[str, dict]:
    grouped: dict[str, list[dict]] = {}
    for row in report["models"]:
        grouped.setdefault(row["family"], []).append(row)
    result = {}
    for family, rows in grouped.items():
        passed = [row for row in rows if row["status"] == "forward_backward_smoke"]
        latency = [row["forward_latency_ms_cpu"] for row in passed]
        shapes = [row["output_shape"] for row in passed]
        sources = [row.get("source") for row in rows if row.get("source")]
        result[family] = {
            "status": rows[0]["status"] if not passed else "forward/backward passed",
            "checks": len(passed), "params": sorted({row.get("params") for row in passed}),
            "latency": "unavailable" if not latency else (f"{latency[0]:.1f} ms" if len(latency) == 1 else f"{min(latency):.1f}–{max(latency):.1f} ms"),
            "shapes": shapes, "sources": sources,
            "reason": rows[0].get("reason", ""),
        }
    return result


COMPARISON_MODELS = [
    ("2D CNN/U-Net", "2D_CNN_UNet", "2d_cnn_unet", "FiLM", "pixel"),
    ("FNO2d", "FNO2d", "fno2d", "FiLM", "pixel/field"),
    ("Dense 3D U-Net", "3D_UNet", "3d_unet", "FiLM", "voxel"),
    ("Sparse 3D U-Net", "Sparse_UNet", None, "none", "sparse voxel"),
    ("PointNet", "PointNet_segmentation", "pointnet_cad_surface_points", "none", "point"),
    ("DGCNN", "DGCNN_dynamic_feature_knn", "dgcnn_cad_surface_points", "none", "point"),
    ("Mesh GNN", "GNN_MeshGraphNet_style", "meshgraphnet", "edge-message", "CAD node"),
    ("Risk GNN", "GNN_CAD_risk_deformation_multitask", "cad_risk_deformation_multitask", "edge-message", "CAD node + run"),
    ("UV-Net", "UVNet_face_grid_adjacency", "uvnet", "none", "B-Rep face"),
    ("Tabular MLP", "tabular_surrogate", "tabular_surrogate", "direct concat", "run"),
]


def comparison_rows(report: dict, weak: dict) -> list[dict]:
    synthetic = {row["family"]: row for row in report["synthetic"] if "losses" in row}
    actual = report["models"]
    output = []
    for display, family, synth_name, fusion, domain in COMPARISON_MODELS:
        candidates = [row for row in actual if row["family"] == family]
        if family in {"PointNet_segmentation", "DGCNN_dynamic_feature_knn"}:
            selected = next((row for row in candidates if "cad_surface_points" in row.get("source", "")), candidates[0])
        elif family == "GNN_MeshGraphNet_style":
            selected = max(candidates, key=lambda row: row.get("actual_nodes", 0))
        else:
            selected = candidates[0]
        synth = synthetic.get(synth_name) if synth_name else None
        initial, final, reduction = None, None, None
        if synth:
            initial, final = synth["losses"]
            reduction = 100 * (initial - final) / max(abs(initial), 1e-12)
        trained = family == "GNN_CAD_risk_deformation_multitask"
        output.append({
            "model": display, "status": selected["status"], "params": selected.get("params"),
            "latency": selected.get("forward_latency_ms_cpu"), "fusion": fusion, "domain": domain,
            "synthetic_initial": initial, "synthetic_final": final, "synthetic_reduction": reduction,
            "weak_loss": weak["loss_final"] if trained else None,
            "iou": weak["training_threshold_iou"] if trained else None,
            "dice": weak["training_threshold_dice"] if trained else None,
        })
    return output


def comparison_preview(rows: list[dict]) -> None:
    names = [row["model"] for row in rows]
    y = np.arange(len(rows))
    fig, axes = plt.subplots(1, 3, figsize=(16, 7), constrained_layout=True)
    params = np.asarray([row["params"] if row["params"] else np.nan for row in rows], float)
    latency = np.asarray([row["latency"] if row["latency"] is not None else np.nan for row in rows], float)
    reduction = np.asarray([row["synthetic_reduction"] if row["synthetic_reduction"] is not None else np.nan for row in rows], float)
    axes[0].barh(y, params, color="#4472c4"); axes[0].set_xscale("log"); axes[0].set_title("Parameters"); axes[0].set_xlabel("count (log scale)")
    axes[1].barh(y, np.maximum(latency, .01), color="#ed7d31"); axes[1].set_xscale("log"); axes[1].set_title("Actual-input CPU forward"); axes[1].set_xlabel("milliseconds (log scale)")
    axes[2].barh(y, reduction, color="#2f9e75"); axes[2].set_title("Two-step synthetic loss change"); axes[2].set_xlabel("reduction (%)")
    for ax in axes:
        ax.set_yticks(y, names); ax.invert_yaxis(); ax.grid(axis="x", alpha=.25)
    axes[2].axvline(0, color="#555", linewidth=.8)
    fig.suptitle("Architecture comparison — execution and synthetic optimization only")
    fig.savefig(OUT / "model-comparison.png", dpi=150)
    plt.close(fig)


def comparison_table(rows: list[dict]) -> str:
    body = []
    for row in rows:
        synthetic = "—" if row["synthetic_initial"] is None else f"{row['synthetic_initial']:.5g} → {row['synthetic_final']:.5g} ({row['synthetic_reduction']:+.1f}%)"
        weak = "—" if row["weak_loss"] is None else f"loss {row['weak_loss']:.4f}; IoU {row['iou']:.3f}; Dice {row['dice']:.3f}"
        latency = "—" if row["latency"] is None else f"{row['latency']:.3f} ms"
        params = "—" if row["params"] is None else f"{row['params']:,}"
        body.append(f"<tr><th>{html.escape(row['model'])}</th><td>{html.escape(row['domain'])}</td><td>{html.escape(row['fusion'])}</td><td>{params}</td><td>{latency}</td><td>{synthetic}</td><td>{weak}</td></tr>")
    return "".join(body)


STRUCTURES = {
    "2D CNN / U-Net": {
        "geometry": ["image input|34×128×128", "Conv2d ×2|34→16→16", "MaxPool2d|128→64", "Conv2d ×2|16→32→32"],
        "process": ["process input|112", "Linear|112→64", "split|scale 32 + bias 32"],
        "fusion": ["FiLM bottleneck|32×64×64", "ConvTranspose2d|32→16", "concat skip|16+16", "Conv2d ×2|32→16→16", "1×1 head|16→1 pixel"],
    },
    "FNO2d": {
        "geometry": ["field input|34×128×128", "1×1 lift|34→24"],
        "process": ["process input|112", "Linear|112→48", "split|scale 24 + bias 24"],
        "fusion": ["FiLM lifted field|24×128×128", "spectral + skip ×3|24 channels", "1×1 head|24→1 pixel"],
    },
    "Dense 3D U-Net": {
        "geometry": ["voxel input|24×32×4×16", "Conv3d ×2|24→8→8", "MaxPool3d|16×2×8", "Conv3d ×2|8→16→16"],
        "process": ["process input|112", "Linear|112→32", "split|scale 16 + bias 16"],
        "fusion": ["FiLM bottleneck|16×16×2×8", "ConvTranspose3d|16→8", "concat skip|8+8", "Conv3d ×2|16→8→8", "1×1 head|8→1 voxel"],
    },
    "Sparse 3D U-Net": {
        "geometry": ["sparse input|403 coords, 24 feat", "MinkowskiConv|24→16", "stride-2 down|16→32", "mid Conv|32→32"],
        "process": ["XLS branch|not wired"],
        "fusion": ["transpose Conv|32→16", "concat sparse skip|16+16", "sparse head|32→1 cell"],
    },
    "PointNet segmentation": {
        "geometry": ["point input|8,192×C", "shared Conv1d|C→64→128", "global max|128", "repeat + concat|128+128"],
        "process": ["XLS MLP|proposed, not wired"],
        "fusion": ["point head|256→64→1", "output|one scalar / point"],
    },
    "DGCNN segmentation": {
        "geometry": ["point input|512×C", "dynamic kNN|k=12", "Edge MLP 1|2C→32→32", "new kNN + Edge MLP 2|64→32→32"],
        "process": ["XLS MLP|proposed, not wired"],
        "fusion": ["local + local + global|32+32+32", "point head|96→32→1"],
    },
    "MeshGraphNet-style GNN": {
        "geometry": ["node input|N×32", "node encoder|32→48", "edge input|E×8"],
        "process": ["global input|112", "Linear + GELU|112→48"],
        "fusion": ["edge message ×3|48+48+8+48→48→48", "sum at destination|N×48", "GRU update ×3|96→48", "node head|48→1"],
    },
    "Physics-constrained risk GNN": {
        "geometry": ["node input|2×11,956×32", "node encoder|32→32", "edge input|2×72,176×8"],
        "process": ["weak-train input|2×150", "Linear + GELU|150→32", "smoke alternative|2×112→32"],
        "fusion": ["edge message ×2|32+32+8+32→32→32", "GRU update ×2|64→32", "raw head|32→(3 + classes)", "node heads|risk, signed, magnitude", "mean pool|run defect type"],
    },
    "UV-Net-style face model": {
        "geometry": ["face UV grids|6,564×12×8×8", "Conv2d ×2|12→8→8", "mean UV pool|6,564×8", "face edges|36,090"],
        "process": ["XLS MLP|proposed, not wired"],
        "fusion": ["face message|8+8→8", "sum at destination|6,564×8", "face head|8→1"],
    },
    "Tabular MLP surrogate": {
        "geometry": ["CAD summary|6 values + masks"],
        "process": ["XLS process|50 values + masks", "concatenate|112 total"],
        "fusion": ["Linear + GELU|112→64", "Linear + GELU|64→64", "run head|64→1"],
    },
}


def _layer_chain(items: list[str]) -> str:
    output = []
    for index, item in enumerate(items):
        label, shape = item.split("|", 1)
        if index:
            output.append('<span class="layer-arrow">→</span>')
        output.append(f'<div class="layer"><strong>{html.escape(label)}</strong><small>{html.escape(shape)}</small></div>')
    return "".join(output)


def structure(model: str) -> str:
    rows = STRUCTURES[model]
    return f"""<div class="structure" aria-label="Detailed layer structure for {html.escape(model)}">
      <div class="lane-label">Geometry</div><div class="layer-chain">{_layer_chain(rows['geometry'])}</div>
      <div class="lane-label">Process</div><div class="layer-chain process-chain">{_layer_chain(rows['process'])}</div>
      <div class="lane-label">Fusion / output</div><div class="layer-chain fusion-chain">{_layer_chain(rows['fusion'])}</div>
    </div>"""


def card(*, model: str, group: str, preview: str, gallery: str, geometry: str,
         encoder: str, process: str, fusion: str, output: str, current: str,
         result: dict, note: str = "") -> str:
    shapes = html.escape(json.dumps(result.get("shapes", []), ensure_ascii=False))
    params = ", ".join(f"{value:,}" for value in result.get("params", [])) or "—"
    status = html.escape(result.get("status", "not executed"))
    reason = f'<p class="warning">{html.escape(result["reason"])}</p>' if result.get("reason") else ""
    note_html = f'<p class="note">{html.escape(note)}</p>' if note else ""
    return f"""
<article class="model-card" data-group="{html.escape(group)}">
  <div class="card-head"><div><p class="eyebrow">{html.escape(group)}</p><h2>{html.escape(model)}</h2></div><span class="status">{status}</span></div>
  <div class="model-grid">
    <figure><a href="{gallery}"><img src="{preview}" alt="Prepared input preview for {html.escape(model)}"></a><figcaption>Prepared geometry input · open source gallery</figcaption></figure>
    <div>
      <p class="route"><strong>Route:</strong> {html.escape(geometry)} → {html.escape(encoder)}; {html.escape(process)} → {html.escape(fusion)} → {html.escape(output)}</p>
      {structure(model)}
      <dl><dt>Current code</dt><dd>{html.escape(current)}</dd><dt>Actual checks</dt><dd>{result.get('checks', 0)}</dd><dt>Parameters</dt><dd>{params}</dd><dt>CPU forward</dt><dd>{html.escape(result.get('latency', '—'))}</dd><dt>Output shape(s)</dt><dd><code>{shapes}</code></dd></dl>
      {reason}{note_html}
    </div>
  </div>
</article>"""


def build() -> Path:
    OUT.mkdir(parents=True, exist_ok=True)
    report = json.loads((ROOT / "results/models/report.json").read_text())
    weak = json.loads((ROOT / "results/models/weak_gnn/report.json").read_text())
    summary = _summaries(report)
    thermal_preview()
    comparison = comparison_rows(report, weak)
    comparison_preview(comparison)
    rel = "../../../"
    cards = [
        card(model="2D CNN / U-Net", group="image", preview=rel+"data_processed/images/visualizations/cad_multiview_128.png", gallery=rel+"data_processed/images/index.html",
             geometry="STEP → six 17-channel orthographic images + 17 masks", encoder="2D CNN encoder", process="50 XLS + 6 CAD-summary values and 56 masks", fusion="single linear projection → FiLM at bottleneck", output="pixel field [view, 1, H, W]",
             current="Thermal/process values generate scale and bias for the deepest image features; skip features and the modulated bottleneck enter the decoder.", result=summary["2D_CNN_UNet"], note="Execution check only: no validated pixel risk mask exists."),
        card(model="FNO2d", group="image / field", preview=rel+"results/representations/feature_channel_contact_sheet.png", gallery=rel+"data_processed/images/index.html",
             geometry="aligned 17-channel image/field + 17 masks", encoder="1×1 lift + Fourier blocks", process="50 XLS + 6 CAD-summary values and 56 masks", fusion="single linear projection → FiLM before spectral blocks", output="pixel/field grid",
             current="The current input is the CAD image tensor. A physical thermal operator still needs paired input and target fields over time.", result=summary["FNO2d"], note="Architecture execution only; this is not a learned temperature field."),
        card(model="Dense 3D U-Net", group="voxel", preview=rel+"data_processed/voxels/visualizations/cad_surface_dense_32.png", gallery=rel+"data_processed/voxels/index.html",
             geometry="STEP → 12-channel dense surface-voxel tensor + 12 masks", encoder="3D convolution encoder", process="50 XLS + 6 CAD-summary values and 56 masks", fusion="single linear projection → FiLM at 3D bottleneck", output="voxel field [run, 1, X, Y, Z]",
             current="The process branch modulates the deepest 3D features before the skip-connected decoder.", result=summary["3D_UNet"], note="The voxel input represents sampled surfaces, not filled material volume."),
        card(model="Sparse 3D U-Net", group="sparse voxel", preview=rel+"data_processed/voxels/visualizations/cad_surface_sparse_64.png", gallery=rel+"data_processed/voxels/index.html",
             geometry="STEP → occupied voxel coordinates + features", encoder="Minkowski sparse encoder", process="XLS branch proposed", fusion="append/broadcast process embedding at sparse bottleneck", output="occupied-voxel field",
             current="Geometry-only sparse implementation. Process fusion is not yet wired because MinkowskiEngine is unavailable on this machine.", result=summary["Sparse_UNet"]),
        card(model="PointNet segmentation", group="point", preview=rel+"data_processed/points/visualizations/cad_surface_points.png", gallery=rel+"data_processed/points/index.html",
             geometry="STEP → 8,192 surface points + normals/features", encoder="shared point MLP + global max pool", process="XLS MLP proposed", fusion="append global process embedding to every point", output="point risk/deformation field",
             current="The present PointNet smoke is geometry-only. The diagram shows the intended process-fusion extension.", result=summary["PointNet_segmentation"], note="CAD points are valid pre-process input; scan points belong to the post-process inspection task."),
        card(model="DGCNN segmentation", group="point", preview=rel+"results/representations/point_sample_comparison.png", gallery=rel+"data_processed/points/index.html",
             geometry="points → dynamic feature-space kNN", encoder="two EdgeConv stages + global pool", process="XLS MLP proposed", fusion="append process embedding to local/global point features", output="point field",
             current="The present DGCNN smoke is geometry-only and capped at 512 points for CPU memory. Process fusion remains a proposed extension.", result=summary["DGCNN_dynamic_feature_knn"]),
        card(model="MeshGraphNet-style GNN", group="graph", preview=rel+"results/graphs/comparison.png", gallery=rel+"data_processed/graphs/index.html",
             geometry="STEP → nodes, mesh edges, curvature/boundary features", encoder="node/edge message passing", process="112-value process/CAD-summary vector → global encoder", fusion="global embedding appended to every edge message", output="CAD-node field",
             current="This is the most direct variable-node CAD representation. The same weights executed on all seven graph resolutions.", result=summary["GNN_MeshGraphNet_style"]),
        card(model="Physics-constrained risk GNN", group="trained diagnostic", preview=rel+"results/models/weak_gnn/predictions.png", gallery=rel+"results/models/weak_gnn/index.html",
             geometry="11,956-node STEP graph", encoder="message passing", process="50 XLS + observed/imputed masks → global encoder", fusion="process embedding in every edge message", output="node risk + signed/magnitude deformation; run defect type",
             current="Four output heads are active. Magnitude ≥ |signed deformation| is enforced by construction; risk/deformation graph smoothness are soft losses.", result=summary["GNN_CAD_risk_deformation_multitask"],
             note=f"Weak two-run training diagnostic: loss {weak['loss_initial']:.3f} → {weak['loss_final']:.3f}, IoU {weak['training_threshold_iou']:.3f}, Dice {weak['training_threshold_dice']:.3f}. Type learning is unavailable because both labels are 찍힘."),
        card(model="UV-Net-style face model", group="B-Rep", preview=rel+"data_processed/brep/visualizations/faces_uv.png", gallery=rel+"data_processed/brep/index.html",
             geometry="STEP faces → trimmed 8×8 UV grids + face adjacency", encoder="per-face 2D CNN + face graph messages", process="XLS MLP proposed", fusion="append run embedding before face-message head", output="face risk/field",
             current="The current UV model is geometry-only. Process fusion and a face-to-surface decoder remain proposed.", result=summary["UVNet_face_grid_adjacency"]),
        card(model="Tabular MLP surrogate", group="tabular", preview="thermal-process-input.png", gallery=rel+"data_processed/tabular/index.html",
             geometry="STEP summary: bbox, area, node/triangle counts", encoder="feature scaling + mask append", process="50 XLS channels + validity masks", fusion="concatenate all tabular inputs", output="one run-level value",
             current="This baseline directly concatenates process and CAD summary features before the MLP; it cannot localize a risk area.", result=summary["tabular_surrogate"]),
    ]
    document = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Model input and fusion dashboard</title>
<style>
:root{{--ink:#162033;--muted:#667085;--line:#d7dee9;--paper:#f4f7fb;--card:#fff;--blue:#235bc1;--green:#147d64;--amber:#9b6500}}
*{{box-sizing:border-box}} body{{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,-apple-system,sans-serif}} main{{max-width:1240px;margin:auto;padding:32px 20px 64px}}
h1{{font-size:clamp(28px,4vw,44px);line-height:1.1;margin:.2rem 0}} h2{{margin:.1rem 0;font-size:23px}} .lead{{max-width:900px;color:var(--muted);font-size:17px}}
.summary{{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px;margin:24px 0}} .summary div,.model-card{{background:var(--card);border:1px solid var(--line);border-radius:12px}} .summary div{{padding:14px 16px}} .summary strong{{display:block;font-size:24px}}
.thermal,.recommended,.comparison,.cad-predictions{{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:18px}} .thermal img,.comparison img,.cad-predictions img{{width:100%;height:auto;display:block}} .thermal p,.recommended>p,.comparison>p,.cad-predictions>p{{color:var(--muted)}} .table-wrap{{overflow-x:auto}} table{{border-collapse:collapse;width:100%;min-width:980px}} th,td{{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}} thead th{{color:var(--muted)}} tbody th{{white-space:nowrap}}
.model-card{{padding:18px;margin:14px 0}} .card-head{{display:flex;justify-content:space-between;gap:16px;align-items:flex-start}} .eyebrow{{color:var(--blue);font-weight:700;text-transform:uppercase;letter-spacing:.08em;font-size:12px;margin:0}} .status{{color:var(--green);background:#e7f6f1;padding:5px 9px;border-radius:999px;white-space:nowrap}}
.model-grid{{display:grid;grid-template-columns:minmax(300px,42%) 1fr;gap:20px;margin-top:14px}} figure{{margin:0}} figure img{{width:100%;height:270px;object-fit:contain;background:#f9fbfd;border:1px solid var(--line);border-radius:8px}} figcaption{{color:var(--muted);margin-top:6px}}
.route{{color:var(--muted);margin-top:0}} .structure{{display:grid;grid-template-columns:112px minmax(0,1fr);gap:8px 10px;margin:10px 0 15px}} .lane-label{{font-weight:700;padding-top:8px}} .layer-chain{{display:flex;align-items:stretch;gap:6px;overflow-x:auto;padding:2px 0 8px}} .layer{{display:flex;flex-direction:column;justify-content:center;min-width:118px;padding:8px 9px;background:#edf3ff;border-radius:7px}} .layer strong{{font-size:13px}} .layer small{{color:var(--muted);font-size:11px}} .process-chain .layer{{background:#fff1d8}} .fusion-chain .layer{{background:#e5f6ef}} .layer-arrow{{display:flex;align-items:center;color:var(--blue);font-weight:800}}
dl{{display:grid;grid-template-columns:130px 1fr;gap:6px 12px;margin:5px 0}} dt{{color:var(--muted)}} dd{{margin:0}} code{{font-size:12px;word-break:break-word}} .note,.warning{{padding:9px 11px;border-left:4px solid var(--blue);background:#f1f5ff}} .warning{{border-color:var(--amber);background:#fff7df}}
a{{color:var(--blue)}} footer{{color:var(--muted);margin-top:24px}}
@media(max-width:760px){{.model-grid{{grid-template-columns:1fr}} .card-head{{display:block}} .status{{display:inline-block;margin-top:8px}} figure img{{height:auto}} dl{{grid-template-columns:110px 1fr}}}}
</style></head><body><main>
<p><a href="../cad_predictions/index.html">All predictions on CAD</a> · <a href="../weak_gnn/index.html">Weak-GNN result</a> · <a href="../attributions/index.html">Local response diagnostics</a> · <a href="../../../data_processed/index.html">All processed inputs</a> · <a href="../report.json">Execution JSON</a></p>
<h1>Model inputs, process fusion, and outputs</h1>
<p class="lead">Each card separates the geometry representation from the XLS process branch and shows where they meet. Execution results prove tensor compatibility and gradients. A separate weak-training diagnostic maps every feasible model back onto common CAD nodes.</p>
<section class="summary"><div><span>Prepared representations</span><strong>6 families</strong></div><div><span>Forward/backward checks</span><strong>19 passed</strong></div><div><span>Independent physical runs</span><strong>2</strong></div><div><span>Validated prediction accuracy</span><strong>Unavailable</strong></div></section>
<section class="cad-predictions"><h2>Every model prediction mapped onto CAD</h2><p>These fields use the 18h01 weak target for a training-set representation comparison. Image, voxel and point outputs use stored source triangles and barycentric coordinates; graph outputs are already node-aligned; UV-Net uses a distance-marked face approximation; the tabular scalar is uniform. Gray regions exceed the 10 mm mapping-support threshold.</p><a href="../cad_predictions/index.html"><img src="../cad_predictions/comparison.png" alt="Weak-trained prediction fields from every model displayed on common CAD nodes"></a></section>
<section class="comparison"><h2>All-model result comparison</h2><p>Parameter count and CPU latency were measured in the same environment, but each model processes a different representation size and output domain, so latency is an operational measurement rather than a fair speed ranking. The two-step losses use a synthetic zero target and differ by output domain; only the within-model decrease checks gradient/optimizer behavior. The final column is the only loss computed from the supplied weak CAD/PLY targets.</p><img src="model-comparison.png" alt="Model parameter, CPU latency and synthetic loss-reduction comparison">
<div class="table-wrap"><table><thead><tr><th>Model</th><th>Output domain</th><th>Process fusion</th><th>Parameters</th><th>CPU forward</th><th>Synthetic loss, step 1→2</th><th>Weak-target training result</th></tr></thead><tbody>{comparison_table(comparison)}</tbody></table></div>
<p class="note"><strong>Task comparison:</strong> the risk GNN is currently the closest match to STEP + XLS → risk area + deformation because it produces CAD-node fields and consumes the process vector. Its IoU/Dice are training-set diagnostics from two unvalidated NG runs. CNN/FNO/3D U-Net already accept process conditioning but have no aligned spatial labels; PointNet, DGCNN, UV-Net and Sparse U-Net still need their XLS branch wired.</p></section>
<section class="thermal"><h2>Shared XLS thermal/process branch</h2><p>The plot shows the 34 heater values supplied to process-aware models. The complete vector has 50 XLS channels; values, observed masks and heater-imputation masks are retained separately.</p><img src="thermal-process-input.png" alt="Upper and lower 17-zone heater matrices for both runs"></section>
<section class="recommended"><h2>Requested MLP + encoder-feature concatenation</h2><p>This is the explicit late-fusion variant to test beside the current FiLM models. The process embedding is repeated across pixels, voxels, points, graph nodes or B-Rep faces before concatenation.</p>
<div class="structure" aria-label="Recommended late-fusion model structure">
  <div class="lane-label">Geometry</div><div class="layer-chain">{_layer_chain(["prepared geometry|image / voxel / point / graph / face", "geometry encoder|z_geo [B,S,C]"])}</div>
  <div class="lane-label">Process</div><div class="layer-chain process-chain">{_layer_chain(["XLS input|50 values + observed/imputed masks", "process MLP|150→64→32", "repeat embedding|z_proc [B,S,32]"])}</div>
  <div class="lane-label">Fusion / output</div><div class="layer-chain fusion-chain">{_layer_chain(["concatenate|[z_geo ; z_proc]", "fusion MLP/conv|C+32→C", "spatial heads|risk + deformation", "global pool + head|defect type"])}</div>
</div><p class="note">For the current two-run diagnostic, the GNN injects its process embedding into every edge message. CNN/FNO/3D U-Net currently use a single Linear-to-FiLM branch. PointNet, DGCNN, UV-Net and Sparse U-Net currently have no process branch.</p></section>
{''.join(cards)}
<footer>Post-process PLY scans are target/inspection data. They are excluded from pre-process inference inputs. Proposed fusion blocks are explicitly marked where the current baseline is still geometry-only.</footer>
</main></body></html>"""
    path = OUT / "index.html"
    path.write_text(document, encoding="utf-8")
    return path


if __name__ == "__main__":
    print(build())
