"""experiment_floor_plan_base_layout_vlm_readback_slice — W18C.

Single fp_l05_01 VLM readback for the W18B base-FP PNG. Pass the W18B
PNG to GPT-5.5 (vision) together with the W18A2 included marker legend
(#1..#17) and ask it to report:
  * `read_markers[]`        — per legend marker: visible / confidence /
                              coarse 10x10 rect / observed label
  * `base_unit_layout[]`    — coarse 10x10 rect per spatial unit
                              (markers #1..#6)
  * `structural_relationship_summary` — open-zone + topology + TV
                              divider observation
  * `unexpected_transient_markers[]` — diagnostic only: if any of the
                              excluded #18..#23 are visible.
  * `readback_conflicts[]`  — diagnostic only

Codex policy (W18C spec): the W18B PNG is NOT truth; the W18A2
candidate/legend stays the truth surface. 10x10 cells are coarse
relative reference, not pixel-precise coordinates — light validation
only.

No image API call (gpt-image-2). No DB / ImageAsset write. No
production manifest mutation. No commit, no push. retry 0.

CLI:
  --derive-base-vlm-readback-from <W18B_run_dir>   (required)
  --target-fp-ids fp_l05_01                        (default, restricted)
  --generate                                        (default off → dry-run)
  --model gpt-5.5                                   (default; fail-closed)
  --output-root <path>
  --diag-print-imports
"""
from __future__ import annotations

import argparse
import base64
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set

_REPO_ROOT = Path(__file__).resolve().parents[2]
_SCRIPTS_DIR = _REPO_ROOT / "backend" / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from experiment_background_pipeline_slice import (  # type: ignore
    KST,
    PLAN_VERSION,
    _check_image_imports_present,
    _check_production_diff_empty,
    _load_backend_env,
    _maybe_print_imports,
)
from experiment_floor_plan_grid_layout_slice import (  # type: ignore
    W16_ALLOWED_TARGET_FP_IDS,
    _resolve_targets,
)

W18C_STAGE = "w18c_floor_plan_base_layout_vlm_readback_slice"
W18C_DEFAULT_MODEL = "gpt-5.5"
W18C_CONFIDENCE_ENUM = frozenset({"high", "medium", "low", "unknown"})

W18C_SYSTEM_PROMPT = """\
You are a schematic floor-plan diagram reader.

Input you receive:
- One PNG image of a schematic floor-plan diagram with circled marker
  numbers drawn directly on the plan.
- A JSON `base_marker_legend` mapping each base marker number to
  {label, base_layer_decision, unit_id, visual_encoding}. These are
  the #1..#N base markers the diagram is supposed to show.
- A JSON `excluded_marker_numbers` array listing marker numbers that
  the base diagram is supposed to NOT depict (transient state cues,
  displaced or altered objects from the scene event). If you observe
  any of them in the image, report under `unexpected_transient_markers`.

Treat the legend as ground-truth for what the image is supposed to
show; your job is to OBSERVE where each base marker actually appears
in the image. Use a coarse 10x10 grid (columns 0..9 left-to-right,
rows 0..9 top-to-bottom) for relative position. Do NOT invent pixel
coordinates.

Output: a single JSON object with this shape (no extra top-level
keys, no markdown fences):

{
  "read_markers": [
    {
      "marker_number": int,
      "visible": bool,
      "confidence": "high" | "medium" | "low" | "unknown",
      "observed_label_or_area": string,
      "approximate_10x10_rect": [x1, y1, x2, y2]  // optional, 0..9
            // OR
      "approximate_10x10_cell": [col, row]        // optional, 0..9
      "notes": string
    }, ...
  ],
  "missing_or_ambiguous_markers": [
    {
      "marker_number": int,
      "reason": string
    }, ...
  ],
  "base_unit_layout": [
    {
      "marker_number": int,           // one of the spatial-unit markers
      "unit_id": string,              // copy from the legend if present
      "observed_area_label": string,
      "approximate_10x10_rect": [x1, y1, x2, y2],  // required, 0..9
      "adjacency_notes": string
    }, ...
  ],
  "structural_relationship_summary": {
    "open_zones_continuous": bool,           // open zones flow together
    "open_zone_notes": string,
    "private_rooms_count": int,
    "service_rooms_count": int,
    "entry_or_threshold_count": int,
    "topology_intact": bool,                 // overall topology matches the
                                              // legend (multi-room + service
                                              // cell + open primary zones)
    "tv_or_wall_fixture_looks_like_divider": bool,
    "tv_or_wall_fixture_notes": string,
    "compact_scale_observed": bool,
    "scale_notes": string
  },
  "unexpected_transient_markers": [
    {
      "marker_number": int,                 // from excluded list
      "notes": string
    }, ...
  ],
  "readback_conflicts": [
    {
      "marker_number": int,
      "expected": string,
      "observed": string,
      "note": string
    }, ...
  ]
}

Reading rules:
- Every base marker number MUST appear EITHER in `read_markers` OR in
  `missing_or_ambiguous_markers`. Do not skip a number, do not
  duplicate a number across the two lists.
- `approximate_10x10_rect` values MUST be integers in 0..9 with
  x1<=x2 and y1<=y2. If you cannot bound a marker confidently, use a
  small surrounding rect rather than guessing.
- Provide `base_unit_layout` entries for spatial-unit markers only
  (the ones with `base_layer_decision == "base_structural_unit"`).
- `unexpected_transient_markers` is diagnostic only — even if you see
  something that resembles an excluded marker, do NOT promote it to
  base truth. Just report it.
- Do not invent marker numbers that are not in the legend.
- Use generic architectural / spatial language only. Do not embed
  scenario-specific proper nouns or narrative wording beyond what the
  legend itself already provides.

Reply ONLY with the JSON object.
"""


def _vlm_caller_default(*, llm_input: Dict[str, Any], model: str,
                        retry_once: bool = False) -> Dict[str, Any]:
    """Default VLM caller using litellm. Test monkeypatch seam is the
    module-level `_vlm_caller` alias below. Fail-closed: missing
    OPENAI_API_KEY → RuntimeError; model id ≠ exact
    `W18C_DEFAULT_MODEL` → RuntimeError BEFORE litellm call. No
    fallback. No auto retry by default."""
    if not os.environ.get("OPENAI_API_KEY"):
        raise RuntimeError("missing OPENAI_API_KEY env var")
    if (model or "").strip() != W18C_DEFAULT_MODEL:
        raise RuntimeError(
            f"w18c routing refuses non-exact model: got '{model}', "
            f"required exact id '{W18C_DEFAULT_MODEL}' (no fallback allowed)"
        )

    import litellm  # lazy

    png_b64 = llm_input.get("_png_base64_data_url") or ""
    payload = {
        "fp_id": llm_input.get("fp_id") or "",
        "base_marker_legend": llm_input.get("base_marker_legend") or [],
        "excluded_marker_numbers": llm_input.get(
            "excluded_marker_numbers"
        ) or [],
    }
    user_content = [
        {
            "type": "text",
            "text": (
                "Read the marker positions in the attached PNG using "
                "the provided base marker legend, and emit the W18C "
                "JSON.\n\n"
                + json.dumps(payload, ensure_ascii=False)
            ),
        },
        {"type": "image_url", "image_url": {"url": png_b64}},
    ]
    last_exc: Optional[Exception] = None
    attempts = 2 if retry_once else 1
    for _ in range(attempts):
        try:
            resp = litellm.completion(
                model=model,
                messages=[
                    {"role": "system", "content": W18C_SYSTEM_PROMPT},
                    {"role": "user", "content": user_content},
                ],
                response_format={"type": "json_object"},
            )
            raw_text = resp.choices[0].message.content
            decoder = json.JSONDecoder()
            stripped = (raw_text or "").lstrip()
            parsed, _ = decoder.raw_decode(stripped)
            return parsed
        except Exception as exc:  # noqa: BLE001
            last_exc = exc
            continue
    raise RuntimeError(f"w18c_vlm_failed: {last_exc!s}"[:400])


# Test-monkeypatch seam.
_vlm_caller: Callable = _vlm_caller_default


_DEFAULT_OUTPUT_ROOT = (
    _REPO_ROOT
    / "scripts_output"
    / "floor_plan_base_layout_vlm_readback_slice_experiment"
)


def _run_id() -> str:
    import secrets

    return datetime.now(KST).strftime("%Y%m%d_%H%M") + "_" + secrets.token_hex(3)


def _parse_args(argv):
    p = argparse.ArgumentParser(
        description=(
            "W18C base-FP VLM readback slice — single fp, single VLM "
            "call. No image API call."
        )
    )
    p.add_argument(
        "--derive-base-vlm-readback-from", required=True,
        help="Path to a prior W18B success run dir (containing "
             "png/<fp_id>.png + run_meta.json).",
    )
    p.add_argument(
        "--target-fp-ids", default="fp_l05_01",
        help="Comma-separated fp_id subset. Wave 1 only allows fp_l05_01.",
    )
    p.add_argument(
        "--generate", action="store_true",
        help="Actually call the VLM once. Default off → dry-run.",
    )
    p.add_argument("--model", default=W18C_DEFAULT_MODEL)
    p.add_argument("--output-root", default=str(_DEFAULT_OUTPUT_ROOT))
    p.add_argument("--diag-print-imports", action="store_true")
    return p.parse_args(argv)


def _resolve_w18a_run_dir_from_w18b(
    w18b_meta: Dict[str, Any],
) -> Optional[Path]:
    args_dict = (w18b_meta or {}).get("args") or {}
    if isinstance(args_dict, dict):
        raw = args_dict.get("derive_base_layout_image_smoke_from")
        if isinstance(raw, str) and raw:
            p = Path(raw)
            if not p.is_absolute():
                p = _REPO_ROOT / raw
            if (p / "base_layout_prompt.json").exists():
                return p
    derived_from = (w18b_meta or {}).get("derived_from") or ""
    if isinstance(derived_from, str) and derived_from:
        parent = (
            _REPO_ROOT
            / "scripts_output"
            / "floor_plan_base_layout_prompt_slice_experiment"
        )
        if (parent / derived_from / "base_layout_prompt.json").exists():
            return parent / derived_from
    return None


def _rect_is_valid_10x10(rect: Any) -> bool:
    if not isinstance(rect, list) or len(rect) != 4:
        return False
    for v in rect:
        if not isinstance(v, int) or v < 0 or v > 9:
            return False
    x1, y1, x2, y2 = rect
    return x1 <= x2 and y1 <= y2


def _build_w18c_compatibility_report(
    *, vlm_output: Dict[str, Any],
    base_legend: List[Dict[str, Any]],
    excluded_marker_numbers: List[int],
    production_diff_empty: bool,
    db_write_count: int,
    image_import_seen: bool,
    image_api_call_count: int,
    vlm_api_call_count: int,
    expected_vlm_api_call_count: int,
    model_used: Optional[str],
    stage_status: str,
    missing_inputs: List[str],
    prev_run_id: str,
    target_fp_ids: Set[str],
) -> dict:
    inv: Dict[str, Dict[str, Any]] = {}

    # 1. inputs_present
    inv["inputs_present"] = {
        "pass": (
            not missing_inputs
            and bool(base_legend)
            and bool(vlm_output)
        ),
        "detail": {
            "missing_inputs": list(missing_inputs),
            "base_legend_count": len(base_legend),
            "excluded_marker_count": len(excluded_marker_numbers),
            "stage_status": stage_status,
            "prev_run_id": prev_run_id,
        },
    }

    # 2. target_fp_only_fp_l05_01
    inv["target_fp_only_fp_l05_01"] = {
        "pass": bool(target_fp_ids) and target_fp_ids.issubset(
            W16_ALLOWED_TARGET_FP_IDS
        ),
        "detail": {
            "received_target_fp_ids": sorted(target_fp_ids or set()),
            "allowed": sorted(W16_ALLOWED_TARGET_FP_IDS),
        },
    }

    # 3. model_is_gpt_5_5_when_generated
    if stage_status == "dry_run":
        model_pass = True
        model_detail = {"skip_reason": "dry_run"}
    else:
        model_pass = (model_used or "").strip() == W18C_DEFAULT_MODEL
        model_detail = {
            "model_used": model_used, "required": W18C_DEFAULT_MODEL,
            "stage_status": stage_status,
        }
    inv["model_is_gpt_5_5_when_generated"] = {
        "pass": model_pass, "detail": model_detail,
    }

    llm_active = stage_status == "generated"
    dry_skip = {"skip_reason": "dry_run"}

    # 4. read_markers_partition_base_legend
    if not llm_active:
        inv["read_markers_partition_base_legend"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        expected: Set[int] = {
            int(it["marker_number"]) for it in base_legend
            if isinstance(it, dict) and isinstance(
                it.get("marker_number"), int
            )
        }
        read_numbers: Set[int] = set()
        missing_numbers: Set[int] = set()
        partition_failures: List[dict] = []
        for entry in vlm_output.get("read_markers") or []:
            if not isinstance(entry, dict):
                partition_failures.append({
                    "field": "read_markers[*]", "reason": "not_a_dict",
                })
                continue
            n = entry.get("marker_number")
            if not isinstance(n, int):
                partition_failures.append({
                    "field": "read_markers[*].marker_number",
                    "reason": "not_int", "value": n,
                })
                continue
            if n in read_numbers:
                partition_failures.append({
                    "marker_number": n, "reason": "duplicate_in_read_markers",
                })
                continue
            read_numbers.add(n)
            if entry.get("confidence") not in W18C_CONFIDENCE_ENUM:
                partition_failures.append({
                    "marker_number": n,
                    "reason": "confidence_not_in_enum",
                    "value": entry.get("confidence"),
                })
            rect = entry.get("approximate_10x10_rect")
            cell = entry.get("approximate_10x10_cell")
            if rect is not None and not _rect_is_valid_10x10(rect):
                partition_failures.append({
                    "marker_number": n,
                    "reason": "approximate_10x10_rect_invalid",
                    "value": rect,
                })
            if (
                cell is not None
                and (
                    not isinstance(cell, list) or len(cell) != 2
                    or not all(
                        isinstance(v, int) and 0 <= v <= 9 for v in cell
                    )
                )
            ):
                partition_failures.append({
                    "marker_number": n,
                    "reason": "approximate_10x10_cell_invalid",
                    "value": cell,
                })
        for entry in vlm_output.get("missing_or_ambiguous_markers") or []:
            if not isinstance(entry, dict):
                partition_failures.append({
                    "field": "missing_or_ambiguous_markers[*]",
                    "reason": "not_a_dict",
                })
                continue
            n = entry.get("marker_number")
            if not isinstance(n, int):
                partition_failures.append({
                    "field": "missing_or_ambiguous_markers[*].marker_number",
                    "reason": "not_int", "value": n,
                })
                continue
            if n in missing_numbers:
                partition_failures.append({
                    "marker_number": n,
                    "reason": "duplicate_in_missing_markers",
                })
                continue
            missing_numbers.add(n)
        overlap = read_numbers & missing_numbers
        covered = read_numbers | missing_numbers
        unaccounted = expected - covered
        extras = covered - expected
        if overlap:
            partition_failures.append({
                "reason": "overlap_between_read_and_missing",
                "overlap": sorted(overlap),
            })
        if unaccounted:
            partition_failures.append({
                "reason": "base_legend_markers_unaccounted",
                "unaccounted": sorted(unaccounted),
            })
        if extras:
            partition_failures.append({
                "reason": "extra_marker_numbers_not_in_base_legend",
                "extras": sorted(extras),
            })
        inv["read_markers_partition_base_legend"] = {
            "pass": not partition_failures,
            "detail": {
                "failures": partition_failures[:30],
                "failure_count": len(partition_failures),
                "expected_count": len(expected),
                "read_count": len(read_numbers),
                "missing_or_ambiguous_count": len(missing_numbers),
            },
        }

    # 5. production / db / image / vlm count guard
    inv["production_diff_zero_db_write_zero_image_generation_zero"] = {
        "pass": (
            production_diff_empty
            and db_write_count == 0
            and not image_import_seen
            and image_api_call_count == 0
            and vlm_api_call_count == expected_vlm_api_call_count
        ),
        "detail": {
            "production_diff_empty": production_diff_empty,
            "db_write_count": db_write_count,
            "image_import_seen": image_import_seen,
            "image_api_call_count": image_api_call_count,
            "vlm_api_call_count": vlm_api_call_count,
            "expected_vlm_api_call_count": expected_vlm_api_call_count,
        },
    }

    all_pass = all(v["pass"] for v in inv.values())
    return {"invariants": inv, "all_pass": all_pass}


def _placeholder_dry_run_vlm_output() -> dict:
    return {
        "read_markers": [],
        "missing_or_ambiguous_markers": [],
        "base_unit_layout": [],
        "structural_relationship_summary": {
            "open_zones_continuous": False,
            "open_zone_notes": "placeholder_dry_run",
            "private_rooms_count": 0,
            "service_rooms_count": 0,
            "entry_or_threshold_count": 0,
            "topology_intact": False,
            "tv_or_wall_fixture_looks_like_divider": False,
            "tv_or_wall_fixture_notes": "placeholder_dry_run",
            "compact_scale_observed": False,
            "scale_notes": "placeholder_dry_run",
        },
        "unexpected_transient_markers": [],
        "readback_conflicts": [],
    }


def main(argv=None) -> int:
    args = _parse_args(argv)
    if args.generate:
        _load_backend_env()
    run_id = _run_id()
    out_root = Path(args.output_root)
    run_dir = out_root / run_id
    run_dir.mkdir(parents=True, exist_ok=True)

    prev_run_dir = Path(args.derive_base_vlm_readback_from)
    if not prev_run_dir.is_absolute():
        prev_run_dir = Path.cwd() / prev_run_dir

    target_fp_ids, invalid_targets = _resolve_targets(args.target_fp_ids)

    failed_invariants: List[str] = []
    run_status = "succeeded"
    exit_code = 0
    stage_status = "dry_run"
    vlm_api_call_count = 0
    model_used: Optional[str] = None
    expected_vlm = 1 if args.generate else 0

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W18C_STAGE,
        "plan_version": PLAN_VERSION,
        "generated_at": datetime.now(KST).isoformat(),
        "args": vars(args),
        "derived_from": prev_run_dir.name,
        "target_fp_ids": sorted(target_fp_ids),
        "invalid_targets": invalid_targets,
        "model_used": model_used,
        "expected_model": W18C_DEFAULT_MODEL,
        "image_api_call_count": 0,
        "vlm_api_call_count": vlm_api_call_count,
        "expected_vlm_api_call_count": expected_vlm,
        "outputs": [],
        "run_status": run_status,
        "exit_code": exit_code,
        "failed_invariants": failed_invariants,
        "stage_status": stage_status,
    }

    def _persist_and_exit(code: int) -> int:
        run_meta["exit_code"] = code
        if code != 0:
            run_meta["run_status"] = "validation_failed"
        run_meta["failed_invariants"] = failed_invariants
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return code

    w18b_meta_path = prev_run_dir / "run_meta.json"
    missing: List[str] = []
    if not w18b_meta_path.exists():
        missing.append("run_meta.json")
    if missing:
        failed_invariants.append("w18b_inputs_missing")
        run_meta["missing_inputs"] = missing
        return _persist_and_exit(1)
    if invalid_targets:
        failed_invariants.append("invalid_target_fp_ids")
        return _persist_and_exit(1)
    if not target_fp_ids.issubset(W16_ALLOWED_TARGET_FP_IDS):
        failed_invariants.append("target_fp_outside_allowed")
        return _persist_and_exit(1)

    w18b_meta = json.loads(w18b_meta_path.read_text())
    fp_id = sorted(target_fp_ids)[0]
    png_path = prev_run_dir / "png" / f"{fp_id}.png"
    if not png_path.exists():
        failed_invariants.append("w18b_png_missing")
        run_meta["png_path"] = str(png_path)
        return _persist_and_exit(1)

    w18a_run_dir = _resolve_w18a_run_dir_from_w18b(w18b_meta)
    base_legend: List[Dict[str, Any]] = []
    excluded_marker_numbers: List[int] = []
    if w18a_run_dir is not None:
        try:
            base_artifact = json.loads(
                (w18a_run_dir / "base_layout_prompt.json").read_text()
            )
            by_fp = (base_artifact or {}).get(
                "base_layout_prompt_by_fp"
            ) or {}
            fp_entry = by_fp.get(fp_id) or {}
            base_legend = list(fp_entry.get("included_marker_legend") or [])
            excluded_marker_numbers = [
                int(e["marker_number"])
                for e in fp_entry.get("excluded_transient_elements") or []
                if isinstance(e, dict) and isinstance(
                    e.get("marker_number"), int
                )
            ]
        except Exception:  # noqa: BLE001
            base_legend = []
            excluded_marker_numbers = []

    vlm_output: Dict[str, Any] = {}
    if args.generate:
        if (args.model or "").strip() != W18C_DEFAULT_MODEL:
            failed_invariants.append("model_must_be_gpt_5_5")
            return _persist_and_exit(1)
        if not os.environ.get("OPENAI_API_KEY"):
            failed_invariants.append("openai_api_key_missing")
            return _persist_and_exit(1)
        try:
            png_bytes = png_path.read_bytes()
            png_b64 = base64.b64encode(png_bytes).decode("ascii")
            data_url = f"data:image/png;base64,{png_b64}"
            llm_input = {
                "_png_base64_data_url": data_url,
                "fp_id": fp_id,
                "base_marker_legend": base_legend,
                "excluded_marker_numbers": excluded_marker_numbers,
            }
            vlm_output = _vlm_caller(
                llm_input=llm_input, model=args.model, retry_once=False,
            )
            vlm_api_call_count = 1
            model_used = args.model
            stage_status = "generated"
        except Exception as exc:  # noqa: BLE001
            failed_invariants.append("vlm_call_failed")
            run_meta["vlm_error"] = str(exc)[:400]
            run_status = "validation_failed"
            exit_code = 1
            stage_status = "vlm_failed"
    else:
        vlm_output = _placeholder_dry_run_vlm_output()
        model_used = None
        stage_status = "dry_run"

    (run_dir / "vlm_readback.json").write_text(
        json.dumps(vlm_output, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("vlm_readback.json")

    report = _build_w18c_compatibility_report(
        vlm_output=vlm_output,
        base_legend=base_legend,
        excluded_marker_numbers=excluded_marker_numbers,
        production_diff_empty=_check_production_diff_empty(),
        db_write_count=0,
        image_import_seen=_check_image_imports_present(),
        image_api_call_count=0,
        vlm_api_call_count=vlm_api_call_count,
        expected_vlm_api_call_count=expected_vlm,
        model_used=model_used,
        stage_status=stage_status,
        missing_inputs=missing,
        prev_run_id=prev_run_dir.name,
        target_fp_ids=target_fp_ids,
    )
    (run_dir / "vlm_readback_compatibility_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("vlm_readback_compatibility_report.json")

    for name, v in report["invariants"].items():
        if not v["pass"] and name not in failed_invariants:
            failed_invariants.append(name)
    if failed_invariants and run_status == "succeeded":
        run_status = "validation_failed"
        exit_code = 1

    run_meta["model_used"] = model_used
    run_meta["stage_status"] = stage_status
    run_meta["vlm_api_call_count"] = vlm_api_call_count
    run_meta["run_status"] = run_status
    run_meta["exit_code"] = exit_code
    run_meta["failed_invariants"] = failed_invariants

    def esc(x):
        return (
            str(x).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        )

    read_rows = "".join(
        f"<tr><td>#{esc(it.get('marker_number'))}</td>"
        f"<td>{esc(it.get('visible'))}</td>"
        f"<td>{esc(it.get('confidence'))}</td>"
        f"<td>{esc(it.get('observed_label_or_area'))}</td>"
        f"<td>{esc(it.get('approximate_10x10_rect'))}</td>"
        f"<td>{esc(it.get('approximate_10x10_cell'))}</td>"
        f"<td>{esc(it.get('notes'))}</td></tr>"
        for it in (vlm_output.get("read_markers") or [])
    )
    missing_rows = "".join(
        f"<tr><td>#{esc(it.get('marker_number'))}</td>"
        f"<td>{esc(it.get('reason'))}</td></tr>"
        for it in (vlm_output.get("missing_or_ambiguous_markers") or [])
    )
    unit_rows = "".join(
        f"<tr><td>#{esc(it.get('marker_number'))}</td>"
        f"<td>{esc(it.get('unit_id'))}</td>"
        f"<td>{esc(it.get('observed_area_label'))}</td>"
        f"<td>{esc(it.get('approximate_10x10_rect'))}</td>"
        f"<td>{esc(it.get('adjacency_notes'))}</td></tr>"
        for it in (vlm_output.get("base_unit_layout") or [])
    )
    unexpected_rows = "".join(
        f"<tr><td>#{esc(it.get('marker_number'))}</td>"
        f"<td>{esc(it.get('notes'))}</td></tr>"
        for it in (vlm_output.get("unexpected_transient_markers") or [])
    )
    conflict_rows = "".join(
        f"<tr><td>#{esc(it.get('marker_number'))}</td>"
        f"<td>{esc(it.get('expected'))}</td>"
        f"<td>{esc(it.get('observed'))}</td>"
        f"<td>{esc(it.get('note'))}</td></tr>"
        for it in (vlm_output.get("readback_conflicts") or [])
    )
    inv_rows = "".join(
        f"<tr><td>{esc(k)}</td>"
        f"<td class=\"{'pass' if v['pass'] else 'fail'}\">"
        f"{'PASS' if v['pass'] else 'FAIL'}</td>"
        f"<td><pre>{esc(json.dumps(v.get('detail'), ensure_ascii=False))[:800]}</pre></td>"
        f"</tr>"
        for k, v in (report.get("invariants") or {}).items()
    )

    summary = vlm_output.get("structural_relationship_summary") or {}
    (run_dir / "index.html").write_text(
        f"""<!doctype html><html><head><meta charset="utf-8">
<title>W18C base_layout_vlm_readback {esc(run_id)}</title>
<style>body{{font-family:sans-serif;margin:1.5em}}
table{{border-collapse:collapse;margin:0.5em 0}}
td,th{{border:1px solid #ccc;padding:4px 8px;vertical-align:top}}
.pass{{color:#080}} .fail{{color:#b00}}
pre{{white-space:pre-wrap;font-size:0.85em;max-width:84ch}}
section{{margin:1.5em 0}}</style></head><body>
<h1>W18C — base_layout_vlm_readback {esc(run_id)}</h1>
<p>stage: <b>{esc(stage_status)}</b>
| run_status: <b>{esc(run_status)}</b>
| exit_code: {esc(exit_code)}
| model: <b>{esc(model_used)}</b>
| vlm_api_call_count: <b>{esc(vlm_api_call_count)}</b>
| image_api_call_count: <b>0</b>
| derived_from(W18B): {esc(prev_run_dir.name)}</p>

<section><h2>structural_relationship_summary</h2>
<pre>{esc(json.dumps(summary, ensure_ascii=False, indent=2))}</pre></section>

<section><h2>read_markers</h2>
<table><tr><th>marker</th><th>visible</th><th>confidence</th>
<th>observed_label_or_area</th><th>rect</th><th>cell</th><th>notes</th></tr>
{read_rows}</table></section>

<section><h2>missing_or_ambiguous_markers</h2>
<table><tr><th>marker</th><th>reason</th></tr>{missing_rows}</table></section>

<section><h2>base_unit_layout</h2>
<table><tr><th>marker</th><th>unit_id</th><th>observed_area_label</th>
<th>10x10 rect</th><th>adjacency_notes</th></tr>{unit_rows}</table></section>

<section><h2>unexpected_transient_markers (diagnostic only)</h2>
<table><tr><th>marker</th><th>notes</th></tr>{unexpected_rows}</table></section>

<section><h2>readback_conflicts (VLM-reported, diagnostic only)</h2>
<table><tr><th>marker</th><th>expected</th><th>observed</th><th>note</th></tr>
{conflict_rows}</table></section>

<section><h2>Invariants</h2>
<table><tr><th>invariant</th><th>status</th><th>detail</th></tr>
{inv_rows}</table></section>

<details><summary>raw run_meta.json</summary>
<pre>{esc(json.dumps(run_meta, ensure_ascii=False, indent=2))}</pre></details>
<details><summary>raw vlm_readback.json</summary>
<pre>{esc(json.dumps(vlm_output, ensure_ascii=False, indent=2))[:200000]}</pre></details>
</body></html>"""
    )
    run_meta["outputs"].append("index.html")

    (run_dir / "run_meta.json").write_text(
        json.dumps(run_meta, ensure_ascii=False, indent=2)
    )
    _maybe_print_imports(args)
    return exit_code


if __name__ == "__main__":
    sys.exit(main())
