#!/usr/bin/env python3
"""Build reproducible AC28/ME1a preprocessing size candidates and decision report."""
from __future__ import annotations

import csv
import html
import json
import math
import os
import shutil
import sys
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from scipy.spatial import cKDTree

ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "전처리"))
from compare_graph_representations import prepared_cad
from representations import cad_points, dense_surface_tensor, make_multiview, surface_voxels

PARTS = {"AC28": ROOT, "ME1a_DT": ROOT / "experiments/ME1a_DT"}
IMAGE_SIZES = (64, 128, 256)
VOXEL = {32: (30.0, (32, 4, 20)), 64: (15.0, (64, 8, 40)),
         128: (7.5, (128, 16, 80))}
POINT_SIZES = (2048, 4096, 8192)
GRAPH_SIZES = (3000, 5000, 6000, 12000)
IMAGE_FOV_MM = 980.0


def save_npz(path: Path, arrays: dict[str, np.ndarray]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    np.savez_compressed(path, **arrays)


def write_csv(path: Path, rows: list[dict]) -> None:
    if not rows: return
    with path.open("w", newline="", encoding="utf-8") as fh:
        fields = list(dict.fromkeys(key for row in rows for key in row))
        writer = csv.DictWriter(fh, fieldnames=fields)
        writer.writeheader(); writer.writerows(rows)


def resized_nearest(a: np.ndarray, size: int) -> np.ndarray:
    index = np.rint(np.linspace(0, a.shape[-1] - 1, size)).astype(int)
    return a[:, index][:, :, index]


def surface_from_barycentric(cad: dict, voxels: dict) -> np.ndarray:
    tri = cad["face"].T[voxels["surface_sample_source_triangle"]]
    bary = voxels["surface_sample_barycentric"]
    return np.einsum("ni,nij->nj", bary, cad["pos_mm"][tri]).astype(np.float32)


def assess_part(name: str, root: Path) -> tuple[list[dict], dict]:
    cad = prepared_cad(root / "data_processed/graphs/graph_5000.npz")
    candidate_dir = HERE / "candidates" / name
    standard_dir = HERE / "standardized" / name
    candidate_dir.mkdir(parents=True, exist_ok=True)
    standard_dir.mkdir(parents=True, exist_ok=True)
    rows: list[dict] = []
    previews = {"images": {}, "voxels": {}}
    images = {}
    for size in IMAGE_SIZES:
        image = make_multiview(cad, size, IMAGE_FOV_MM)
        path = candidate_dir / f"image_{size}.npz"
        save_npz(path, image)
        images[size] = image
        previews["images"][size] = image["depth_mm"][2], image["visibility"][2]
    reference = images[256]
    for size, image in images.items():
        low = resized_nearest(image["visibility"], 256)
        ref = reference["visibility"]
        common = low & ref
        depth = resized_nearest(image["depth_mm"], 256)
        rows.append({"part": name, "modality": "image", "candidate": size,
                     "fixed_tensor_shape": f"6x{size}x{size}",
                     "physical_unit_mm": IMAGE_FOV_MM / (size - 1),
                     "fidelity_metric": "silhouette_iou_to_256",
                     "fidelity": float((low & ref).sum() / max(1, (low | ref).sum())),
                     "secondary_metric": "depth_mae_mm_on_common_pixels",
                     "secondary": float(np.mean(np.abs(depth[common] - reference["depth_mm"][common]))),
                     "native_elements": int(6 * size * size),
                     "raw_feature_mb": round(image["x"].nbytes / 1e6, 3),
                     "candidate_file": str((candidate_dir / f"image_{size}.npz").relative_to(HERE))})
    shutil.copy2(candidate_dir / "image_256.npz", standard_dir / "image_256.npz")

    voxels = {}
    for size, (pitch, shape) in VOXEL.items():
        voxel = surface_voxels(cad, size, voxel_pitch_mm=pitch, grid_shape=shape)
        assert tuple(voxel["grid_dims"]) == shape
        path = candidate_dir / f"voxel_{size}.npz"
        save_npz(path, voxel)
        voxels[size] = voxel
        previews["voxels"][size] = voxel["voxel_coords"]
    fine_surface = surface_from_barycentric(cad, voxels[128])
    for size, voxel in voxels.items():
        centers = voxel["grid_origin_mm"] + (voxel["voxel_coords"] + .5) * float(voxel["voxel_pitch_mm"])
        distance = cKDTree(centers).query(fine_surface, workers=-1)[0]
        rows.append({"part": name, "modality": "voxel", "candidate": size,
                     "fixed_tensor_shape": "x".join(map(str, VOXEL[size][1])),
                     "physical_unit_mm": VOXEL[size][0],
                     "fidelity_metric": "surface_to_occupied_center_p95_mm",
                     "fidelity": float(np.quantile(distance, .95)),
                     "secondary_metric": "occupied_surface_cells",
                     "secondary": int(len(voxel["voxel_coords"])),
                     "native_elements": int(np.prod(VOXEL[size][1])),
                     "raw_feature_mb": round(12 * int(np.prod(VOXEL[size][1])) * 4 / 1e6, 3),
                     "candidate_file": str((candidate_dir / f"voxel_{size}.npz").relative_to(HERE))})
    shutil.copy2(candidate_dir / "voxel_64.npz", standard_dir / "voxel_sparse_64.npz")
    dense = dense_surface_tensor(voxels[64])
    dense.update({"grid_origin_mm": voxels[64]["grid_origin_mm"],
                  "voxel_pitch_mm": voxels[64]["voxel_pitch_mm"]})
    save_npz(standard_dir / "voxel_dense_64.npz", dense)

    for size in POINT_SIZES:
        points = cad_points(cad, size)
        path = candidate_dir / f"points_{size}.npz"
        save_npz(path, points)
        distance = cKDTree(points["pos_mm"]).query(cad["pos_mm"], workers=-1)[0]
        rows.append({"part": name, "modality": "point", "candidate": size,
                     "fixed_tensor_shape": f"{size}x{points['x'].shape[1]}",
                     "physical_unit_mm": "",
                     "fidelity_metric": "cad_node_to_sample_p95_mm",
                     "fidelity": float(np.quantile(distance, .95)),
                     "secondary_metric": "sampled_surface_points",
                     "secondary": size,
                     "native_elements": size,
                     "raw_feature_mb": round(points["x"].nbytes / 1e6, 3),
                     "candidate_file": str(path.relative_to(HERE))})
    shutil.copy2(candidate_dir / "points_8192.npz", standard_dir / "points_8192.npz")
    for budget, filename in ((5000, "graph_input_5000.npz"),
                             (12000, "graph_reference_12000.npz")):
        shutil.copy2(root / f"data_processed/graphs/graph_{budget}.npz", standard_dir / filename)
    return rows, previews


def graph_rows() -> list[dict]:
    combined = ROOT / "results/ME1a_DT_comparison"
    representation = json.loads((combined / "representation_sensitivity.json").read_text())["records"]
    fit = json.loads((combined / "graph_fit/report.json").read_text())["records"]
    rows = []
    for part, root in PARTS.items():
        for budget in GRAPH_SIZES:
            r = next(x for x in representation if x["part"] == part and x["requested_nodes"] == budget)
            f = next(x for x in fit if x["part"] == part and x["requested_nodes"] == budget)
            rows.append({"part": part, "modality": "graph", "candidate": budget,
                         "fixed_tensor_shape": f"variable_actual_{r['actual_nodes']}",
                         "physical_unit_mm": "",
                         "fidelity_metric": "source_to_graph_p95_mm",
                         "fidelity": r["source_to_graph_p95_mm"],
                         "secondary_metric": "six_view_silhouette_iou_to_12k",
                         "secondary": r["image_silhouette_iou_128"],
                         "native_elements": r["actual_nodes"],
                         "raw_feature_mb": round(r["actual_nodes"] * 16 * 4 / 1e6, 3),
                         "weak_label_transfer_iou": f["weak_label_transfer_iou_on_finest_graph"],
                         "candidate_file": str((root / f"data_processed/graphs/graph_{budget}.npz").relative_to(ROOT))})
    return rows


def score_rows(rows: list[dict]) -> list[dict]:
    scored = []
    for modality in ("image", "voxel", "graph", "point"):
        sizes = sorted({int(r["candidate"]) for r in rows if r["modality"] == modality})
        min_cost = min(r["native_elements"] for r in rows if r["modality"] == modality)
        for size in sizes:
            group = [r for r in rows if r["modality"] == modality and r["candidate"] == size]
            max_cost = max(r["native_elements"] for r in group)
            cost = 20 * math.sqrt(min_cost / max_cost)
            if modality == "image":
                worst_iou = min(r["fidelity"] for r in group)
                max_pitch = max(float(r["physical_unit_mm"]) for r in group)
                quality = 50 * min(1, worst_iou / .99)
                detail = 30 * min(1, 5.0 / max_pitch)
                gate = worst_iou >= .99 and max_pitch <= 5.0
                evidence = f"worst IoU {worst_iou:.3f}; pixel {max_pitch:.2f} mm"
                purpose = "localized 2D CNN input"
            elif modality == "voxel":
                worst_p95 = max(r["fidelity"] for r in group)
                pitch = max(float(r["physical_unit_mm"]) for r in group)
                quality = 50 * min(1, 15.0 / worst_p95)
                detail = 30 * min(1, 15.0 / pitch)
                gate = worst_p95 <= 15.0 and pitch <= 15.0
                evidence = f"worst center P95 {worst_p95:.2f} mm; pitch {pitch:.1f} mm"
                purpose = "coarse 3D shape context"
            elif modality == "graph":
                worst_p95 = max(r["fidelity"] for r in group)
                worst_iou = min(r["secondary"] for r in group)
                quality = 50 * min(1, .35 / worst_p95)
                detail = 30 * min(1, worst_iou / .99)
                gate = worst_p95 <= .35 and worst_iou >= .99
                evidence = f"worst surface P95 {worst_p95:.3f} mm; view IoU {worst_iou:.3f}"
                purpose = "CAD geometry/context graph"
            else:
                worst_p95 = max(r["fidelity"] for r in group)
                quality = 50 * min(1, 10.0 / worst_p95)
                detail = 30 * min(1, size / 8192)
                gate = worst_p95 <= 10.0
                evidence = f"worst CAD-node coverage P95 {worst_p95:.2f} mm"
                purpose = "CAD PointNet input"
            scored.append({"modality": modality, "candidate": size,
                           "purpose": purpose, "evidence": evidence,
                           "quality_points_of_50": round(quality, 2),
                           "detail_points_of_30": round(detail, 2),
                           "cost_points_of_20": round(cost, 2),
                           "total_of_100": round(quality + detail + cost, 2),
                           "passes_gate": bool(gate), "selected": False})
    for modality in ("image", "voxel", "graph", "point"):
        passing = [r for r in scored if r["modality"] == modality and r["passes_gate"]]
        if not passing: raise ValueError(f"No {modality} size satisfies the declared decision gate")
        # Smallest adequate candidate is the declared policy; score shows the
        # quality/detail/cost trade-off but never rescues a failed hard gate.
        min(passing, key=lambda r: r["candidate"])["selected"] = True
    return scored


def fit_rows() -> list[dict]:
    paths = {5000: HERE / "gnn_5000_hard/report.json",
             12000: ROOT / "results/ME1a_DT_comparison/gnn_joint_hard/report.json"}
    rows = []
    for budget, path in paths.items():
        report = json.loads(path.read_text())
        if report["epochs_per_fit"] != 180 or report["loss_mode"] != "hard":
            raise ValueError("GNN comparison must use the same 180-epoch hard-label fit")
        for run in ("AC28_16h", "AC28_18h01", "ME1a_14h06"):
            for setup in (f"single_{run}", "joint", f"holdout_{run}"):
                r = next(x for x in report["rows"] if x["budget"] == budget
                         and x["kind"] == "residual_geo" and x["eval_run"] == run
                         and x["setup"] == setup)
                rows.append({"requested_nodes": budget, "run": run,
                             "fit": "individual" if setup.startswith("single") else
                                    "held_out" if setup.startswith("holdout") else "joint",
                             "actual_nodes": r["actual_nodes"],
                             "same_or_held_out_weak_ap": r["mapped_ap"],
                             "same_or_held_out_weak_iou": r["mapped_iou"],
                             "weak_label_transfer_iou": r["label_transfer_iou"],
                             "source_report": str(path.relative_to(ROOT))})
    return rows


def figures(rows: list[dict], scores: list[dict], fits: list[dict], previews: dict) -> None:
    fig, axes = plt.subplots(2, 3, figsize=(14, 7), constrained_layout=True)
    for row, part in enumerate(PARTS):
        for col, size in enumerate(IMAGE_SIZES):
            depth, visible = previews[part]["images"][size]
            axes[row, col].imshow(np.ma.masked_where(~visible, depth), cmap="viridis", origin="lower",
                                  interpolation="nearest")
            axes[row, col].set_title(f"{part} · {size}² · {IMAGE_FOV_MM/(size-1):.1f} mm/pixel")
            axes[row, col].axis("off")
    fig.suptitle("What: same 980 mm field of view, six directions, different raster sizes")
    fig.savefig(HERE / "image_sizes.png", dpi=165); plt.close(fig)

    fig, axes = plt.subplots(2, 3, figsize=(14, 7), constrained_layout=True)
    for row, part in enumerate(PARTS):
        for col, size in enumerate(VOXEL):
            coord = previews[part]["voxels"][size]
            axes[row, col].scatter(coord[:, 0], coord[:, 2], s=max(.08, 15 / size), c="#2b6d94")
            axes[row, col].set_xlim(0, VOXEL[size][1][0]); axes[row, col].set_ylim(0, VOXEL[size][1][2])
            axes[row, col].set_aspect("equal"); axes[row, col].set_title(
                f"{part} · {size} axis cells · {VOXEL[size][0]:g} mm voxel\n{len(coord):,} occupied")
            axes[row, col].set_xlabel("grid X"); axes[row, col].set_ylabel("grid Z")
    fig.suptitle("How: centered fixed physical voxel grids; X-Z projection of surface occupancy")
    fig.savefig(HERE / "voxel_sizes.png", dpi=165); plt.close(fig)

    fig, axes = plt.subplots(1, 3, figsize=(15, 4.5), constrained_layout=True)
    for part in PARTS:
        sub = [r for r in rows if r["part"] == part and r["modality"] == "graph"]
        axes[0].plot([r["native_elements"] for r in sub], [r["fidelity"] for r in sub], "o-", label=part)
        axes[1].plot([r["native_elements"] for r in sub], [r["secondary"] for r in sub], "o-", label=part)
        axes[2].plot([r["native_elements"] for r in sub], [r["weak_label_transfer_iou"] for r in sub], "o-", label=part)
    for ax, title in zip(axes, ("Source-to-graph surface P95 (mm)", "Image silhouette IoU vs 12k",
                                "Weak label transfer IoU vs 12k")):
        ax.set_title(title); ax.set_xlabel("Actual nodes"); ax.grid(alpha=.25)
        ax.axvline(5000, color="#c96b30", linestyle="--", alpha=.7)
    axes[0].legend(); fig.suptitle("Why: 5k retains shape; small risk regions do not transfer reliably")
    fig.savefig(HERE / "graph_sizes.png", dpi=165); plt.close(fig)

    fig, axes = plt.subplots(1, 4, figsize=(16, 4.5), constrained_layout=True)
    for ax, modality in zip(axes, ("image", "voxel", "graph", "point")):
        sub = [r for r in scores if r["modality"] == modality]
        ax.bar(range(len(sub)), [r["total_of_100"] for r in sub],
               color=["#126d83" if r["selected"] else "#aab8c4" if r["passes_gate"] else "#d8a78d" for r in sub])
        ax.set_xticks(range(len(sub)), [str(r["candidate"]) for r in sub]); ax.set_ylim(0, 105)
        ax.set_xlabel("candidate size"); ax.set_title(modality); ax.grid(axis="y", alpha=.2)
    axes[0].set_ylabel("weighted score / 100")
    fig.suptitle("Decision score: quality 50 + spatial detail 30 + cost 20; size must also pass its gate")
    fig.savefig(HERE / "scorecard.png", dpi=165); plt.close(fig)

    fig, axes = plt.subplots(1, 3, figsize=(15, 4.5), constrained_layout=True)
    for ax, run in zip(axes, ("AC28_16h", "AC28_18h01", "ME1a_14h06")):
        sub = [r for r in fits if r["run"] == run]
        labels = ["individual", "joint", "held_out"]
        for i, budget in enumerate((5000, 12000)):
            values = [next(r["same_or_held_out_weak_ap"] for r in sub if r["fit"] == fit
                           and r["requested_nodes"] == budget) for fit in labels]
            ax.bar(np.arange(3) + (i - .5) * .34, values, width=.34,
                   label=f"{budget:,} requested" if ax is axes[0] else None)
        ax.set_xticks(range(3), labels, rotation=18); ax.set_title(run); ax.grid(axis="y", alpha=.2)
    axes[0].set_ylabel("mapped weak-label AP")
    axes[0].legend(fontsize=8)
    fig.suptitle("Same training recipe, two graph sizes; same-run and held-out diagnostics separated")
    fig.savefig(HERE / "gnn_5000_vs_12000.png", dpi=165); plt.close(fig)


def table(rows: list[dict], columns: tuple[tuple[str, str], ...]) -> str:
    head = "".join(f"<th>{html.escape(label)}</th>" for _, label in columns)
    body = "".join("<tr>" + "".join(f"<td>{html.escape(str(row[key]))}</td>" for key, _ in columns)
                   + "</tr>" for row in rows)
    return f"<div class='scroll'><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>"


def report(rows: list[dict], scores: list[dict], fits: list[dict]) -> None:
    chosen = {r["modality"]: r["candidate"] for r in scores if r["selected"]}
    contract = {
        "purpose": "fixed cross-part model input sizes; existing historical checkpoints are not silently changed",
        "image": {"views": 6, "height": chosen["image"], "width": chosen["image"],
                  "field_of_view_mm": IMAGE_FOV_MM, "pixel_pitch_mm": IMAGE_FOV_MM / (chosen["image"] - 1),
                  "source": "graph_5000", "format": "orthographic raster with validity masks"},
        "graph_context": {"requested_node_budget": chosen["graph"],
                          "actual_nodes": {p: next(r["native_elements"] for r in rows if r["part"] == p
                                                   and r["modality"] == "graph" and r["candidate"] == chosen["graph"])
                                           for p in PARTS},
                          "format": "variable actual nodes, common feature/edge schema"},
        "graph_localized_output_reference": {"requested_node_budget": 12000,
                "reason": "5k weak-label transfer and 180-epoch node-wise risk fit lose small regions; no 5k-to-12k prediction head has been validated"},
        "voxel": {"physical_pitch_mm": VOXEL[chosen["voxel"]][0],
                  "dense_grid_shape": list(VOXEL[chosen["voxel"]][1]),
                  "origin": "each CAD bounding-box center minus half the shared physical grid extent",
                  "surface_only": True, "source": "graph_5000"},
        "cad_points": {"count": chosen["point"], "source": "graph_5000", "sampling_seed": 20260914},
        "brep_uv_grid": {"shape_per_face": [8, 8], "face_count_variable_by_part": True,
                         "note": "existing independent face-grid contract; not a dense whole-part tensor"},
        "selection_rule": "smallest candidate meeting every modality-specific hard gate; weighted score is explanatory, not a substitute for the gates",
        "score_weights": {"fidelity": 50, "spatial_detail": 30, "resource_cost": 20},
        "hard_gates": {"image": "worst six-view IoU to 256 >= 0.99 and pixel pitch <= 5 mm",
                       "voxel": "worst fine-surface-to-occupied-center P95 <= 15 mm and pitch <= 15 mm; shape context only",
                       "graph_context": "worst source-surface P95 <= 0.35 mm and six-view IoU to 12k >= 0.99",
                       "point": "worst CAD-node-to-sampled-point P95 <= 10 mm"},
        "limitations": ["Weak 10 mm regions are provisional CSV-derived targets, not reviewed defect extents.",
                        "Five-thousand-node graphs are selected only for geometry/context input; current localized GNN outputs remain on 12k.",
                        "Historical CNN and voxel model checkpoints were trained on 128 images and 32 voxels, respectively; new selected-size files are preprocessing contracts, not retrained checkpoints.",
                        "No production accuracy can be inferred from three physical runs or unvalidated registration."]}
    (HERE / "standard_sizes.json").write_text(json.dumps(contract, indent=2, ensure_ascii=False), encoding="utf-8")
    (HERE / "measurements.json").write_text(json.dumps({"rows": rows, "scores": scores, "gnn_fits": fits}, indent=2, ensure_ascii=False), encoding="utf-8")
    write_csv(HERE / "measurements.csv", rows)
    write_csv(HERE / "decision_scores.csv", scores)
    write_csv(HERE / "gnn_5000_vs_12000.csv", fits)
    summary = ("Six 256×256 views at a shared 980 mm field of view; a 5,000-requested-node "
               "CAD context graph; centered 64×8×40 surface voxels at 15 mm; 8,192 CAD points. "
               "Keep the 12,000-node graph for localized weak-risk output and evaluation.")
    score_display = []
    for item in scores:
        score_display.append({"modality": item["modality"], "candidate": item["candidate"],
                              "evidence": item["evidence"], "score": item["total_of_100"],
                              "gate": "pass" if item["passes_gate"] else "fail",
                              "decision": "SELECT" if item["selected"] else ""})
    graph_display = []
    for row in rows:
        if row["modality"] == "graph":
            graph_display.append({"part": row["part"], "requested": row["candidate"],
                                  "actual": row["native_elements"],
                                  "surface_p95": f"{row['fidelity']:.3f}",
                                  "view_iou": f"{row['secondary']:.3f}",
                                  "label_transfer": f"{row['weak_label_transfer_iou']:.3f}"})
    fit_display = [{"run": r["run"], "nodes": r["requested_nodes"], "fit": r["fit"],
                    "ap": f"{r['same_or_held_out_weak_ap']:.3f}",
                    "iou": f"{r['same_or_held_out_weak_iou']:.3f}"} for r in fits]
    rel = lambda path: os.path.relpath(path, HERE).replace(os.sep, "/")
    page = f'''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>AC28 + ME1a preprocessing size decisions</title>
<style>body{{font:15px/1.5 system-ui,sans-serif;color:#182b3a;background:#f2f6fa;max-width:1500px;margin:auto;padding:24px}}h1,h2{{line-height:1.2}}section{{background:white;border:1px solid #d7e1e9;border-radius:12px;padding:20px;margin:18px 0}}.lead{{font-size:18px}}.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}}.card{{background:#e8f1f7;padding:14px;border-radius:8px}}.card strong{{font-size:22px}}img{{width:100%;border:1px solid #dce5ea;border-radius:7px;margin:10px 0}}.scroll{{overflow:auto}}table{{border-collapse:collapse;min-width:780px;width:100%}}td,th{{border-bottom:1px solid #dfe7ed;padding:8px;text-align:right;white-space:nowrap}}td:first-child,th:first-child{{text-align:left}}.warn{{background:#fff0cd;border-left:5px solid #c28b16;padding:12px}}a{{color:#126193}}</style>
<h1>AC28 + ME1a: preprocessing size decisions</h1><p class="lead">{summary}</p>
<p class="warn">The 5k graph is a fixed geometry/context <em>input</em>. It is not approved as the current node-wise risk output grid: small provisional target regions are lost at 5k. Historical model checkpoints were trained on their original sizes and have not been relabeled as selected-size models.</p>
<section><h2>What is fixed?</h2><div class="cards"><div class="card"><strong>Image: 256²</strong><p>Six views, 980 mm field of view, 3.84 mm/pixel.</p></div><div class="card"><strong>Graph input: 5k</strong><p>4,920 AC28 / 5,132 ME1a actual nodes. Shared node/edge schema.</p></div><div class="card"><strong>Voxel: 64×8×40</strong><p>15 mm isotropic cells; centered fixed physical grid; surface occupancy.</p></div><div class="card"><strong>Points: 8,192</strong><p>Deterministic CAD surface samples.</p></div><div class="card"><strong>Risk grid: 12k</strong><p>Existing fine graph for localized outputs and size comparison.</p></div></div>
<p><a href="standard_sizes.json">Machine-readable size contract</a> · <a href="standardized/index.html">All standardized inputs</a></p></section>
<section><h2>How were candidate inputs made?</h2><p>Both parts start from their 5,000-requested-node CAD graph. Images rasterize six orthographic views with the same 980 mm square field of view and validity masks. Voxels sample CAD triangles at fixed physical pitches of 30, 15 and 7.5 mm, center each part in the same candidate grid shape, and keep only occupied surface cells. Points use deterministic area-based triangle samples. Graph comparisons reuse the original decimated meshes, surface distance measurements and weak-label transfer audit.</p><img src="image_sizes.png" alt="Image size visual comparison"><img src="voxel_sizes.png" alt="Voxel size visual comparison"><p><a href="candidates/index.html">All 26 per-part/per-size PNG input samples</a> · <a href="measurements.csv">All per-part candidate measurements CSV</a> · <a href="measurements.json">Measurements and scoring JSON</a> · <a href="validation_report.json">Cross-part input and model-shape validation JSON</a></p></section>
<section><h2>Why these sizes? Decision rule and score</h2><p>First require each candidate to pass the modality's physical/geometry gate. Then select the smallest passing candidate. The 100-point diagnostic score is fidelity (50) + spatial detail (30) + relative input cost (20); it exposes the trade-off but cannot override a failed gate.</p><ul><li><b>Images:</b> worst silhouette IoU to 256 ≥ 0.99 and physical pixel ≤ 5 mm. Only 256 has enough pixels for a provisional 10 mm-radius region.</li><li><b>Voxels:</b> worst fine-surface-to-occupied-center P95 ≤ 15 mm and cell pitch ≤ 15 mm. This chooses 64 for coarse 3D context; 15 mm voxels are not a precise 10 mm defect output.</li><li><b>Graph input:</b> worst source-surface P95 ≤ 0.35 mm and six-view IoU to 12k ≥ 0.99. 5k is the smallest passing geometry graph.</li><li><b>Points:</b> worst CAD-node-to-sample P95 ≤ 10 mm, below the provisional 10 mm target radius. Use the smallest passing sample count.</li></ul><img src="scorecard.png" alt="Size decision scorecard">
{table(score_display, (("modality","Input"),("candidate","Candidate"),("evidence","Evidence"),("score","Score / 100"),("gate","Gate"),("decision","Choice")))}<p><a href="decision_scores.csv">Decision scores CSV</a></p></section>
<section><h2>Graph nodes: shape versus localized targets</h2><p>Five thousand requested nodes preserves the CAD surface and rendered views while cutting graph size relative to 12k. The provisional small weak mask transfers poorly at 5k. Keep 12k as the current risk-output/evaluation grid; a future 5k-input/12k-output model would require a cross-resolution decoder and separate validation.</p><img src="graph_sizes.png" alt="Graph shape and weak label transfer by node size">
{table(graph_display, (("part","Part"),("requested","Requested"),("actual","Actual"),("surface_p95","Surface P95 mm"),("view_iou","Image IoU"),("label_transfer","Weak mask transfer IoU")))}
<p><a href="../../results/ME1a_DT_comparison/representation_sensitivity.csv">Original representation sensitivity CSV</a> · <a href="../../results/ME1a_DT_comparison/graph_fit/report.csv">Original graph-fit CSV</a></p></section>
<section><h2>Can 5k exactly match the ME1a 12k scores?</h2><p>Under the current evaluation rule, each fine 12k node inherits its nearest coarse graph node's single prediction. Separately optimizing the AP ranking and the binary overlap under that fixed projection gives the ME1a 5k graph maximum AP 0.6261, IoU 0.4583 and Dice 0.6286. At the IoU optimum there are 11 true positives, 11 false positives and 2 false negatives. Thus the fixed 5k projection cannot exactly reach the observed 12k same-run AP 0.6292, IoU 0.4762 and Dice 0.6452. The observed 5k model scored only AP 0.0685 and IoU 0.0547, well below its own ceilings, so optimization also matters. Different graph placement or a learned upsampling decoder could change these ceilings; neither has been validated.</p><img src="projection_oracle.png" alt="Best possible projected AP and IoU versus observed GNN scores by graph size"><p><a href="projection_oracle.csv">All runs and graph budgets, oracle CSV</a> · <a href="compute_projection_oracle.py">Calculation code</a></p></section>
<section><h2>Same-recipe GNN check: 5k versus 12k</h2><p>Residual GNNs at both sizes used 180 epochs and the same hard, class-balanced provisional mask recipe. Individual/joint scores are on training runs; held-out scores use a run excluded from training. They are diagnostics, not production accuracy. In particular, the 5k ME1a individual fit loses most of the localized weak target compared with 12k.</p><img src="gnn_5000_vs_12000.png" alt="GNN weak AP at two graph sizes">
{table(fit_display, (("run","Run"),("nodes","Requested"),("fit","Fit"),("ap","Weak AP"),("iou","Weak IoU")))}
<p><a href="gnn_5000_vs_12000.csv">Full GNN comparison CSV</a> · <a href="gnn_5000_hard/index.html">5k trained GNN report</a> · <a href="../../results/ME1a_DT_comparison/gnn_joint_hard/index.html">12k trained GNN report</a></p></section>
<section><h2>Files and limits</h2><p>The <code>candidates/</code> folder holds every generated image, voxel and point size for both parts, plus PNG previews for those inputs and every tested graph size. The graph NPZ files remain in each part's <code>data_processed/graphs/</code> folder. The <code>standardized/</code> folder holds the selected inputs and the 12k reference graph. Raw scans remain post-process diagnostics; CAD-only inputs do not include them. ME1a registration and weak defect extents are unvalidated, and three physical runs cannot establish model generalization.</p><p><a href="candidates/index.html">All tested input files and PNGs</a> · <a href="README.md">Methods and decisions in Markdown</a> · <a href="../../experiments/index.html">All experiments</a> · <a href="../../results/index.html">All results</a></p></section></html>'''
    (HERE / "index.html").write_text(page, encoding="utf-8")
    markdown = f"""# AC28 + ME1a preprocessing size decisions

## What

- Image: six 256×256 views, common 980 mm field of view (3.84 mm/pixel).
- Geometry/context graph input: 5,000 requested nodes; AC28 4,920 and ME1a 5,132 actual.
- Voxel context input: 64×8×40 centered grid, isotropic 15 mm cells, surface-only occupancy.
- CAD point input: 8,192 deterministic surface samples.
- Localized weak-risk output/reference: retain the 12,000-requested-node graph.

## How

Run `.venv/bin/python experiments/preprocess_size_desicisions/build.py`, `validate_standardized.py`, `render_candidate_samples.py`, `publish_candidate_gallery.py`, and `compute_projection_oracle.py` in that order. Both parts use their own 5k CAD mesh with the same image field of view, physical voxel pitch/grid dimensions, sampling seed and feature masks. All candidate NPZ files are in `candidates/`; selected files are in `standardized/`. Compare the generated `measurements.csv`, `decision_scores.csv`, `gnn_5000_vs_12000.csv`, `projection_oracle.csv`, and the PNG plots. The browser report is `index.html`, with `page.png` beside it. The candidates gallery has every part/type/size sample PNG.

## Why

The selection rule is the smallest candidate passing the physical/geometry gate. The explanatory score is fidelity 50 + spatial detail 30 + relative input cost 20. The exact gates are in `standard_sizes.json` and the per-candidate evidence is in `decision_scores.csv`.

Five thousand nodes passes the CAD geometry gate, but does not preserve the small provisional weak-risk mask: 5k is a geometry/context input, not the current localized GNN output. The 12k reference remains necessary for the present risk labels and output. Fifteen-millimetre voxels are likewise coarse 3D shape context. Historical CNN/voxel checkpoints used 128 images and 32 voxels; the selected-size inputs require new training before claiming selected-size model results.

For ME1a, the best possible 5k-to-fine AP/IoU/Dice under the **current nearest-node projection** is 0.6261/0.4583/0.6286, slightly below the observed same-run 12k scores 0.6292/0.4762/0.6452. The actual 5k hard GNN reached AP 0.0685 and IoU 0.0547. These ceilings are specific to this graph and projection, not limits on learned decoders or adaptive node placement.

All reported AP/IoU values come from provisional CSV-derived labels with unvalidated scan-to-CAD registration. They are not verified production accuracy.
"""
    (HERE / "README.md").write_text(markdown, encoding="utf-8")
    print(HERE / "index.html")


def main() -> None:
    all_rows: list[dict] = []
    previews = {}
    for name, root in PARTS.items():
        part_rows, part_previews = assess_part(name, root)
        all_rows.extend(part_rows)
        previews[name] = part_previews
        print(name, "image/voxel/point candidates complete", flush=True)
    all_rows.extend(graph_rows())
    scores = score_rows(all_rows)
    fits = fit_rows()
    figures(all_rows, scores, fits, previews)
    report(all_rows, scores, fits)


if __name__ == "__main__":
    main()
