"""experiment_floor_plan_grid_layout_slice — W16.

Coarse 10x10 grid-layout composer + deterministic SVG/HTML renderer for
the previously-rejected T2I floor-plan slot. Consumes a prior W15e
topology slice run and asks GPT-5.5 for a structured grid layout, then
draws the result as a deterministic SVG (no image API).

CLI:
  --derive-grid-layout-from <W15e_run_dir>     (required)
  --target-fp-ids fp_l05_01                    (default, restricted)
  --generate                                    (default off — dry-run)
  --model gpt-5.5                               (default; no fallback)
  --output-root <path>                          (defaults to scripts_output)
  --diag-print-imports

This stage makes no image API call, no DB write, no production manifest
mutation, and no commit. GPT-5.5 routing is fail-closed: if
OPENAI_API_KEY is missing or the model name is not gpt-5.5, the caller
raises and the run records validation_failed.
"""
from __future__ import annotations

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

_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,
)

W16_STAGE = "w16_floor_plan_grid_layout_slice"
DEFAULT_W16_MODEL = "gpt-5.5"
W16_ALLOWED_TARGET_FP_IDS = frozenset({"fp_l05_01"})
GRID_COLS = 10
GRID_ROWS = 10

# W16d schema-level numeric budgets (deterministic invariants — not prompt
# strongarming). Every LLM model must satisfy the same contract; budget
# values are generic 10x10 coarse-grid policy with no scenario-specific
# meaning.
W16D_TOTAL_OCCUPIED_MIN_CELLS = 55
W16D_TOTAL_OCCUPIED_MAX_CELLS = 75
W16D_SCALE_HINT_BUDGET: Dict[str, Dict[str, int]] = {
    "compact_nook_or_wall_run": {"max_cells": 4, "shape_constraint": "one_axis_thin"},
    "compact_service_cell": {"max_cells": 4},
    "interior_threshold": {"max_cells": 4},
    "secondary_zone": {"min_cells": 8, "max_cells": 18},
    "primary_zone": {"min_cells": 25, "max_cells": 40},
    "exterior_landing": {"max_cells": 6},
}

# W16e unit_kind → allowed scale_hint whitelist. Per-spatial-unit topology
# overrides via `allow_primary_zone=true` or `allow_scale_hints=[...]`
# extend the whitelist for that unit only.
W16E_UNIT_KIND_ALLOWED_SCALES: Dict[str, Tuple[str, ...]] = {
    "living_zone": ("primary_zone",),
    "kitchen_zone": ("compact_nook_or_wall_run",),
    "service_room": ("compact_service_cell",),
    "entry_transition": ("interior_threshold", "exterior_landing"),
    "private_room": ("secondary_zone",),
    "plot_zone": ("secondary_zone",),
    "exterior_zone": ("exterior_landing",),
    "opening": ("interior_threshold",),
}

_DEFAULT_OUTPUT_ROOT = (
    _REPO_ROOT / "scripts_output" / "floor_plan_grid_layout_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=(
            "W16 floor_plan_grid_layout_slice — coarse 10x10 grid layout "
            "composer + deterministic SVG render. No image API call."
        )
    )
    p.add_argument(
        "--derive-grid-layout-from",
        required=False, default=None,
        help="Path to a prior W15e success run dir (containing "
             "source_topology_brief.json + floor_plan_prompt_candidate.json + "
             "run_meta.json). Required unless --reuse-grid-from is given.",
    )
    p.add_argument(
        "--reuse-grid-from",
        default=None,
        help="Path to a prior W16 run dir. When set, the LLM call is "
             "skipped and the existing grid_layout_plan.json is re-rendered "
             "(SVG + HTML + invariants) using the current code. Useful for "
             "patch verification without paying for another LLM call.",
    )
    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="Actual LLM call (GPT-5.5). Default off — placeholder dry-run.",
    )
    p.add_argument("--model", default=DEFAULT_W16_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)


# ─────────────────────────────────────────────────────────────────────────────
# W15e artifact loader
# ─────────────────────────────────────────────────────────────────────────────

def _load_w15e_artifacts(prev_run_dir: Path) -> dict:
    required = {
        "topology": "source_topology_brief.json",
        "candidate": "floor_plan_prompt_candidate.json",
        "run_meta": "run_meta.json",
    }
    out: Dict[str, Any] = {}
    missing: List[str] = []
    for key, fname in required.items():
        p = prev_run_dir / fname
        if not p.exists():
            missing.append(fname)
            continue
        out[key] = json.loads(p.read_text())
    out["_missing"] = missing
    out["_prev_run_id"] = prev_run_dir.name
    return out


def _resolve_targets(targets_arg: str) -> Tuple[Set[str], List[str]]:
    s = (targets_arg or "").strip()
    if not s:
        return set(), []
    requested = [tok.strip() for tok in s.split(",") if tok.strip()]
    valid: Set[str] = set()
    invalid: List[str] = []
    for tok in requested:
        if tok in W16_ALLOWED_TARGET_FP_IDS:
            valid.add(tok)
        else:
            invalid.append(tok)
    return valid, invalid


# ─────────────────────────────────────────────────────────────────────────────
# LLM call — GPT-5.5, fail-closed (no fallback)
# ─────────────────────────────────────────────────────────────────────────────

W16_SYSTEM_PROMPT = """\
You are a floor-plan layout composer.

Input: a JSON object containing
- `topology_brief`: the source-topology contract for a small interior
  floor plan, with `spatial_units[]` (each has `unit_id`, `unit_kind`,
  `is_enclosed_room`, etc.) and `relationships[]`
  (`from_unit`, `to_unit`, `relation_kind`).
- `candidate_floor_plan`: the prior numbered candidate elements
  (`candidate_numbered_elements[]` each with `number`, `category`,
  `position_hint`, `unit_id_pointer`).
- `target_fp_id`: the single fp_id you must produce a layout for.

Output: a single JSON object with a top-level key
`grid_layout_by_fp` containing exactly one entry keyed by
`target_fp_id`. The entry shape:

{
  "grid": {"cols": 10, "rows": 10},
  "outer_outline": [
      {"x": int, "y": int, "w": int, "h": int}, ...
  ],
  "units": [
    {
      "unit_id": "<must match topology spatial_unit.unit_id>",
      "unit_kind": "<copy from topology>",
      "enclosure_mode": "<full_wall_enclosed_room | open_zone_inside_plan | interior_threshold | exterior_open>",
      "rects": [{"x": int, "y": int, "w": int, "h": int}, ...],
      "open_to": ["<unit_id>", ...],
      "door_to": ["<unit_id>", ...],
      "scale_hint": "<compact_nook_or_wall_run | compact_service_cell | primary_zone | secondary_zone | exterior_landing>",
      "rationale_refs": ["<source_ref>", ...]
    }, ...
  ],
  "doorways": [
    {"from_unit": "<unit_id>", "to_unit": "<unit_id>",
     "edge_hint": "<north|south|east|west|shared_edge|threshold>"}, ...
  ],
  "windows": [
    {"unit_id": "<unit_id>",
     "edge_hint": "<north|south|east|west>",
     "marker_number": int_or_omitted}, ...
  ],
  "fixtures": [
    {"number": int, "unit_id": "<unit_id>",
     "cell": [int_x, int_y],
     "anchor_edge": "<north|south|east|west>"_or_omitted,
     "render_mode": "<area_marker|opening_marker|tiny_wall_icon|furniture_symbol|floor_marker|wall_marker>"}, ...
  ],
  "adjacency_notes": [string, ...],
  "layout_rationale": string
}

Hard layout rules:
- The grid is integer 10 columns x 10 rows. All x/y/w/h values are
  integers in [0, 10] with x+w <= 10 and y+h <= 10.
- Cell budget (numeric contract — strict, deterministic invariants
  enforce these limits, the prompt is NOT the only safeguard):
    - compact_nook_or_wall_run: total cells <= 4 AND every rect is
      one-axis-thin (w == 1 or h == 1). Use at most 2 rects to form a
      short L. Do NOT use multi-row/multi-col blocks like 4x3 — that
      is a room, not a nook.
    - compact_service_cell: total cells <= 4.
    - interior_threshold: total cells <= 4.
    - secondary_zone (private rooms ONLY): total cells in [8, 18].
      Private rooms larger than 18 cells are rejected regardless of
      how they are labelled. Service rooms MUST use
      compact_service_cell (max 4); they are NOT secondary_zone.
    - primary_zone (living/dining open core): total cells in [25, 40].
    - Total UNIQUE occupied cells across all units must be in
      [55, 75] (out of 100). Filling the whole 10x10 grid is rejected
      because it loses the compact apartment scale.
- unit_kind → scale_hint whitelist (each unit_kind has ONE allowed
  scale_hint; topology may override per-unit via
  `allow_primary_zone=true` or `allow_scale_hints=[...]`):
    - living_zone: primary_zone only
    - kitchen_zone: compact_nook_or_wall_run only
    - service_room: compact_service_cell only (NOT secondary_zone)
    - entry_transition: interior_threshold or exterior_landing only
    - private_room: secondary_zone only (allow_primary_zone override
      may upgrade to primary_zone)
- Two private rooms must each fit the secondary_zone budget; do not
  expand either to primary_zone to communicate importance.
- Unit rect rule: rects belonging to two different units MUST NOT have
  positive-area overlap. Rects WITHIN the same unit must not have
  positive-area overlap either. Shared edges (touching, gap == 0) are
  fine — overlap is not.
- Every fixture cell MUST sit inside one of its bound unit's rects.
  Do not place a fixture cell outside its unit's rect set.
- Every `unit_id` you emit MUST already exist in
  `topology_brief.spatial_units`. Do NOT invent new unit ids.
- Every numbered candidate element (`candidate_numbered_elements[*]`)
  MUST appear EXACTLY ONCE in `fixtures[]`. Use its `number` and bind
  `unit_id` to the same value as the candidate's `unit_id_pointer`.
- For two units linked by `open_connection` in the topology, set their
  `open_to[]` to include each other and have their `rects` SHARE A
  COMMON EDGE (touch on a row or column line). They must read as one
  continuous floor with no full wall between them.
- For two units linked by `door_between` in the topology, set their
  `door_to[]` to include each other and have their `rects` SHARE AT
  LEAST ONE EDGE CELL (touch). Add a doorway in `doorways[]` with the
  appropriate `edge_hint`. The receiving unit's
  `enclosure_mode` should be `full_wall_enclosed_room`.
- Open zones with `scale_hint=compact_nook_or_wall_run` MUST occupy a
  narrow wall-run outline (a single row or column of cells, or a
  short L-shape), not a square room.
- `interior_threshold` units MUST occupy a tiny outline (1-2 cells
  total) close to an exterior edge. They must not look like a room.
- Enclosed `unit_kind` values (private_room, service_room) are
  `full_wall_enclosed_room` and must have their rects fully enclosed
  (no shared edge with another unit except where their `door_to` peer
  sits, and that shared edge is the doorway).
- Use the 10x10 grid as COARSE PLANNING LANGUAGE only. Do not emit
  pixel-precise coordinates.
- Do not collapse multiple enclosed `private_room` units into one
  room. Distinct enclosed `private_room` units must occupy disjoint
  rects.
- Do not embed any scenario-specific proper noun, character name, or
  source-quoted color word in `layout_rationale`, `adjacency_notes`,
  or any other field. Use generic architectural language only.

Reply ONLY with the JSON object. No prose, no markdown fences.
"""


def _generate_w16_via_llm(llm_input: dict, *, model: str = DEFAULT_W16_MODEL,
                          retry_once: bool = False) -> dict:
    """Call GPT-5.5 via litellm. Fail-closed: refuses anything other than
    the configured GPT-5.5 model name and refuses when OPENAI_API_KEY is
    absent. No gemini fallback. Default `retry_once=False` so a transient
    failure does not silently incur a second paid attempt; callers must
    opt into a retry explicitly."""
    if not os.environ.get("OPENAI_API_KEY"):
        raise RuntimeError("missing OPENAI_API_KEY env var")
    if (model or "").strip().lower() != DEFAULT_W16_MODEL:
        raise RuntimeError(
            f"w16 routing refuses non-gpt-5.5 model: got '{model}', "
            f"required '{DEFAULT_W16_MODEL}' (no fallback allowed)"
        )

    import litellm  # lazy import — dry-run path must not import it

    user_prompt = json.dumps(llm_input, ensure_ascii=False)
    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": W16_SYSTEM_PROMPT},
                    {"role": "user", "content": user_prompt},
                ],
                response_format={"type": "json_object"},
            )
            raw_text = resp.choices[0].message.content
            decoder = json.JSONDecoder()
            stripped = raw_text.lstrip()
            parsed, _ = decoder.raw_decode(stripped)
            return parsed
        except Exception as exc:  # noqa: BLE001
            last_exc = exc
            continue
    raise RuntimeError(f"w16_llm_failed_after_retry: {last_exc!s}"[:400])


def _build_w16_llm_input(*, topology_brief: dict, candidate: dict,
                        target_fp_id: str) -> dict:
    by_fp = (topology_brief or {}).get("source_topology_by_fp") or {}
    fp_topo = by_fp.get(target_fp_id) or {}
    candidates = (candidate or {}).get("candidate_floor_plans") or {}
    fp_cand = candidates.get(target_fp_id) or {}
    return {
        "target_fp_id": target_fp_id,
        "topology_brief": {
            "fp_id": target_fp_id,
            "spatial_units": fp_topo.get("spatial_units") or [],
            "relationships": fp_topo.get("relationships") or [],
            "room_count_assessment": fp_topo.get("room_count_assessment") or {},
        },
        "candidate_floor_plan": {
            "fp_id": target_fp_id,
            "candidate_numbered_elements": fp_cand.get(
                "candidate_numbered_elements"
            ) or [],
            "candidate_key_elements": fp_cand.get("candidate_key_elements") or [],
        },
    }


# ─────────────────────────────────────────────────────────────────────────────
# Grid geometry helpers
# ─────────────────────────────────────────────────────────────────────────────

def _iter_rects(unit: dict) -> List[dict]:
    rects = unit.get("rects") if isinstance(unit, dict) else None
    return [r for r in (rects or []) if isinstance(r, dict)]


def _rect_in_bounds(r: dict, cols: int, rows: int) -> bool:
    try:
        x, y, w, h = int(r["x"]), int(r["y"]), int(r["w"]), int(r["h"])
    except (KeyError, TypeError, ValueError):
        return False
    if w <= 0 or h <= 0:
        return False
    return 0 <= x and 0 <= y and (x + w) <= cols and (y + h) <= rows


def _rects_share_edge(rects_a: List[dict], rects_b: List[dict]) -> bool:
    for a in rects_a:
        try:
            ax, ay, aw, ah = int(a["x"]), int(a["y"]), int(a["w"]), int(a["h"])
        except Exception:  # noqa: BLE001
            continue
        ar0, ac0, ar1, ac1 = ay, ax, ay + ah, ax + aw
        for b in rects_b:
            try:
                bx, by, bw, bh = int(b["x"]), int(b["y"]), int(b["w"]), int(b["h"])
            except Exception:  # noqa: BLE001
                continue
            br0, bc0, br1, bc1 = by, bx, by + bh, bx + bw
            # Vertical shared edge: a right == b left OR a left == b right;
            # row overlap > 0.
            if (ac1 == bc0 or bc1 == ac0):
                if min(ar1, br1) - max(ar0, br0) > 0:
                    return True
            # Horizontal shared edge: a bottom == b top OR a top == b bottom;
            # col overlap > 0.
            if (ar1 == br0 or br1 == ar0):
                if min(ac1, bc1) - max(ac0, bc0) > 0:
                    return True
    return False


def _rects_within_tolerance(rects_a: List[dict], rects_b: List[dict],
                            tol: int = 1) -> bool:
    """Door-jamb tolerance check: ONE axis must have positive overlap and
    the OTHER axis may have a gap of up to `tol` cells. A pure diagonal
    near-miss (gap on BOTH axes, overlap on neither) does NOT count — the
    intended use is the small architectural offset where a door jamb
    eats one cell between two adjacent rooms."""
    for a in rects_a:
        try:
            ax, ay, aw, ah = int(a["x"]), int(a["y"]), int(a["w"]), int(a["h"])
        except Exception:  # noqa: BLE001
            continue
        ar0, ac0, ar1, ac1 = ay, ax, ay + ah, ax + aw
        for b in rects_b:
            try:
                bx, by, bw, bh = int(b["x"]), int(b["y"]), int(b["w"]), int(b["h"])
            except Exception:  # noqa: BLE001
                continue
            br0, bc0, br1, bc1 = by, bx, by + bh, bx + bw
            col_overlap = min(ac1, bc1) - max(ac0, bc0)
            row_overlap = min(ar1, br1) - max(ar0, br0)
            col_gap = max(0, max(ac0, bc0) - min(ac1, bc1))
            row_gap = max(0, max(ar0, br0) - min(ar1, br1))
            # One axis overlaps positively while the other axis is within
            # the tolerance gap. Touching (gap == 0) is also accepted.
            if col_overlap > 0 and row_gap <= tol:
                return True
            if row_overlap > 0 and col_gap <= tol:
                return True
    return False


def _topology_open_pairs(fp_topo: dict) -> List[Tuple[str, str]]:
    return [
        (r.get("from_unit"), r.get("to_unit"))
        for r in (fp_topo or {}).get("relationships") or []
        if isinstance(r, dict) and r.get("relation_kind") == "open_connection"
    ]


def _topology_door_pairs(fp_topo: dict) -> List[Tuple[str, str]]:
    return [
        (r.get("from_unit"), r.get("to_unit"))
        for r in (fp_topo or {}).get("relationships") or []
        if isinstance(r, dict) and r.get("relation_kind") == "door_between"
    ]


# ─────────────────────────────────────────────────────────────────────────────
# Compatibility report — 9 invariants
# ─────────────────────────────────────────────────────────────────────────────

def _build_w16_compatibility_report(
    *, grid_layout: Dict[str, Any],
    topology_brief: Dict[str, Any],
    candidate: Dict[str, Any],
    target_fp_ids: Set[str],
    production_diff_empty: bool,
    db_write_count: int,
    image_import_seen: bool,
    image_api_call_count: int,
    svg_emitted: bool,
    model_used: Optional[str],
    stage_status: str,
    missing_inputs: List[str],
    prev_run_id: str,
    svg_paths_by_fp: Optional[Dict[str, Any]] = None,
    expected_model_id: str = DEFAULT_W16_MODEL,
    model_invariant_key: str = "model_is_gpt_5_5_when_generated",
) -> dict:
    inv: Dict[str, Dict[str, Any]] = {}

    by_fp = (grid_layout or {}).get("grid_layout_by_fp") or {}

    # 1. inputs present.
    inv["inputs_present"] = {
        "pass": (
            not missing_inputs
            and bool(topology_brief)
            and bool(candidate)
            and bool(grid_layout)
            and bool(by_fp)
        ),
        "detail": {
            "missing_inputs": list(missing_inputs),
            "topology_brief_present": bool(topology_brief),
            "candidate_present": bool(candidate),
            "grid_layout_present": bool(grid_layout),
            "grid_layout_fp_count": len(by_fp),
            "stage_status": stage_status,
            "prev_run_id": prev_run_id,
        },
    }

    # 2. target_fp_only_fp_l05_01: wave-1 restriction. Always reports what was
    # received; passes only when the received set is a subset of the allowed
    # singleton (fp_l05_01).
    received_sorted = sorted(target_fp_ids or set())
    inv["target_fp_only_fp_l05_01"] = {
        "pass": bool(target_fp_ids) and set(target_fp_ids).issubset(
            W16_ALLOWED_TARGET_FP_IDS
        ),
        "detail": {
            "received_target_fp_ids": received_sorted,
            "allowed": sorted(W16_ALLOWED_TARGET_FP_IDS),
        },
    }

    # 3. model_is_<expected>_when_generated. Dry-run auto-PASSes. Reuse
    # path carries the prior run's model value so the invariant still
    # reflects what produced the layout. The invariant key + expected id
    # are parameterized so a sibling wave (e.g. Gemini comparison) can
    # rename the invariant without weakening the original gpt-5.5
    # fail-closed contract elsewhere.
    if stage_status == "dry_run":
        model_pass = True
        model_detail = {"skip_reason": "dry_run"}
    else:
        model_pass = (model_used or "").strip().lower() == expected_model_id.lower()
        model_detail = {
            "model_used": model_used,
            "required": expected_model_id,
            "stage_status": stage_status,
        }
    inv[model_invariant_key] = {
        "pass": model_pass,
        "detail": model_detail,
    }

    # LLM-output-derived invariants are skipped (auto-PASS with note) in
    # dry-run mode. They become active once a real layout exists — either
    # freshly generated this run, or carried over from a `--reuse-grid-from`
    # path that re-renders an existing W16 layout.
    llm_output_active = stage_status in ("generated", "reused")
    dry_run_skip_detail = {"skip_reason": "dry_run"}

    # 4. grid_is_10x10_and_all_rects_in_bounds.
    if not llm_output_active:
        inv["grid_is_10x10_and_all_rects_in_bounds"] = {
            "pass": True, "detail": dict(dry_run_skip_detail),
        }
    else:
        grid_failures: List[dict] = []
        for fp_id, fp_layout in by_fp.items():
            if not isinstance(fp_layout, dict):
                grid_failures.append({"fp_id": fp_id, "reason": "not_a_dict"})
                continue
            grid = fp_layout.get("grid") or {}
            if grid.get("cols") != GRID_COLS or grid.get("rows") != GRID_ROWS:
                grid_failures.append({
                    "fp_id": fp_id, "reason": "grid_not_10x10",
                    "grid": grid,
                })
            for rect in fp_layout.get("outer_outline") or []:
                if not _rect_in_bounds(rect, GRID_COLS, GRID_ROWS):
                    grid_failures.append({
                        "fp_id": fp_id, "scope": "outer_outline",
                        "rect": rect,
                    })
            for unit in fp_layout.get("units") or []:
                uid = (unit or {}).get("unit_id") or ""
                for rect in _iter_rects(unit):
                    if not _rect_in_bounds(rect, GRID_COLS, GRID_ROWS):
                        grid_failures.append({
                            "fp_id": fp_id, "unit_id": uid,
                            "rect": rect,
                        })
        inv["grid_is_10x10_and_all_rects_in_bounds"] = {
            "pass": not grid_failures,
            "detail": {
                "failures": grid_failures[:30],
                "failure_count": len(grid_failures),
                "grid_cols": GRID_COLS,
                "grid_rows": GRID_ROWS,
            },
        }

    # 5. unit_ids_exist_in_w15_topology.
    topo_by_fp = (topology_brief or {}).get("source_topology_by_fp") or {}
    if not llm_output_active:
        inv["unit_ids_exist_in_w15_topology"] = {
            "pass": True, "detail": dict(dry_run_skip_detail),
        }
        # Skip ahead past the per-fp loop by short-circuiting via flag.
        _skip_unit_id_check = True
    else:
        _skip_unit_id_check = False
    unit_id_failures: List[dict] = []
    for fp_id, fp_layout in by_fp.items():
        if _skip_unit_id_check:
            break
        if not isinstance(fp_layout, dict):
            continue
        fp_topo = topo_by_fp.get(fp_id) or {}
        known_units: Set[str] = {
            (u or {}).get("unit_id") or ""
            for u in fp_topo.get("spatial_units") or []
            if isinstance(u, dict)
        }
        known_units.discard("")
        for unit in fp_layout.get("units") or []:
            uid = (unit or {}).get("unit_id") or ""
            if not uid or uid not in known_units:
                unit_id_failures.append({
                    "fp_id": fp_id, "unit_id": uid,
                    "known_units": sorted(known_units),
                })
        # Same check for unit_ids referenced from doorways/windows/fixtures.
        for door in fp_layout.get("doorways") or []:
            for slot in ("from_unit", "to_unit"):
                uid = (door or {}).get(slot) or ""
                if uid and uid not in known_units:
                    unit_id_failures.append({
                        "fp_id": fp_id, "doorway_slot": slot,
                        "unit_id": uid,
                    })
        for win in fp_layout.get("windows") or []:
            uid = (win or {}).get("unit_id") or ""
            if uid and uid not in known_units:
                unit_id_failures.append({
                    "fp_id": fp_id, "window_unit_id": uid,
                })
        for fx in fp_layout.get("fixtures") or []:
            uid = (fx or {}).get("unit_id") or ""
            if uid and uid not in known_units:
                unit_id_failures.append({
                    "fp_id": fp_id, "fixture_unit_id": uid,
                })
    if not _skip_unit_id_check:
        inv["unit_ids_exist_in_w15_topology"] = {
            "pass": not unit_id_failures,
            "detail": {
                "failures": unit_id_failures[:30],
                "failure_count": len(unit_id_failures),
            },
        }

    # 6. fixtures_reference_candidate_numbers_and_units.
    candidates = (candidate or {}).get("candidate_floor_plans") or {}
    if not llm_output_active:
        inv["fixtures_reference_candidate_numbers_and_units"] = {
            "pass": True, "detail": dict(dry_run_skip_detail),
        }
        _skip_fixture_check = True
    else:
        _skip_fixture_check = False
    fixture_failures: List[dict] = []
    for fp_id, fp_layout in by_fp.items():
        if _skip_fixture_check:
            break
        if not isinstance(fp_layout, dict):
            continue
        fp_cand = candidates.get(fp_id) or {}
        candidate_by_num: Dict[int, dict] = {}
        for entry in fp_cand.get("candidate_numbered_elements") or []:
            if isinstance(entry, dict) and isinstance(entry.get("number"), int):
                candidate_by_num[entry["number"]] = entry
        seen_numbers: Set[int] = set()
        for fx in fp_layout.get("fixtures") or []:
            if not isinstance(fx, dict):
                fixture_failures.append({"fp_id": fp_id, "reason": "not_a_dict"})
                continue
            n = fx.get("number")
            if not isinstance(n, int) or n not in candidate_by_num:
                fixture_failures.append({
                    "fp_id": fp_id, "reason": "unknown_number", "number": n,
                })
                continue
            seen_numbers.add(n)
            expected_unit = candidate_by_num[n].get("unit_id_pointer") or ""
            if expected_unit and fx.get("unit_id") != expected_unit:
                fixture_failures.append({
                    "fp_id": fp_id, "reason": "unit_id_mismatch",
                    "number": n,
                    "fixture_unit_id": fx.get("unit_id"),
                    "candidate_unit_id_pointer": expected_unit,
                })
        missing_numbers = sorted(set(candidate_by_num.keys()) - seen_numbers)
        if missing_numbers:
            fixture_failures.append({
                "fp_id": fp_id, "reason": "candidate_numbers_missing_in_fixtures",
                "missing": missing_numbers,
            })
    if not _skip_fixture_check:
        inv["fixtures_reference_candidate_numbers_and_units"] = {
            "pass": not fixture_failures,
            "detail": {
                "failures": fixture_failures[:30],
                "failure_count": len(fixture_failures),
            },
        }

    # 7. declared_open_or_door_adjacency_has_touching_or_nearby_grid_relation.
    if not llm_output_active:
        inv["declared_open_or_door_adjacency_has_touching_or_nearby_grid_relation"] = {
            "pass": True, "detail": dict(dry_run_skip_detail),
        }
        _skip_adj_check = True
    else:
        _skip_adj_check = False
    adj_failures: List[dict] = []
    for fp_id, fp_layout in by_fp.items():
        if _skip_adj_check:
            break
        if not isinstance(fp_layout, dict):
            continue
        fp_topo = topo_by_fp.get(fp_id) or {}
        rects_by_unit: Dict[str, List[dict]] = {}
        for unit in fp_layout.get("units") or []:
            uid = (unit or {}).get("unit_id") or ""
            if not uid:
                continue
            rects_by_unit[uid] = _iter_rects(unit)
        def _check_pair(a: str, b: str, kind: str, allow_tolerance: bool) -> None:
            ra = rects_by_unit.get(a) or []
            rb = rects_by_unit.get(b) or []
            if not ra or not rb:
                adj_failures.append({
                    "fp_id": fp_id, "kind": kind,
                    "from_unit": a, "to_unit": b,
                    "reason": "unit_rect_missing",
                })
                return
            if _rects_share_edge(ra, rb):
                return
            if allow_tolerance and _rects_within_tolerance(ra, rb, tol=1):
                return
            adj_failures.append({
                "fp_id": fp_id, "kind": kind,
                "from_unit": a, "to_unit": b,
                "reason": "no_touching_or_nearby",
            })
        # Topology open_connection pairs must share a real edge (no
        # tolerance — continuous floor must touch).
        for a, b in _topology_open_pairs(fp_topo):
            if not a or not b:
                continue
            _check_pair(a, b, kind="open_connection", allow_tolerance=False)
        # Topology door_between pairs must touch within 1-cell tolerance.
        for a, b in _topology_door_pairs(fp_topo):
            if not a or not b:
                continue
            _check_pair(a, b, kind="door_between", allow_tolerance=True)
    if not _skip_adj_check:
        inv["declared_open_or_door_adjacency_has_touching_or_nearby_grid_relation"] = {
            "pass": not adj_failures,
            "detail": {
                "failures": adj_failures[:30],
                "failure_count": len(adj_failures),
            },
        }

    # 8. svg_render_emitted_and_no_image_api_call. Image-API-call check is
    # always live; SVG presence is required only when the LLM produced a
    # layout (i.e. stage_status == "generated"). Dry-run skips the SVG
    # presence half but still asserts image_api_call_count == 0.
    if not llm_output_active:
        inv["svg_render_emitted_and_no_image_api_call"] = {
            "pass": image_api_call_count == 0,
            "detail": {
                "svg_emitted": bool(svg_emitted),
                "image_api_call_count": image_api_call_count,
                "skip_reason": "dry_run_no_svg_required",
            },
        }
    else:
        inv["svg_render_emitted_and_no_image_api_call"] = {
            "pass": bool(svg_emitted) and image_api_call_count == 0,
            "detail": {
                "svg_emitted": bool(svg_emitted),
                "image_api_call_count": image_api_call_count,
            },
        }

    # W16d budget invariants (LLM-output-active only). Helpers operate on
    # plain dicts — no semantic interpretation of text fields. Shape
    # constraint for compact_nook_or_wall_run uses pure geometry.
    def _rect_cells(r: dict) -> Set[Tuple[int, int]]:
        try:
            x = int(r["x"]); y = int(r["y"])
            w = int(r["w"]); h = int(r["h"])
        except (KeyError, TypeError, ValueError):
            return set()
        if w <= 0 or h <= 0:
            return set()
        cells: Set[Tuple[int, int]] = set()
        for cx in range(x, x + w):
            for cy in range(y, y + h):
                cells.add((cx, cy))
        return cells

    def _rect_pos_overlap(a: dict, b: dict) -> bool:
        try:
            ax, ay, aw, ah = int(a["x"]), int(a["y"]), int(a["w"]), int(a["h"])
            bx, by, bw, bh = int(b["x"]), int(b["y"]), int(b["w"]), int(b["h"])
        except Exception:  # noqa: BLE001
            return False
        col_overlap = min(ax + aw, bx + bw) - max(ax, bx)
        row_overlap = min(ay + ah, by + bh) - max(ay, by)
        return col_overlap > 0 and row_overlap > 0

    def _is_one_axis_thin(rects: List[dict]) -> bool:
        """compact_nook_or_wall_run shape constraint: max 2 rects, each
        rect is one-axis-thin (w == 1 or h == 1)."""
        if not rects or len(rects) > 2:
            return False
        for r in rects:
            try:
                w = int(r["w"]); h = int(r["h"])
            except (KeyError, TypeError, ValueError):
                return False
            if w != 1 and h != 1:
                return False
        return True

    if not llm_output_active:
        for inv_key in (
            "scale_hint_cell_budget",
            "total_occupied_cells_in_bound",
            "private_room_primary_zone_disallowed",
            "unit_kind_scale_hint_compatibility",
            "unit_rects_do_not_overlap",
            "fixture_cells_inside_declared_unit_rects",
        ):
            inv[inv_key] = {"pass": True, "detail": dict(dry_run_skip_detail)}
    else:
        # scale_hint_cell_budget
        budget_failures: List[dict] = []
        for fp_id, fp_layout in by_fp.items():
            if not isinstance(fp_layout, dict):
                continue
            for unit in fp_layout.get("units") or []:
                if not isinstance(unit, dict):
                    continue
                uid = unit.get("unit_id") or ""
                scale = unit.get("scale_hint") or ""
                rects = _iter_rects(unit)
                cell_count = sum(len(_rect_cells(r)) for r in rects)
                budget = W16D_SCALE_HINT_BUDGET.get(scale)
                if budget is None:
                    budget_failures.append({
                        "fp_id": fp_id, "unit_id": uid,
                        "reason": "unknown_scale_hint", "scale_hint": scale,
                    })
                    continue
                if "max_cells" in budget and cell_count > budget["max_cells"]:
                    budget_failures.append({
                        "fp_id": fp_id, "unit_id": uid,
                        "reason": "exceeds_max_cells",
                        "scale_hint": scale,
                        "cell_count": cell_count,
                        "max_cells": budget["max_cells"],
                    })
                if "min_cells" in budget and cell_count < budget["min_cells"]:
                    budget_failures.append({
                        "fp_id": fp_id, "unit_id": uid,
                        "reason": "below_min_cells",
                        "scale_hint": scale,
                        "cell_count": cell_count,
                        "min_cells": budget["min_cells"],
                    })
                if budget.get("shape_constraint") == "one_axis_thin":
                    if not _is_one_axis_thin(rects):
                        budget_failures.append({
                            "fp_id": fp_id, "unit_id": uid,
                            "reason": "shape_not_one_axis_thin",
                            "scale_hint": scale,
                            "rects": rects,
                        })
        inv["scale_hint_cell_budget"] = {
            "pass": not budget_failures,
            "detail": {
                "failures": budget_failures[:30],
                "failure_count": len(budget_failures),
                "budget_table": W16D_SCALE_HINT_BUDGET,
            },
        }

        # total_occupied_cells_in_bound (unique cells; overlap dedup)
        total_failures: List[dict] = []
        per_fp_totals: Dict[str, int] = {}
        for fp_id, fp_layout in by_fp.items():
            if not isinstance(fp_layout, dict):
                continue
            unique_cells: Set[Tuple[int, int]] = set()
            for unit in fp_layout.get("units") or []:
                for r in _iter_rects(unit):
                    unique_cells.update(_rect_cells(r))
            per_fp_totals[fp_id] = len(unique_cells)
            if not (W16D_TOTAL_OCCUPIED_MIN_CELLS
                    <= len(unique_cells)
                    <= W16D_TOTAL_OCCUPIED_MAX_CELLS):
                total_failures.append({
                    "fp_id": fp_id,
                    "unique_cells": len(unique_cells),
                    "min": W16D_TOTAL_OCCUPIED_MIN_CELLS,
                    "max": W16D_TOTAL_OCCUPIED_MAX_CELLS,
                })
        # Surface per-fp unique count directly so callers/tests can verify
        # the dedup behaviour (rect overlap must NOT inflate the total).
        inv["total_occupied_cells_in_bound"] = {
            "pass": not total_failures,
            "detail": {
                "failures": total_failures,
                "unique_cells": (
                    sorted(per_fp_totals.values())[-1]
                    if per_fp_totals else 0
                ),
                "per_fp_unique_cells": per_fp_totals,
                "min": W16D_TOTAL_OCCUPIED_MIN_CELLS,
                "max": W16D_TOTAL_OCCUPIED_MAX_CELLS,
            },
        }

        # private_room_primary_zone_disallowed
        private_primary_failures: List[dict] = []
        for fp_id, fp_layout in by_fp.items():
            if not isinstance(fp_layout, dict):
                continue
            fp_topo = topo_by_fp.get(fp_id) or {}
            allow_overrides: Set[str] = set()
            for su in fp_topo.get("spatial_units") or []:
                if (
                    isinstance(su, dict)
                    and su.get("allow_primary_zone") is True
                    and su.get("unit_id")
                ):
                    allow_overrides.add(su["unit_id"])
            for unit in fp_layout.get("units") or []:
                if not isinstance(unit, dict):
                    continue
                uid = unit.get("unit_id") or ""
                kind = unit.get("unit_kind") or ""
                scale = unit.get("scale_hint") or ""
                if (
                    kind == "private_room"
                    and scale == "primary_zone"
                    and uid not in allow_overrides
                ):
                    private_primary_failures.append({
                        "fp_id": fp_id, "unit_id": uid,
                        "unit_kind": kind, "scale_hint": scale,
                    })
        inv["private_room_primary_zone_disallowed"] = {
            "pass": not private_primary_failures,
            "detail": {
                "failures": private_primary_failures[:30],
                "failure_count": len(private_primary_failures),
            },
        }

        # unit_kind_scale_hint_compatibility — strict whitelist per kind,
        # with per-spatial-unit overrides honoured.
        compat_failures: List[dict] = []
        for fp_id, fp_layout in by_fp.items():
            if not isinstance(fp_layout, dict):
                continue
            fp_topo = topo_by_fp.get(fp_id) or {}
            unit_overrides: Dict[str, Set[str]] = {}
            for su in fp_topo.get("spatial_units") or []:
                if not isinstance(su, dict):
                    continue
                uid = su.get("unit_id") or ""
                if not uid:
                    continue
                allowed: Set[str] = set()
                if su.get("allow_primary_zone") is True:
                    allowed.add("primary_zone")
                for h in su.get("allow_scale_hints") or []:
                    if isinstance(h, str):
                        allowed.add(h)
                if allowed:
                    unit_overrides[uid] = allowed
            for unit in fp_layout.get("units") or []:
                if not isinstance(unit, dict):
                    continue
                uid = unit.get("unit_id") or ""
                kind = unit.get("unit_kind") or ""
                scale = unit.get("scale_hint") or ""
                base_allowed = W16E_UNIT_KIND_ALLOWED_SCALES.get(kind)
                if base_allowed is None:
                    compat_failures.append({
                        "fp_id": fp_id, "unit_id": uid,
                        "reason": "unknown_unit_kind",
                        "unit_kind": kind,
                    })
                    continue
                effective_allowed = set(base_allowed) | unit_overrides.get(uid, set())
                if scale not in effective_allowed:
                    compat_failures.append({
                        "fp_id": fp_id, "unit_id": uid,
                        "reason": "scale_hint_not_allowed_for_unit_kind",
                        "unit_kind": kind,
                        "scale_hint": scale,
                        "allowed": sorted(effective_allowed),
                    })
        inv["unit_kind_scale_hint_compatibility"] = {
            "pass": not compat_failures,
            "detail": {
                "failures": compat_failures[:30],
                "failure_count": len(compat_failures),
                "whitelist": {k: list(v) for k, v in W16E_UNIT_KIND_ALLOWED_SCALES.items()},
            },
        }

        # unit_rects_do_not_overlap (intra- and inter-unit)
        overlap_failures: List[dict] = []
        for fp_id, fp_layout in by_fp.items():
            if not isinstance(fp_layout, dict):
                continue
            unit_rects: List[Tuple[str, dict]] = []
            for unit in fp_layout.get("units") or []:
                if not isinstance(unit, dict):
                    continue
                uid = unit.get("unit_id") or ""
                for r in _iter_rects(unit):
                    unit_rects.append((uid, r))
            for i in range(len(unit_rects)):
                for j in range(i + 1, len(unit_rects)):
                    uid_a, ra = unit_rects[i]
                    uid_b, rb = unit_rects[j]
                    if _rect_pos_overlap(ra, rb):
                        overlap_failures.append({
                            "fp_id": fp_id,
                            "unit_a": uid_a, "rect_a": ra,
                            "unit_b": uid_b, "rect_b": rb,
                            "kind": (
                                "intra_unit" if uid_a == uid_b else "inter_unit"
                            ),
                        })
        inv["unit_rects_do_not_overlap"] = {
            "pass": not overlap_failures,
            "detail": {
                "failures": overlap_failures[:30],
                "failure_count": len(overlap_failures),
            },
        }

        # fixture_cells_inside_declared_unit_rects
        containment_failures: List[dict] = []
        for fp_id, fp_layout in by_fp.items():
            if not isinstance(fp_layout, dict):
                continue
            cells_by_unit: Dict[str, Set[Tuple[int, int]]] = {}
            for unit in fp_layout.get("units") or []:
                if not isinstance(unit, dict):
                    continue
                uid = unit.get("unit_id") or ""
                acc: Set[Tuple[int, int]] = set()
                for r in _iter_rects(unit):
                    acc.update(_rect_cells(r))
                cells_by_unit[uid] = acc
            for fx in fp_layout.get("fixtures") or []:
                if not isinstance(fx, dict):
                    continue
                uid = fx.get("unit_id") or ""
                cell = fx.get("cell") or []
                if (
                    not isinstance(cell, list)
                    or len(cell) != 2
                    or not all(isinstance(c, int) for c in cell)
                ):
                    containment_failures.append({
                        "fp_id": fp_id, "fixture_number": fx.get("number"),
                        "reason": "cell_malformed", "cell": cell,
                    })
                    continue
                target_cells = cells_by_unit.get(uid) or set()
                if (cell[0], cell[1]) not in target_cells:
                    containment_failures.append({
                        "fp_id": fp_id, "fixture_number": fx.get("number"),
                        "unit_id": uid, "cell": cell,
                        "reason": "cell_outside_unit_rects",
                    })
        inv["fixture_cells_inside_declared_unit_rects"] = {
            "pass": not containment_failures,
            "detail": {
                "failures": containment_failures[:30],
                "failure_count": len(containment_failures),
            },
        }

    # svg_is_well_formed_xml. Active only when at least one SVG was
    # emitted; checks each path with ElementTree.parse to confirm the
    # file is XML well-formed (catches things like raw `<` mid-text from
    # earlier `>N<` glyph wrapping).
    import xml.etree.ElementTree as _ET
    paths_by_fp = svg_paths_by_fp or {}
    well_formed_failures: List[dict] = []
    if not paths_by_fp:
        # No SVG emitted (dry-run or LLM failure). Skip with note.
        inv["svg_is_well_formed_xml"] = {
            "pass": True,
            "detail": {
                "svg_count": 0,
                "skip_reason": "no_svg_emitted",
            },
        }
    else:
        for fp_id, p in paths_by_fp.items():
            try:
                _ET.parse(str(p))
            except (_ET.ParseError, OSError) as exc:
                well_formed_failures.append({
                    "fp_id": fp_id,
                    "svg_path": str(p),
                    "error": str(exc)[:240],
                })
        inv["svg_is_well_formed_xml"] = {
            "pass": not well_formed_failures,
            "detail": {
                "svg_count": len(paths_by_fp),
                "failures": well_formed_failures[:10],
                "failure_count": len(well_formed_failures),
            },
        }

    # 10. production_diff_zero_db_write_zero_image_api_call_zero.
    inv["production_diff_zero_db_write_zero_image_api_call_zero"] = {
        "pass": (
            production_diff_empty
            and db_write_count == 0
            and not image_import_seen
            and image_api_call_count == 0
        ),
        "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,
        },
    }

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


# ─────────────────────────────────────────────────────────────────────────────
# Deterministic SVG render
# ─────────────────────────────────────────────────────────────────────────────

_CELL = 64
_PAD = 24
_UNIT_STROKE = {
    "full_wall_enclosed_room": ("#222", 3),
    "interior_threshold": ("#888", 1.5),
    "exterior_open": ("#aaa", 1.5),
}
_UNIT_FILL_BY_KIND = {
    "living_zone": "#fff7d8",
    "kitchen_zone": "#d8f5d8",
    "private_room": "#d8e7f5",
    "service_room": "#d8eaf5",
    "entry_transition": "#f5ecd8",
    "plot_zone": "#f5d8e7",
    "opening": "#ffffff",
    "exterior_zone": "#f0f0f0",
    "uncertain": "#f0f0f0",
}
_FIXTURE_STYLE_BY_MODE = {
    "area_marker": {"r": 14, "fill": "#ffffff", "stroke": "#222",
                     "stroke_width": 1.5},
    "opening_marker": {"r": 10, "fill": "#ffffff", "stroke": "#222",
                       "stroke_width": 1.5},
    "tiny_wall_icon": {"r": 8, "fill": "#222", "stroke": "#222",
                       "stroke_width": 1.0, "text_fill": "#fff"},
    "furniture_symbol": {"r": 12, "fill": "#fff", "stroke": "#222",
                          "stroke_width": 1.5},
    "floor_marker": {"r": 10, "fill": "#ffe6d8", "stroke": "#c44",
                     "stroke_width": 1.5},
    "wall_marker": {"r": 10, "fill": "#ffe6d8", "stroke": "#c44",
                    "stroke_width": 1.5},
}


def _esc_xml(text: str) -> str:
    return (
        str(text)
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
    )


def _stable_jitter(cell: List[int], index_within_cell: int) -> Tuple[int, int]:
    """Tiny deterministic jitter when multiple markers fall in the same
    cell. No randomness; index_within_cell drives the offset."""
    if index_within_cell == 0:
        return 0, 0
    # 8-way spiral; small visual offsets so circles do not stack.
    offsets = [(0, 0), (10, -10), (-10, 10), (10, 10), (-10, -10),
               (14, 0), (-14, 0), (0, 14), (0, -14)]
    return offsets[index_within_cell % len(offsets)]


def _render_w16_svg(*, fp_id: str, fp_layout: dict, run_dir: Path) -> Path:
    svg_dir = run_dir / "svg"
    svg_dir.mkdir(parents=True, exist_ok=True)
    cols = (fp_layout.get("grid") or {}).get("cols") or GRID_COLS
    rows = (fp_layout.get("grid") or {}).get("rows") or GRID_ROWS
    width = _PAD * 2 + cols * _CELL
    height = _PAD * 2 + rows * _CELL + 48  # title row

    parts: List[str] = []
    parts.append('<?xml version="1.0" encoding="UTF-8"?>')
    parts.append(
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
        f'height="{height}" viewBox="0 0 {width} {height}" '
        f'font-family="sans-serif">'
    )
    parts.append(
        f'<rect width="{width}" height="{height}" fill="#ffffff"/>'
    )
    parts.append(
        f'<text x="{_PAD}" y="{_PAD + 6}" font-size="16" font-weight="bold">'
        f'{_esc_xml(fp_id)} — coarse grid layout (cols={cols}, rows={rows})'
        f'</text>'
    )

    # Grid background (light gridlines).
    for c in range(cols + 1):
        x = _PAD + c * _CELL
        parts.append(
            f'<line x1="{x}" y1="{_PAD + 24}" x2="{x}" '
            f'y2="{_PAD + 24 + rows * _CELL}" stroke="#eee" stroke-width="1"/>'
        )
    for r in range(rows + 1):
        y = _PAD + 24 + r * _CELL
        parts.append(
            f'<line x1="{_PAD}" y1="{y}" '
            f'x2="{_PAD + cols * _CELL}" y2="{y}" '
            f'stroke="#eee" stroke-width="1"/>'
        )

    def _cell_to_px(cx: int, cy: int) -> Tuple[int, int]:
        return _PAD + cx * _CELL, _PAD + 24 + cy * _CELL

    # Units.
    for unit in fp_layout.get("units") or []:
        uid = (unit or {}).get("unit_id") or ""
        kind = (unit or {}).get("unit_kind") or ""
        enclosure = (unit or {}).get("enclosure_mode") or ""
        scale_hint = (unit or {}).get("scale_hint") or ""
        fill = _UNIT_FILL_BY_KIND.get(kind, "#f7f7f7")
        stroke_color, stroke_w = _UNIT_STROKE.get(
            enclosure, ("#666", 1.5)
        )
        # Open zones use dashed perimeter (continuous floor cue).
        dash = ""
        if enclosure in ("open_zone_inside_plan", "interior_threshold"):
            dash = ' stroke-dasharray="6,4"'
        for rect in _iter_rects(unit):
            try:
                x = int(rect["x"]); y = int(rect["y"])
                w = int(rect["w"]); h = int(rect["h"])
            except (KeyError, TypeError, ValueError):
                continue
            px, py = _cell_to_px(x, y)
            parts.append(
                f'<rect x="{px}" y="{py}" width="{w * _CELL}" '
                f'height="{h * _CELL}" fill="{fill}" '
                f'stroke="{stroke_color}" stroke-width="{stroke_w}"'
                f'{dash} data-unit-id="{_esc_xml(uid)}" '
                f'data-enclosure="{_esc_xml(enclosure)}" '
                f'data-scale="{_esc_xml(scale_hint)}"/>'
            )
            # Unit id label inside the rect.
            label_x = px + (w * _CELL) // 2
            label_y = py + 18
            parts.append(
                f'<text x="{label_x}" y="{label_y}" font-size="12" '
                f'text-anchor="middle" fill="#333">'
                f'{_esc_xml(uid)}</text>'
            )

    # Doorways.
    for door in fp_layout.get("doorways") or []:
        a = (door or {}).get("from_unit") or ""
        b = (door or {}).get("to_unit") or ""
        edge = (door or {}).get("edge_hint") or ""
        if not a or not b:
            continue
        # Mark the doorway as a small inverted-V on the shared edge if we
        # can find the touching segment.
        rects_a = _collect_rects_for_unit(fp_layout, a)
        rects_b = _collect_rects_for_unit(fp_layout, b)
        seg = _find_shared_edge_segment(rects_a, rects_b)
        if seg is not None:
            mid_x, mid_y = seg
            parts.append(
                f'<circle cx="{mid_x}" cy="{mid_y}" r="6" '
                f'fill="#fff" stroke="#222" stroke-width="1.5" '
                f'data-doorway-from="{_esc_xml(a)}" '
                f'data-doorway-to="{_esc_xml(b)}" '
                f'data-doorway-edge="{_esc_xml(edge)}"/>'
            )

    # Windows (drawn as little blue bars on the named edge of the unit's
    # first rect — purely deterministic).
    for win in fp_layout.get("windows") or []:
        uid = (win or {}).get("unit_id") or ""
        edge = (win or {}).get("edge_hint") or ""
        rects = _collect_rects_for_unit(fp_layout, uid)
        if not rects:
            continue
        x = int(rects[0]["x"]); y = int(rects[0]["y"])
        w = int(rects[0]["w"]); h = int(rects[0]["h"])
        px, py = _cell_to_px(x, y)
        x1, y1, x2, y2 = px, py, px + w * _CELL, py + h * _CELL
        if edge == "north":
            seg = (x1 + 8, y1 + 2, x2 - 8, y1 + 2)
        elif edge == "south":
            seg = (x1 + 8, y2 - 2, x2 - 8, y2 - 2)
        elif edge == "west":
            seg = (x1 + 2, y1 + 8, x1 + 2, y2 - 8)
        elif edge == "east":
            seg = (x2 - 2, y1 + 8, x2 - 2, y2 - 8)
        else:
            continue
        parts.append(
            f'<line x1="{seg[0]}" y1="{seg[1]}" '
            f'x2="{seg[2]}" y2="{seg[3]}" stroke="#558" stroke-width="3" '
            f'data-window-unit="{_esc_xml(uid)}"/>'
        )

    # Fixtures (numbered markers). Stable per-cell jitter.
    seen_by_cell: Dict[Tuple[int, int], int] = {}
    for fx in fp_layout.get("fixtures") or []:
        if not isinstance(fx, dict):
            continue
        cell = fx.get("cell") or [0, 0]
        try:
            cx = int(cell[0]); cy = int(cell[1])
        except Exception:  # noqa: BLE001
            continue
        n = fx.get("number")
        mode = (fx.get("render_mode") or "area_marker")
        style = _FIXTURE_STYLE_BY_MODE.get(mode, _FIXTURE_STYLE_BY_MODE["area_marker"])
        key = (cx, cy)
        idx = seen_by_cell.get(key, 0)
        seen_by_cell[key] = idx + 1
        ox, oy = _stable_jitter(cell, idx)
        px = _PAD + cx * _CELL + _CELL // 2 + ox
        py = _PAD + 24 + cy * _CELL + _CELL // 2 + oy
        parts.append(
            f'<circle cx="{px}" cy="{py}" r="{style["r"]}" '
            f'fill="{style["fill"]}" stroke="{style["stroke"]}" '
            f'stroke-width="{style["stroke_width"]}" '
            f'data-fixture-number="{n}" '
            f'data-fixture-unit="{_esc_xml(fx.get("unit_id") or "")}" '
            f'data-render-mode="{_esc_xml(mode)}"/>'
        )
        text_fill = style.get("text_fill", "#222")
        # Marker text is the plain integer number; the surrounding circle
        # provides the visual marker shape. Earlier W16a used a `>N<` glyph
        # wrapping that broke XML parsing (raw `<` mid-text) — never reintroduce
        # that without escaping.
        parts.append(
            f'<text x="{px}" y="{py + 4}" font-size="11" '
            f'text-anchor="middle" fill="{text_fill}">'
            f'{n}'
            f'</text>'
        )

    parts.append('</svg>')
    svg_text = "\n".join(parts)
    out_path = svg_dir / f"{fp_id}.svg"
    out_path.write_text(svg_text)
    return out_path


def _collect_rects_for_unit(fp_layout: dict, unit_id: str) -> List[dict]:
    for unit in fp_layout.get("units") or []:
        if (unit or {}).get("unit_id") == unit_id:
            return _iter_rects(unit)
    return []


def _find_shared_edge_segment(rects_a: List[dict],
                              rects_b: List[dict]) -> Optional[Tuple[int, int]]:
    """Return the midpoint pixel of the touching segment between the two
    rect sets, or None if no shared edge."""
    for a in rects_a:
        try:
            ax, ay, aw, ah = int(a["x"]), int(a["y"]), int(a["w"]), int(a["h"])
        except Exception:  # noqa: BLE001
            continue
        for b in rects_b:
            try:
                bx, by, bw, bh = int(b["x"]), int(b["y"]), int(b["w"]), int(b["h"])
            except Exception:  # noqa: BLE001
                continue
            ar0, ac0, ar1, ac1 = ay, ax, ay + ah, ax + aw
            br0, bc0, br1, bc1 = by, bx, by + bh, bx + bw
            # Vertical shared edge.
            if ac1 == bc0:
                row_start = max(ar0, br0); row_end = min(ar1, br1)
                if row_end - row_start > 0:
                    mid_col = ac1
                    mid_row = (row_start + row_end) / 2.0
                    px = _PAD + int(mid_col * _CELL)
                    py = _PAD + 24 + int(mid_row * _CELL)
                    return (px, py)
            if bc1 == ac0:
                row_start = max(ar0, br0); row_end = min(ar1, br1)
                if row_end - row_start > 0:
                    mid_col = ac0
                    mid_row = (row_start + row_end) / 2.0
                    px = _PAD + int(mid_col * _CELL)
                    py = _PAD + 24 + int(mid_row * _CELL)
                    return (px, py)
            # Horizontal shared edge.
            if ar1 == br0:
                col_start = max(ac0, bc0); col_end = min(ac1, bc1)
                if col_end - col_start > 0:
                    mid_row = ar1
                    mid_col = (col_start + col_end) / 2.0
                    px = _PAD + int(mid_col * _CELL)
                    py = _PAD + 24 + int(mid_row * _CELL)
                    return (px, py)
            if br1 == ar0:
                col_start = max(ac0, bc0); col_end = min(ac1, bc1)
                if col_end - col_start > 0:
                    mid_row = ar0
                    mid_col = (col_start + col_end) / 2.0
                    px = _PAD + int(mid_col * _CELL)
                    py = _PAD + 24 + int(mid_row * _CELL)
                    return (px, py)
    return None


# ─────────────────────────────────────────────────────────────────────────────
# HTML render
# ─────────────────────────────────────────────────────────────────────────────

def _render_w16_html(*, run_meta: dict, grid_layout: dict,
                      report: dict, svg_paths_by_fp: Dict[str, Path],
                      run_dir: Path) -> None:
    def esc(x):
        return (
            str(x).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        )

    inv = (report or {}).get("invariants", {}) 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))[:600]}</pre></td>"
        f"</tr>"
        for k, v in inv.items()
    )

    fp_sections: List[str] = []
    by_fp = (grid_layout or {}).get("grid_layout_by_fp") or {}
    for fp_id, fp_layout in by_fp.items():
        svg_rel = ""
        svg_path = svg_paths_by_fp.get(fp_id)
        if svg_path is not None:
            try:
                svg_rel = str(svg_path.relative_to(run_dir))
            except ValueError:
                svg_rel = svg_path.name
        unit_rows = "".join(
            f"<tr><td>{esc(u.get('unit_id'))}</td>"
            f"<td>{esc(u.get('unit_kind'))}</td>"
            f"<td>{esc(u.get('enclosure_mode'))}</td>"
            f"<td>{esc(u.get('scale_hint'))}</td>"
            f"<td><pre>{esc(json.dumps(u.get('rects')))}</pre></td>"
            f"<td>{esc(', '.join(u.get('open_to') or []))}</td>"
            f"<td>{esc(', '.join(u.get('door_to') or []))}</td></tr>"
            for u in fp_layout.get("units") or []
        )
        fixture_rows = "".join(
            f"<tr><td>#{esc(f.get('number'))}</td>"
            f"<td>{esc(f.get('unit_id'))}</td>"
            f"<td>{esc(json.dumps(f.get('cell')))}</td>"
            f"<td>{esc(f.get('render_mode'))}</td></tr>"
            for f in fp_layout.get("fixtures") or []
        )
        fp_sections.append(
            f"<section><h2>fp: {esc(fp_id)}</h2>"
            f"<p>layout_rationale:</p>"
            f"<pre>{esc(fp_layout.get('layout_rationale') or '')}</pre>"
            f"<p><a href=\"{esc(svg_rel)}\" target=\"_blank\">{esc(svg_rel)}</a></p>"
            f"<object data=\"{esc(svg_rel)}\" type=\"image/svg+xml\" "
            f"width=\"720\"></object>"
            f"<h3>Units</h3>"
            f"<table><tr><th>unit_id</th><th>unit_kind</th>"
            f"<th>enclosure_mode</th><th>scale_hint</th><th>rects</th>"
            f"<th>open_to</th><th>door_to</th></tr>{unit_rows}</table>"
            f"<h3>Fixtures</h3>"
            f"<table><tr><th>number</th><th>unit_id</th><th>cell</th>"
            f"<th>render_mode</th></tr>{fixture_rows}</table></section>"
        )

    body = f"""<!doctype html><html><head><meta charset="utf-8">
<title>W16 floor_plan_grid_layout_slice {esc(run_meta.get('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:72ch}}
section{{margin:1.5em 0}}</style></head><body>
<h1>W16 — floor_plan_grid_layout_slice {esc(run_meta.get('run_id'))}</h1>
<p>stage: <b>{esc(run_meta.get('stage'))}</b>
| run_status: <b class="{'pass' if run_meta.get('run_status')=='succeeded' else 'fail'}">
{esc(run_meta.get('run_status'))}</b>
| exit_code: {esc(run_meta.get('exit_code'))}
| model: <b>{esc(run_meta.get('model_used'))}</b>
| derived_from(W15e): {esc(run_meta.get('derived_from'))}
| image_api_call_count: <b>{esc(run_meta.get('image_api_call_count'))}</b></p>

{''.join(fp_sections)}

<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 grid_layout.json</summary>
<pre>{esc(json.dumps(grid_layout, ensure_ascii=False, indent=2))[:200000]}</pre></details>
</body></html>"""
    (run_dir / "index.html").write_text(body)


# ─────────────────────────────────────────────────────────────────────────────
# Placeholder dry-run layout
# ─────────────────────────────────────────────────────────────────────────────

def _placeholder_dry_run_layout(*, target_fp_ids: Set[str]) -> dict:
    """Skeleton output emitted when --generate is off. Carries the schema
    shape but no real layout; downstream invariants will mostly FAIL (which
    is the honest dry-run signal)."""
    out: Dict[str, Dict[str, Any]] = {}
    for fp_id in sorted(target_fp_ids):
        out[fp_id] = {
            "grid": {"cols": GRID_COLS, "rows": GRID_ROWS},
            "outer_outline": [],
            "units": [],
            "doorways": [],
            "windows": [],
            "fixtures": [],
            "adjacency_notes": [],
            "layout_rationale": "placeholder_dry_run",
        }
    return {"grid_layout_by_fp": out, "_dry_run": True}


# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────

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)

    if not args.derive_grid_layout_from and not args.reuse_grid_from:
        run_meta_err = {
            "run_id": run_id,
            "stage": W16_STAGE,
            "run_status": "validation_failed",
            "exit_code": 1,
            "failed_invariants": ["missing_required_args"],
            "error": "either --derive-grid-layout-from or --reuse-grid-from is required",
        }
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta_err, ensure_ascii=False, indent=2)
        )
        return 1

    # Decide source: reuse path skips LLM entirely.
    reuse_run_dir: Optional[Path] = None
    if args.reuse_grid_from:
        reuse_run_dir = Path(args.reuse_grid_from)
        if not reuse_run_dir.is_absolute():
            reuse_run_dir = Path.cwd() / reuse_run_dir
        # The reuse path still needs the upstream W15e brief and candidate
        # so invariants can validate against topology + candidate.
        reuse_run_meta_path = reuse_run_dir / "run_meta.json"
        reuse_grid_path = reuse_run_dir / "grid_layout_plan.json"
        if not reuse_run_meta_path.exists() or not reuse_grid_path.exists():
            run_meta_err = {
                "run_id": run_id,
                "stage": W16_STAGE,
                "run_status": "validation_failed",
                "exit_code": 1,
                "failed_invariants": ["reuse_grid_inputs_missing"],
                "error": (
                    f"reuse_grid_from missing required files in {reuse_run_dir}: "
                    "run_meta.json + grid_layout_plan.json"
                ),
            }
            (run_dir / "run_meta.json").write_text(
                json.dumps(run_meta_err, ensure_ascii=False, indent=2)
            )
            return 1
        reuse_run_meta = json.loads(reuse_run_meta_path.read_text())
        reuse_derived_from = reuse_run_meta.get("derived_from") or ""
        if not args.derive_grid_layout_from and reuse_derived_from:
            # Resolve the original W15e run dir from the reused run's
            # derived_from name relative to the topology slice parent.
            w15e_parent = (
                _REPO_ROOT / "scripts_output" / "floor_plan_topology_slice_experiment"
            )
            args.derive_grid_layout_from = str(w15e_parent / reuse_derived_from)

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

    artifacts = _load_w15e_artifacts(prev_run_dir)
    missing = list(artifacts.get("_missing", []))

    target_fp_ids, invalid_targets = _resolve_targets(args.target_fp_ids)

    failed_invariants: List[str] = []
    run_status = "succeeded"
    exit_code = 0
    model_used: Optional[str] = None
    stage_status = "dry_run"

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W16_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": None,
        "image_api_call_count": 0,
        "image_generation_count": 0,
        "outputs": [],
        "run_status": run_status,
        "exit_code": exit_code,
        "failed_invariants": failed_invariants,
        "stage_status": stage_status,
    }

    # Hard fail on missing inputs.
    if missing:
        failed_invariants.append("w15e_inputs_missing")
        run_meta["run_status"] = "validation_failed"
        run_meta["exit_code"] = 1
        run_meta["failed_invariants"] = failed_invariants
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return 1
    if invalid_targets:
        failed_invariants.append("invalid_target_fp_ids")
        run_meta["run_status"] = "validation_failed"
        run_meta["exit_code"] = 1
        run_meta["failed_invariants"] = failed_invariants
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return 1

    topology_brief = artifacts["topology"]
    candidate = artifacts["candidate"]

    # Build LLM input per fp (wave-1 restricts to fp_l05_01, but the loop is
    # generic; CLI restriction is enforced upstream).
    grid_layout_by_fp: Dict[str, Any] = {}
    if reuse_run_dir is not None:
        # Reuse path: load prior grid_layout_plan.json verbatim. No LLM
        # call, no image API call. model_used carries the prior run's
        # model value so the invariant still reflects what produced the
        # layout.
        reused_plan = json.loads(
            (reuse_run_dir / "grid_layout_plan.json").read_text()
        )
        reused_by_fp = (reused_plan or {}).get("grid_layout_by_fp") or {}
        grid_layout_by_fp = {
            k: v for k, v in reused_by_fp.items() if k in target_fp_ids
        }
        prior_meta = json.loads(
            (reuse_run_dir / "run_meta.json").read_text()
        )
        model_used = prior_meta.get("model_used")
        stage_status = "reused"
        run_meta["reused_grid_from"] = reuse_run_dir.name
        run_meta["reused_prior_model"] = model_used
    elif args.generate:
        try:
            for fp_id in sorted(target_fp_ids):
                llm_input = _build_w16_llm_input(
                    topology_brief=topology_brief,
                    candidate=candidate,
                    target_fp_id=fp_id,
                )
                parsed = _generate_w16_via_llm(
                    llm_input, model=args.model, retry_once=False,
                )
                by_fp = (parsed or {}).get("grid_layout_by_fp") or {}
                if fp_id not in by_fp:
                    # Tolerate flat shape if the model emitted the fp dict
                    # directly under the top key.
                    if "grid" in parsed:
                        by_fp = {fp_id: parsed}
                grid_layout_by_fp.update(
                    {k: v for k, v in by_fp.items() if k == fp_id}
                )
            model_used = args.model
            stage_status = "generated"
        except Exception as exc:  # noqa: BLE001
            failed_invariants.append("llm_call_failed")
            run_meta["llm_error"] = str(exc)[:400]
            run_status = "validation_failed"
            exit_code = 1
            stage_status = "llm_failed"
    else:
        skeleton = _placeholder_dry_run_layout(target_fp_ids=target_fp_ids)
        grid_layout_by_fp = skeleton.get("grid_layout_by_fp") or {}
        model_used = None
        stage_status = "dry_run"

    grid_layout = {"grid_layout_by_fp": grid_layout_by_fp}
    (run_dir / "grid_layout_plan.json").write_text(
        json.dumps(grid_layout, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("grid_layout_plan.json")

    # Deterministic SVG renderer — only when fp_layout has at least one unit.
    svg_paths_by_fp: Dict[str, Path] = {}
    for fp_id, fp_layout in grid_layout_by_fp.items():
        if not isinstance(fp_layout, dict):
            continue
        units = fp_layout.get("units") or []
        if not units:
            continue
        try:
            svg_paths_by_fp[fp_id] = _render_w16_svg(
                fp_id=fp_id, fp_layout=fp_layout, run_dir=run_dir,
            )
        except Exception as exc:  # noqa: BLE001
            run_meta.setdefault("svg_errors", []).append({
                "fp_id": fp_id, "error": str(exc)[:240],
            })
    svg_emitted = bool(svg_paths_by_fp)

    report = _build_w16_compatibility_report(
        grid_layout=grid_layout,
        topology_brief=topology_brief,
        candidate=candidate,
        target_fp_ids=target_fp_ids,
        production_diff_empty=_check_production_diff_empty(),
        db_write_count=0,
        image_import_seen=_check_image_imports_present(),
        image_api_call_count=0,
        svg_emitted=svg_emitted,
        model_used=model_used,
        stage_status=stage_status,
        missing_inputs=missing,
        prev_run_id=prev_run_dir.name,
        svg_paths_by_fp=svg_paths_by_fp,
    )
    (run_dir / "grid_layout_compatibility_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("grid_layout_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["run_status"] = run_status
    run_meta["exit_code"] = exit_code
    run_meta["failed_invariants"] = failed_invariants

    _render_w16_html(
        run_meta=run_meta, grid_layout=grid_layout,
        report=report, svg_paths_by_fp=svg_paths_by_fp, run_dir=run_dir,
    )
    run_meta["outputs"].append("index.html")
    for fp_id, p in svg_paths_by_fp.items():
        try:
            run_meta["outputs"].append(str(p.relative_to(run_dir)))
        except ValueError:
            run_meta["outputs"].append(p.name)

    (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())
