"""experiment_floor_plan_topology_slice — W15.

Source-topology-first floor-plan candidate revision experiment. Replaces
W12's `_build_w12_llm_input` deficit: this stage injects the full scene
segment text for shots that touch the target fp, plus selected_shots raw
fields with their parsed t2i variation anchors, plus the production
floor_plan_prompt JSON as named ``baseline_soft_evidence``. The LLM
(gemini-3.5-flash, one combined call) emits a four-part schema:
``source_topology_by_fp``, ``bg_unit_bindings``,
``candidate_floor_plans`` (W12 shape), and
``per_bg_render_reference_instructions`` (W12 shape).

Non-target fps and bgs are copied through from the prior W12c run
unchanged so downstream W14/W13 consumers can use the W15 artifacts as a
drop-in W12c replacement. No image API call, no DB write, no production
manifest mutation.

CLI:
  --derive-topology-from <W12c_run_dir>     (required)
  --target-fp-ids fp_l05_01                  (CSV or 'all'; default 'all')
  --generate                                  (default off — LLM call only)
  --model gemini-3.5-flash                    (default)
  --output-root <path>                        (defaults to scripts_output)
  --diag-print-imports
"""
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,
    W6_IMAGE_BACKEND,
    _check_image_imports_present,
    _check_production_diff_empty,
    _extract_t2i_anchors,
    _load_backend_env,
    _load_production_floor_plan_context,
    _load_w3_adapter_from_chain,
    _maybe_print_imports,
    _resolve_source_bundle_via_derived_from_chain,
    _safe_parse_json_field,
)

W15_STAGE = "w15_floor_plan_topology_slice"

# Generic entity-instance terms (background/entity ownership-boundary
# guard). These are universal noun forms with no scenario-specific
# meaning; they are forbidden on render surfaces regardless of which
# episode is being rendered. Scenario-specific character proper nouns
# come dynamically from the run-time evidence bundle (see
# `character_entity_names_from_visible_entities`).
_STATIC_GENERIC_ENTITY_INSTANCE_TERMS = (
    "body", "corpse", "cadaver", "dead person",
    "human figure on the floor", "person remains",
)
W15_IMAGE_BACKEND = W6_IMAGE_BACKEND  # carry-only; this stage makes no image call
DEFAULT_W15_MODEL = "gemini-3.5-flash"

_DEFAULT_OUTPUT_ROOT = (
    _REPO_ROOT / "scripts_output" / "floor_plan_topology_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=(
            "W15 floor_plan_topology_slice — source-topology-first LLM pass. "
            "No image API call. No production manifest mutation."
        )
    )
    p.add_argument(
        "--derive-topology-from",
        required=True,
        help="Path to a prior W12c success run dir.",
    )
    p.add_argument(
        "--target-fp-ids", default="all",
        help="Comma-separated candidate fp_id subset or 'all' (default).",
    )
    p.add_argument("--generate", action="store_true",
                   help="Actual LLM call (gemini-3.5-flash). Default off — "
                        "placeholder dry-run only. No image API call ever.")
    p.add_argument("--model", default=DEFAULT_W15_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)


# ─────────────────────────────────────────────────────────────────────────────
# W12c artifact loader
# ─────────────────────────────────────────────────────────────────────────────

def _load_w12c_artifacts(prev_run_dir: Path) -> dict:
    required = {
        "candidate": "floor_plan_prompt_candidate.json",
        "per_bg": "per_bg_render_reference_instruction.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())
    # W12c emits director_set_layout_brief.json. Later W15 topology
    # runs emit source_topology_brief.json. Accept either as the prior
    # brief so narrow follow-up passes can use the last topology output
    # as their baseline without re-dropping source-grounded elements.
    brief_path = prev_run_dir / "director_set_layout_brief.json"
    if brief_path.exists():
        out["brief"] = json.loads(brief_path.read_text())
        out["_source_kind"] = "w12c"
    else:
        topology_path = prev_run_dir / "source_topology_brief.json"
        if topology_path.exists():
            out["brief"] = json.loads(topology_path.read_text())
            out["_source_kind"] = "w15_topology"
        else:
            missing.append("director_set_layout_brief.json|source_topology_brief.json")
    out["_missing"] = missing
    out["_prev_run_id"] = prev_run_dir.name
    return out


_W11_PARENT_DIR = (
    _REPO_ROOT / "scripts_output" / "background_pipeline_slice_experiment"
)


def _resolve_w11_run_dir(
    *, w12c_args_value: Optional[str],
    derived_from: Optional[str],
    w12c_run_dir: Path,
) -> Optional[Path]:
    """Robust resolver: tries absolute / repo-root-relative / W12c-run-dir-
    relative / run-id under the standard background_pipeline_slice parent.
    """
    candidates: List[Path] = []
    if w12c_args_value:
        raw = Path(w12c_args_value)
        if raw.is_absolute():
            candidates.append(raw)
        else:
            candidates.append(_REPO_ROOT / w12c_args_value)
            candidates.append(w12c_run_dir / w12c_args_value)
            try:
                candidates.append((w12c_run_dir / w12c_args_value).resolve())
            except OSError:
                pass
    if derived_from:
        candidates.append(_W11_PARENT_DIR / derived_from)
    seen: set = set()
    for c in candidates:
        key = str(c)
        if key in seen:
            continue
        seen.add(key)
        try:
            if c.exists() and (c / "run_meta.json").exists():
                return c
        except OSError:
            continue
    return None


def _resolve_targets(targets_arg: str, candidate_fp_ids: Set[str]) -> Tuple[Set[str], List[str]]:
    s = (targets_arg or "").strip()
    if s.lower() == "all":
        return set(candidate_fp_ids), []
    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 candidate_fp_ids:
            valid.add(tok)
        else:
            invalid.append(tok)
    return valid, invalid


def _load_scene_save_segments(project_id: str, episode_id: str) -> dict:
    """Read `scene_save/manifest.json` segments and indexable views. Returns:
        {"segments": [...], "by_scene_index": {N: segment}, "status": "ok"|...}
    No image I/O; pure JSON read.
    """
    base = _REPO_ROOT / "projects" / project_id / "checkpoints" / "episodes" / episode_id
    path = base / "scene_save" / "manifest.json"
    out: Dict[str, Any] = {"segments": [], "by_scene_index": {},
                           "status": "missing", "path": str(path)}
    if not path.exists():
        return out
    try:
        m = json.loads(path.read_text())
        segs = ((m.get("data") or {}).get("segments") or [])
        out["segments"] = segs
        out["by_scene_index"] = {s.get("scene_index"): s for s in segs}
        out["status"] = "ok"
    except Exception as exc:  # noqa: BLE001
        out["status"] = "parse_failed"
        out["error"] = str(exc)[:240]
    return out


# ─────────────────────────────────────────────────────────────────────────────
# Evidence gathering (exact IDs only — no semantic judgement in code)
# ─────────────────────────────────────────────────────────────────────────────

def _bgs_using_target_fp(adapter: dict, target_fp_ids: Set[str]) -> Set[str]:
    out: Set[str] = set()
    catalog = (adapter or {}).get("background_catalog") or {}
    for bg_id, entry in catalog.items():
        deps = (entry or {}).get("depends_on_fp") or []
        if any(fp in target_fp_ids for fp in deps):
            out.add(bg_id)
    return out


def _scene_indices_for_bgs(adapter: dict, bgs: Set[str],
                           selected_shots_by_key: Dict[str, dict]) -> Set[int]:
    catalog = (adapter or {}).get("background_catalog") or {}
    scenes: Set[int] = set()
    for bg in bgs:
        entry = catalog.get(bg) or {}
        for shot_key in (entry.get("applies_to_shots") or []):
            shot = selected_shots_by_key.get(shot_key)
            if not shot:
                continue
            si = shot.get("scene_index")
            if isinstance(si, int):
                scenes.add(si)
    return scenes


_RAW_SHOT_FIELDS = (
    "shot_key", "scene_index", "shot_index",
    "screenplay_scene_heading", "scene_summary", "shot_description",
    "still_frame_prompt", "t2i_prompt_cinematic", "t2i_prompt_closeup",
    "visible_entities_json", "t2i_variations_json",
    "beat_title", "scene_type", "camera_json", "lighting_json",
)

_T2I_ANCHOR_KEEP_FIELDS = (
    "source_facts", "visual_inferences", "owned_object_usage",
    "applied_frame_spatial_constraint_ids", "reference_phrase_kinds",
    "anchor_objects", "variant_label",
)


def _carry_raw_shot_row(shot: dict) -> dict:
    """Carry W3 selected_shots fields verbatim (no summarization). raw JSON
    strings stay as strings so the LLM also sees the unparsed form."""
    return {k: shot.get(k) for k in _RAW_SHOT_FIELDS if k in shot}


def _build_parsed_t2i_anchors_by_shot(
    selected_shot_rows: List[dict],
) -> Tuple[Dict[str, List[dict]], List[dict]]:
    """Per shot_key, parse `t2i_variations_json` and keep only the anchor
    fields the LLM should reason over. Parse failures are NOT fatal; they
    surface in a diagnostic list and the raw string remains in the shot
    row carried separately."""
    out: Dict[str, List[dict]] = {}
    diagnostics: List[dict] = []
    for shot in selected_shot_rows or []:
        key = shot.get("shot_key") or ""
        if not key:
            continue
        raw = shot.get("t2i_variations_json")
        parsed = _safe_parse_json_field(raw)
        if parsed is None:
            if raw:
                diagnostics.append({
                    "shot_key": key, "field": "t2i_variations_json",
                    "reason": "parse_failed_or_empty",
                })
            out[key] = []
            continue
        if not isinstance(parsed, list):
            diagnostics.append({
                "shot_key": key, "field": "t2i_variations_json",
                "reason": "not_a_list",
            })
            out[key] = []
            continue
        anchors_full = _extract_t2i_anchors(parsed)
        # `_extract_t2i_anchors` does not surface `reference_phrase_kinds`
        # (it's a variant-level field, not an anchor field). Carry it from
        # the raw variant by matching `variant_label`.
        ref_phrase_kinds_by_variant: Dict[Any, Any] = {
            v.get("variant_label"): v.get("reference_phrase_kinds")
            for v in parsed
            if isinstance(v, dict) and v.get("variant_label") is not None
        }
        anchors_trimmed: List[dict] = []
        for entry in anchors_full:
            trimmed = {k: entry.get(k) for k in _T2I_ANCHOR_KEEP_FIELDS if k in entry}
            vl = entry.get("variant_label")
            if vl in ref_phrase_kinds_by_variant:
                trimmed["reference_phrase_kinds"] = ref_phrase_kinds_by_variant[vl]
            anchors_trimmed.append(trimmed)
        out[key] = anchors_trimmed
    return out, diagnostics


def _build_source_evidence_bundle(
    *, target_fp_ids: Set[str],
    w12c_candidates: Dict[str, dict],
    w12c_per_bg: Dict[str, dict],
    adapter: dict,
    selected_shots: List[dict],
    scene_save: dict,
    production_fp_context: dict,
) -> dict:
    """Deterministic source-evidence collector. No semantic judgement here.

    Walks: target fp → adapter `depends_on_fp` → target bg set → bg
    `applies_to_shots` → target shot_keys → target scene_indices → full
    `scene_save` segment text. Carries raw selected_shots rows + parsed
    t2i anchors + production fp baseline + W12c baseline candidates and
    per_bg, all under `baseline_soft_evidence` so source wins on conflict.
    """
    selected_shots_by_key = {
        s.get("shot_key"): s for s in (selected_shots or []) if s.get("shot_key")
    }
    catalog = (adapter or {}).get("background_catalog") or {}

    target_bg_ids = sorted(_bgs_using_target_fp(adapter, target_fp_ids))
    target_bg_entries: Dict[str, dict] = {bg: catalog.get(bg) or {} for bg in target_bg_ids}

    target_shot_keys_ordered: List[str] = []
    seen: Set[str] = set()
    for bg in target_bg_ids:
        for shot_key in (target_bg_entries[bg].get("applies_to_shots") or []):
            if shot_key not in seen and shot_key in selected_shots_by_key:
                seen.add(shot_key)
                target_shot_keys_ordered.append(shot_key)
    target_selected_shots: List[dict] = [
        _carry_raw_shot_row(selected_shots_by_key[k])
        for k in target_shot_keys_ordered
    ]

    target_scene_indices = sorted({
        s.get("scene_index") for s in target_selected_shots
        if isinstance(s.get("scene_index"), int)
    })
    by_scene = scene_save.get("by_scene_index") or {}
    if not by_scene:
        by_scene = {
            s.get("scene_index"): s
            for s in (scene_save.get("segments") or [])
            if isinstance(s.get("scene_index"), int)
        }
    target_scene_segments: List[dict] = []
    for si in target_scene_indices:
        seg = by_scene.get(si)
        if seg:
            target_scene_segments.append({
                "scene_index": seg.get("scene_index"),
                "heading": seg.get("heading") or "",
                "text": seg.get("text") or "",
            })

    parsed_t2i_anchors_by_shot, anchor_diagnostics = _build_parsed_t2i_anchors_by_shot(
        target_selected_shots
    )

    # Dynamic character-name collector for the render-surface output guard.
    # We only carry structured entities whose `short_id` begins with "C"
    # (character namespace) — locations (L*) and props (P*) are not
    # character names and would falsely fail render guards. This is an
    # exact ID-prefix join, NOT a regex / substring semantic extraction.
    character_entity_names: Set[str] = set()
    for raw_shot in target_selected_shots:
        ve_raw = raw_shot.get("visible_entities_json")
        ve_parsed = _safe_parse_json_field(ve_raw)
        if not isinstance(ve_parsed, list):
            continue
        for ent in ve_parsed:
            if not isinstance(ent, dict):
                continue
            sid = ent.get("short_id") or ""
            if not isinstance(sid, str) or not sid.startswith("C"):
                continue
            name = ent.get("entity_name") or ""
            if isinstance(name, str) and name.strip():
                character_entity_names.add(name.strip())

    w12c_target_candidates: Dict[str, dict] = {
        fp: w12c_candidates.get(fp) or {} for fp in sorted(target_fp_ids)
    }
    w12c_target_per_bg: Dict[str, dict] = {
        bg: w12c_per_bg.get(bg) or {} for bg in target_bg_ids
    }
    production_floor_plans = (production_fp_context or {}).get("floor_plans") or {}

    return {
        "target_fp_ids": sorted(target_fp_ids),
        "target_bg_ids": target_bg_ids,
        "target_bg_entries": target_bg_entries,
        "target_shot_keys": target_shot_keys_ordered,
        "target_selected_shots": target_selected_shots,
        "target_scene_segments": target_scene_segments,
        "parsed_t2i_anchors_by_shot": parsed_t2i_anchors_by_shot,
        "anchor_parse_diagnostics": anchor_diagnostics,
        "character_entity_names_from_visible_entities": sorted(character_entity_names),
        "baseline_soft_evidence": {
            "production_floor_plan_prompt": production_floor_plans,
            "w12c_candidate_floor_plans_for_target": w12c_target_candidates,
            "w12c_per_bg_render_reference_instructions_for_target": w12c_target_per_bg,
        },
        "scenario_metadata": {
            "scene_save_status": scene_save.get("status"),
            "production_fp_prompt_status": (production_fp_context or {}).get("fp_prompt_status"),
            "target_shot_count": len(target_selected_shots),
            "target_scene_count": len(target_scene_segments),
        },
    }


# ─────────────────────────────────────────────────────────────────────────────
# LLM call (gemini-3.5-flash, single combined-schema call)
# ─────────────────────────────────────────────────────────────────────────────

W15_SYSTEM_PROMPT = """\
You are a floor-plan topology auditor and set designer assistant.

Your job: read the full source scene segments and shot evidence for the
target floor-plan IDs and decide the topology of enclosed rooms and
spatial units.

The `target_scene_segments` block contains full source scene text for
all scenes connected to this fp through target bg applies_to_shots.
Treat it as hard source evidence.

The `target_selected_shots` and `parsed_t2i_anchors_by_shot` are shot-
level supporting evidence. They may omit parts of the scene, so do not
override full scene text with a shot summary when they conflict.

`baseline_soft_evidence` is only prior model output (production
floor_plan_prompt, W12/W15 candidate). If it conflicts with source
scene text, explicitly report the conflict in
`conflicts_and_assumptions` and revise the candidate. However, do not
drop or renumber source-grounded physical fixtures/devices already
present in the prior candidate or in per-bg final prompts merely to
avoid a placement problem. Prefer correcting unit placement, scale, and
`element_placement_constraints[].revised_position_hint`.

Rules:
- Do not collapse distinct source rooms just because their loc_id,
  space_key, or fp_id are the same.
- If source supports more than one enclosed room, the revised candidate
  diagram MUST draw separate enclosed rooms with wall boundaries and
  door openings; do not merge them into a single open studio.
- If production floor_plan_prompt baseline asserts a specific room
  count or unit layout but the source text shows different topology,
  SOURCE wins. Record the contradiction in `conflicts_and_assumptions`.
- Do not invent rooms that are not supported by the source. Every
  spatial unit must cite at least one evidence_ref with source_ref of
  the form `scene:<scene_index>` or a shot key.
- Every concrete physical fixture/device that is narrated in a target
  `per_bg_render_reference_instructions[*].final_prompt_assembly_preview`
  and affects the background layout or visible environmental state must
  either appear as a numbered candidate element or be explicitly
  accounted for in `reconciliation_notes_vs_production`. Do not silently
  remove such fixtures/devices from the candidate.
- Do not put scenario-specific room names into the static schema
  template; runtime label text comes only from your reading of the
  source evidence.

OUTPUT SURFACE RULES — strict, render-safety:
- `source_topology_by_fp` (the topology brief) is review-only metadata.
  Evidence quotes there MAY carry source proper nouns verbatim because
  they are evidence excerpts.
- `candidate_floor_plans` and `per_bg_render_reference_instructions`
  are render-consumed surfaces. They MUST follow the production
  floor_plan_prompt rule: no proper nouns from the work. Do not use
  any character name, family-member name, role label tied to a specific
  character, or any other named entity from the source in render-
  consumed surfaces. Use generic descriptors only — e.g.
  `private bedroom A`, `private bedroom B`, `bedroom A door`,
  `bedroom B door`, `living dining area`, `bathroom`, `entry alcove`.
- Render surfaces must not name entity instances. Generic entity-
  instance terms such as `body`, `corpse`, `dead person`, `cadaver`,
  `human figure on the floor`, or `person remains` are FORBIDDEN in
  `candidate_diagram_t2i_prompt`,
  `candidate_key_elements`,
  `candidate_numbered_elements[].label` and `.position_hint`,
  `candidate_camera_recommendations`,
  `per_bg_render_reference_instructions[*].render_prompt_appendix`,
  and `per_bg_render_reference_instructions[*].final_prompt_assembly_preview`.
  Plot-device entries on the floor plan must use background-surface
  cues only: e.g. `red floor stain zone`,
  `curtain-side floor evidence zone`, `floor trace endpoint`,
  `footprint trail`, `wall red ring mark`.
- Generic stable unit_id_pointer slugs are preferred (e.g.
  `private_bedroom_a`, `private_bedroom_b`, `bathroom`, `kitchen_nook`,
  `living_dining`, `entry_alcove`). Do not embed any character name in
  the unit_id.

Output a single JSON object with these top-level keys:
- `source_topology_by_fp`: per target fp_id object with:
    - `fp_id`
    - `spatial_units[]`: each with
        `unit_id`, `unit_label`, `unit_kind`
        (enum: living_zone|kitchen_zone|private_room|service_room|
                entry_transition|opening|plot_zone|exterior_zone|uncertain),
        `is_enclosed_room` (bool),
        `evidence_refs[]` each with
            `source_ref` (e.g. "scene:12" or "S12_Shot4"),
            `quote` (verbatim short quote from that source),
            `field` (e.g. "scene_subheader", "shot_description",
                     "scene_summary", "beat_title")
    - `relationships[]`: `{from_unit, to_unit, relation_kind}` where
        relation_kind ∈ {door_between, open_connection,
                         window_to_exterior, line_of_sight, unknown}
    - `room_count_assessment`: `{standalone_enclosed_room_count, rationale}`
    - `do_not_collapse_units[]`: items of shape
        `{unit_id, reason, evidence_refs[]}`
    - `conflicts_and_assumptions[]`: list of short strings; mention any
        production baseline contradiction.
- `bg_unit_bindings`: per target bg_id object with
    `primary_unit_ids[]`, `secondary_visible_unit_ids[]`,
    `state_delta_unit_ids[]`, `evidence_refs[]`.
- `candidate_floor_plans`: per target fp_id, W12 shape (`fp_id`,
    `group_id_pointer`, `candidate_diagram_t2i_prompt`,
    `candidate_key_elements`, `candidate_numbered_elements`,
    `candidate_camera_recommendations`,
    `reconciliation_notes_vs_production`). Each numbered element MAY add
    an optional `unit_id_pointer` mapping to a unit_id; this is
    diagnostic and downstream consumers ignore it.
- `per_bg_render_reference_instructions`: per target bg_id, W12 shape
    (`bg_id`, `group_id`, `fp_id`, `applies_to_shots`,
    `floor_plan_ref_role`, `use_numbered_elements`,
    `ignore_numbered_elements`, `camera_axis_used`,
    `camera_axis_source`, `visible_zone_scope`, `prior_bg_ref_role`,
    `render_prompt_appendix`, `final_prompt_assembly_preview`).
- `scene_shot_consistency_audit`: a lightweight per-scene / per-shot /
    per-bg consistency review over the topology you just produced and
    the candidate/per_bg instruction you just produced. Use ONLY the
    same source evidence + your own topology output; do not invent new
    facts. Fields:
    - `scene_unit_claims[]`: items `{scene_index, relevant_unit_ids[],
      source_refs[], notes}`. List, for every scene_index in the
      `target_scene_segments` you were given, which spatial unit_ids
      that scene actually involves, with source_ref evidence.
    - `shot_unit_claims[]`: items `{shot_key, bg_id, relevant_unit_ids[],
      relevant_numbered_elements[], source_refs[], notes}`. For every
      shot_key in the `target_selected_shots` you were given, list the
      unit_ids and numbered element numbers (matching the revised
      `candidate_floor_plans` numbers) that the shot requires. `bg_id`
      must be the bg the shot maps to via `target_bg_entries`
      `applies_to_shots`.
    - `bg_instruction_consistency[]`: items
      `{bg_id, bound_unit_ids[], use_numbered_elements[],
        missing_unit_ids[], questionable_ignore_numbers[], status}`.
      For every target bg_id, compare the bg's bound unit_ids (from
      `bg_unit_bindings`) with the `use_numbered_elements` you produced
      in `per_bg_render_reference_instructions` (via each numbered
      element's `unit_id_pointer`). `missing_unit_ids[]` lists bound
      units whose numbered elements are not present in
      `use_numbered_elements`. `questionable_ignore_numbers[]` lists
      numbers placed in `ignore_numbered_elements` whose unit_id_pointer
      is in `bound_unit_ids[]` (i.e. ignoring a bound unit's element).
      `status` ∈ {"ok", "warning", "must_split"}.
    - `candidate_topology_consistency[]`: items
      `{fp_id, missing_unit_ids_in_candidate, missing_required_elements,
        conflicts}`. For every target fp_id, list spatial_units that
      have no numbered element in the revised candidate
      (`missing_unit_ids_in_candidate`), and any required element types
      the candidate is missing (`missing_required_elements`).

Audit rules:
- The audit is a consistency review, not a new topology pass.
- Every `relevant_unit_ids[]` value must be a `unit_id` you already
  declared in `source_topology_by_fp[*].spatial_units`.
- Every `relevant_numbered_elements[]` value must be a marker number
  you already declared in the revised `candidate_floor_plans[*]
  .candidate_numbered_elements`.
- Every `source_ref` must be either `scene:<N>` for an N in
  `target_scene_segments`, or a shot_key from `target_shot_keys`.
- If `bg_instruction_consistency.missing_unit_ids` is non-empty for a
  bg, set `status="warning"` and explain in `notes` of the
  corresponding `shot_unit_claims` why the bg should still proceed or
  whether the bg should be split. Do not silently pass.

ALSO emit `element_placement_and_unit_scale_audit` as a top-level key
with fields:
- `unit_scale_constraints[]`: one item per spatial_unit (every unit_id
  you declared in `source_topology_by_fp[*].spatial_units`). Fields:
  - `unit_id`
  - `enclosure_mode`: enum
    {"full_wall_enclosed_room", "open_zone_inside_plan",
     "interior_threshold", "exterior_open"}
  - `relative_scale_hint`: enum
    {"compact_nook_or_wall_run", "compact_service_cell",
     "primary_zone", "secondary_zone", "exterior_landing",
     "uncertain"}
  - `open_connection_behavior`: short string. For open-zone units,
    describe how they connect to neighbouring zones without a wall
    (for example continuous floor, open threshold, no partition).
    For enclosed units, write "n/a".
  - `render_notes`: short string. Generic guidance only; no
    scenario-specific or character-specific terms.
- `element_placement_constraints[]`: one item per numbered element in
  the revised `candidate_floor_plans` (every number from every
  target fp). Fields:
  - `number`
  - `fp_id`
  - `unit_id_pointer`: the unit_id this element sits inside
  - `category`: the candidate element's category verbatim
  - `placement_mode`: enum
    {"wall_fixture", "perimeter_furniture",
     "interior_floor_furniture", "ceiling_or_overhead",
     "floor_plot_marker", "wall_plot_marker",
     "opening_marker", "area_label_only",
     "not_applicable"}
  - `anchor_surface_hint`: short string, one of the unit's interior
    walls or floor regions where this element must sit. Generic
    architectural terms only.
  - `avoid_open_connection_boundary`: boolean. For furniture, fixtures,
    props, or plot_device markers inside a unit that participates in an
    `open_connection` pair, set this true. Area labels and opening
    markers may set it false. When true, this element must not be
    placed on or across the connection boundary with the neighbouring
    open unit.
  - `must_not_form_boundary`: boolean. true for every furniture /
    fixture / plot_device element whose `position_hint` could be read
    as a partition or divider.
  - `revised_position_hint`: short string. If the candidate
    `position_hint` is ambiguous, conflicts with the unit's enclosure
    or with an `open_connection` neighbour, rewrite it here using
    generic architectural terms. If unchanged, copy the original.
  - `conflict_note`: short string. If the candidate position_hint
    conflicted with the topology (e.g. element pinned to a wall that
    is actually an open connection), describe the conflict in generic
    terms. Empty string when no conflict.

Placement audit rules:
- Furniture, fixtures, and plot_device markers MUST NOT be placed on
  the boundary between two units that are connected via
  `open_connection`. If the candidate position_hint forces such a
  placement, set `avoid_open_connection_boundary=true`,
  `must_not_form_boundary=true`, and rewrite `revised_position_hint`
  to an interior wall or floor inside the element's own unit.
- HARD VALIDATION RULE: if an element is furniture, fixture, prop, or
  plot_device and its `unit_id_pointer` participates in any
  `open_connection` relationship, set
  `avoid_open_connection_boundary=true` even when the element is placed
  on a valid perimeter/exterior wall. This flag means "avoid using the
  open edge as placement", not "avoid all walls".
- For wall fixtures in open-connected units, set
  `must_not_form_boundary=true` unless there is explicit topology
  evidence that the fixture belongs to a full-wall enclosed boundary.
- For furniture, fixtures, props, or plot_device markers whose
  unit_id_pointer is an open-zone or threshold unit, prefer
  `wall_fixture` or `perimeter_furniture` placements anchored to the
  unit's exterior/perimeter wall or interior floor region, not to the
  open-connection edge.
- A wall_fixture in an open-zone unit may still be valid, but it must
  not define a visual divider. If the current position_hint uses an
  interior partition/wall that is not supported by a `door_between`
  relationship, rewrite `revised_position_hint` to a safe perimeter or
  exterior wall inside that same unit and explain the conflict.
- Non-enclosed service or open units (`enclosure_mode` ∈
  {"open_zone_inside_plan", "interior_threshold"}) should be drawn
  as compact wall-run / nook surfaces, not blown up into full-sized
  separate rooms. Set their `relative_scale_hint` accordingly.
- Do not hardcode any scenario-specific term (no character names, no
  fictional location names). Use generic architectural language only.

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


def _generate_w15_via_llm(llm_input: dict, *, model: str,
                          retry_once: bool = True) -> dict:
    import litellm

    user_prompt = json.dumps(llm_input, ensure_ascii=False)
    routed = (
        model
        if model.startswith("gemini/") or not model.lower().startswith("gemini")
        else f"gemini/{model}"
    )
    if not (os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")):
        raise RuntimeError("missing GEMINI_API_KEY / GOOGLE_API_KEY env var")
    last_exc: Optional[Exception] = None
    attempts = 2 if retry_once else 1
    for _ in range(attempts):
        try:
            resp = litellm.completion(
                model=routed,
                messages=[
                    {"role": "system", "content": W15_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:
            last_exc = exc
            continue
    raise RuntimeError(f"w15_llm_failed_after_retry: {last_exc!s}"[:400])


# ─────────────────────────────────────────────────────────────────────────────
# Merge revised + W12c copy-through
# ─────────────────────────────────────────────────────────────────────────────

def _merge_revised_candidates_with_w12c_copy(
    *, w12c_candidates: Dict[str, dict],
    llm_revised: Dict[str, dict],
    target_fp_ids: Set[str],
) -> Dict[str, dict]:
    out: Dict[str, dict] = {}
    for fp_id, fp in (w12c_candidates or {}).items():
        if fp_id in target_fp_ids and fp_id in (llm_revised or {}):
            out[fp_id] = llm_revised[fp_id]
        else:
            out[fp_id] = fp
    # If the LLM emits a target fp_id not in W12c (shouldn't normally happen),
    # carry it through as well.
    for fp_id, fp in (llm_revised or {}).items():
        if fp_id not in out and fp_id in target_fp_ids:
            out[fp_id] = fp
    return out


def _merge_revised_per_bg_with_w12c_copy(
    *, w12c_per_bg: Dict[str, dict],
    llm_revised: Dict[str, dict],
    target_bg_ids: Set[str],
) -> Dict[str, dict]:
    out: Dict[str, dict] = {}
    for bg_id, entry in (w12c_per_bg or {}).items():
        if bg_id in target_bg_ids and bg_id in (llm_revised or {}):
            out[bg_id] = llm_revised[bg_id]
        else:
            out[bg_id] = entry
    for bg_id, entry in (llm_revised or {}).items():
        if bg_id not in out and bg_id in target_bg_ids:
            out[bg_id] = entry
    return out


# ─────────────────────────────────────────────────────────────────────────────
# Evidence-refs resolver (used by invariant 4 and self-consistency invariant)
# ─────────────────────────────────────────────────────────────────────────────

def _resolve_evidence_refs(refs: List[dict], scene_idx_set: Set[int],
                           shot_keys: Set[str]) -> Tuple[List[dict], List[dict]]:
    resolved: List[dict] = []
    unresolved: List[dict] = []
    for r in refs or []:
        if isinstance(r, str):
            r = {"source_ref": r}
        if not isinstance(r, dict):
            unresolved.append({"ref": str(r), "reason": "not_a_dict"})
            continue
        sref = (r.get("source_ref") or "").strip()
        if not sref:
            unresolved.append({"ref": sref, "reason": "empty"})
            continue
        if sref.startswith("scene:"):
            idx_str = sref[len("scene:"):]
            try:
                idx = int(idx_str)
            except ValueError:
                unresolved.append({"ref": sref, "reason": "scene_index_not_int"})
                continue
            if idx in scene_idx_set:
                resolved.append(r)
            else:
                unresolved.append({"ref": sref, "reason": "scene_index_unknown"})
        else:
            if sref in shot_keys:
                resolved.append(r)
            else:
                unresolved.append({"ref": sref, "reason": "shot_key_unknown"})
    return resolved, unresolved


# ─────────────────────────────────────────────────────────────────────────────
# Compatibility report (7 invariants)
# ─────────────────────────────────────────────────────────────────────────────

def _build_w15_compatibility_report(
    *, w12c_per_bg: Dict[str, dict],
    w12c_candidates: Dict[str, dict],
    revised_candidates: Dict[str, dict],
    topology_by_fp: Dict[str, dict],
    bg_unit_bindings: Dict[str, dict],
    target_fp_ids: Set[str],
    scene_idx_set: Set[int],
    shot_keys: Set[str],
    production_diff_empty: bool,
    db_write_count: int,
    image_import_seen: bool,
    missing_inputs: List[str],
    stage_status: str,
    prev_run_id: str,
    revised_per_bg: Optional[Dict[str, dict]] = None,
    forbidden_character_terms: Optional[List[str]] = None,
    consistency_audit: Optional[Dict[str, Any]] = None,
    target_scene_idx_set: Optional[Set[int]] = None,
    target_shot_keys_set: Optional[Set[str]] = None,
    placement_scale_audit: Optional[Dict[str, Any]] = None,
) -> dict:
    inv: Dict[str, Dict[str, Any]] = {}

    inv["inputs_present"] = {
        "pass": (
            not missing_inputs
            and bool(w12c_candidates)
            and bool(w12c_per_bg)
        ),
        "detail": {
            "missing_inputs": list(missing_inputs),
            "w12c_candidate_count": len(w12c_candidates or {}),
            "w12c_per_bg_count": len(w12c_per_bg or {}),
            "stage_status": stage_status,
            "prev_run_id": prev_run_id,
        },
    }

    missing_topology: List[str] = []
    missing_revised_cand: List[str] = []
    for fp_id in sorted(target_fp_ids):
        if fp_id not in (topology_by_fp or {}):
            missing_topology.append(fp_id)
        if fp_id not in (revised_candidates or {}):
            missing_revised_cand.append(fp_id)
    inv["target_fp_covered"] = {
        "pass": (not missing_topology) and (not missing_revised_cand)
                 and bool(target_fp_ids),
        "detail": {
            "target_fp_ids": sorted(target_fp_ids),
            "missing_topology": missing_topology,
            "missing_revised_candidates": missing_revised_cand,
        },
    }

    target_bgs = {
        bg for bg, instr in (w12c_per_bg or {}).items()
        if (instr or {}).get("fp_id") in target_fp_ids
    }
    uncovered_bgs = sorted(bg for bg in target_bgs if bg not in (bg_unit_bindings or {}))
    inv["bg_unit_bindings_cover_target_bgs"] = {
        "pass": not uncovered_bgs,
        "detail": {
            "target_bg_count": len(target_bgs),
            "uncovered_bgs": uncovered_bgs,
            "binding_fp_set": sorted(set(target_bgs)),
        },
    }

    refs_total = 0
    refs_unresolved_global: List[dict] = []
    for fp_id, topo in (topology_by_fp or {}).items():
        for unit in (topo or {}).get("spatial_units") or []:
            refs = (unit or {}).get("evidence_refs") or []
            refs_total += len(refs)
            _, unres = _resolve_evidence_refs(refs, scene_idx_set, shot_keys)
            for u in unres:
                refs_unresolved_global.append({"fp_id": fp_id, **u})
        for dnc in (topo or {}).get("do_not_collapse_units") or []:
            if isinstance(dnc, dict):
                refs = dnc.get("evidence_refs") or []
                refs_total += len(refs)
                _, unres = _resolve_evidence_refs(refs, scene_idx_set, shot_keys)
                for u in unres:
                    refs_unresolved_global.append({"fp_id": fp_id, **u})
    for bg_id, binding in (bg_unit_bindings or {}).items():
        refs = (binding or {}).get("evidence_refs") or []
        refs_total += len(refs)
        _, unres = _resolve_evidence_refs(refs, scene_idx_set, shot_keys)
        for u in unres:
            refs_unresolved_global.append({"bg_id": bg_id, **u})
    inv["evidence_refs_resolve"] = {
        "pass": not refs_unresolved_global,
        "detail": {
            "total_refs_seen": refs_total,
            "unresolved": refs_unresolved_global[:50],
            "unresolved_count": len(refs_unresolved_global),
        },
    }

    missing_elements: Dict[str, List[str]] = {}
    for fp_id in sorted(target_fp_ids):
        rev = (revised_candidates or {}).get(fp_id) or {}
        gaps: List[str] = []
        if not rev.get("candidate_diagram_t2i_prompt"):
            gaps.append("candidate_diagram_t2i_prompt")
        ne = rev.get("candidate_numbered_elements")
        if not isinstance(ne, list) or len(ne) == 0:
            gaps.append("candidate_numbered_elements")
        if gaps:
            missing_elements[fp_id] = gaps
    inv["candidate_elements_present"] = {
        "pass": not missing_elements,
        "detail": {"missing_elements_by_fp": missing_elements},
    }

    # Loose self-consistency: (a) declared room count matches enclosed unit
    # count; (b) every do_not_collapse_units id exists in spatial_units; (c)
    # non-enclosed do_not_collapse items must carry a non-empty evidence_refs.
    self_consistency_failures: List[dict] = []
    for fp_id, topo in (topology_by_fp or {}).items():
        units = (topo or {}).get("spatial_units") or []
        unit_id_set: Set[str] = {
            (u or {}).get("unit_id")
            for u in units
            if isinstance(u, dict) and (u or {}).get("unit_id")
        }
        enclosed_count = sum(
            1 for u in units
            if isinstance(u, dict) and (u or {}).get("is_enclosed_room") is True
        )
        rca = (topo or {}).get("room_count_assessment") or {}
        declared = rca.get("standalone_enclosed_room_count")
        if not isinstance(declared, int) or declared != enclosed_count:
            self_consistency_failures.append({
                "fp_id": fp_id,
                "kind": "room_count_mismatch",
                "declared": declared,
                "enclosed_unit_count": enclosed_count,
            })
        for dnc in (topo or {}).get("do_not_collapse_units") or []:
            if not isinstance(dnc, dict):
                self_consistency_failures.append({
                    "fp_id": fp_id,
                    "kind": "do_not_collapse_not_a_dict",
                    "value": str(dnc)[:80],
                })
                continue
            uid = dnc.get("unit_id")
            if not uid or uid not in unit_id_set:
                self_consistency_failures.append({
                    "fp_id": fp_id,
                    "kind": "do_not_collapse_unit_id_unknown",
                    "unit_id": uid,
                })
                continue
            # If the referenced unit is not enclosed, require evidence_refs.
            target_unit = next(
                (u for u in units if isinstance(u, dict) and (u or {}).get("unit_id") == uid),
                None,
            )
            if target_unit and (target_unit.get("is_enclosed_room") is not True):
                ev = dnc.get("evidence_refs") or []
                if not ev:
                    self_consistency_failures.append({
                        "fp_id": fp_id,
                        "kind": "do_not_collapse_non_enclosed_without_evidence",
                        "unit_id": uid,
                    })
    inv["topology_room_count_self_consistent"] = {
        "pass": not self_consistency_failures,
        "detail": {
            "failures": self_consistency_failures[:50],
            "failure_count": len(self_consistency_failures),
        },
    }

    # Output-safety guard: render-consumed surfaces (candidate fp text +
    # per-bg render appendix/final preview) must not embed character
    # proper nouns OR generic entity-instance words like "body" or
    # "corpse".
    #
    # The static lexicon below holds only GENERIC entity-instance terms
    # (background/entity ownership-boundary guard). No scenario-specific
    # proper noun is hardcoded here. Per-run character proper-noun terms
    # are supplied via `forbidden_character_terms`, collected dynamically
    # from `visible_entities_json[].entity_name` whose `short_id` begins
    # with "C". This is an exact ID-prefix join, not a regex / substring
    # semantic extraction.
    forbidden_render_tokens = tuple(
        list(_STATIC_GENERIC_ENTITY_INSTANCE_TERMS)
        + list(forbidden_character_terms or ())
    )
    render_surface_failures: List[dict] = []
    for fp_id in sorted(target_fp_ids):
        fp = (revised_candidates or {}).get(fp_id) or {}
        fields = [
            ("candidate_diagram_t2i_prompt", fp.get("candidate_diagram_t2i_prompt") or ""),
        ]
        for ke in fp.get("candidate_key_elements") or []:
            fields.append(("candidate_key_elements", str(ke)))
        for el in fp.get("candidate_numbered_elements") or []:
            if isinstance(el, dict):
                fields.append(("candidate_numbered_elements.label", str(el.get("label") or "")))
                fields.append(("candidate_numbered_elements.position_hint",
                               str(el.get("position_hint") or "")))
        for cr in fp.get("candidate_camera_recommendations") or []:
            if isinstance(cr, dict):
                fields.append(("candidate_camera_recommendations.framing_notes",
                               str(cr.get("framing_notes") or "")))
                fields.append(("candidate_camera_recommendations.camera_position",
                               str(cr.get("camera_position") or "")))
        for field_name, value in fields:
            for token in forbidden_render_tokens:
                if token.lower() in value.lower():
                    render_surface_failures.append({
                        "fp_id": fp_id, "field": field_name,
                        "token": token, "snippet": value[:160],
                    })
    target_bg_set = {
        bg for bg, instr in (w12c_per_bg or {}).items()
        if (instr or {}).get("fp_id") in target_fp_ids
    }
    per_bg_to_scan = revised_per_bg if revised_per_bg is not None else (w12c_per_bg or {})
    for bg_id in sorted(target_bg_set):
        instr = (per_bg_to_scan or {}).get(bg_id) or {}
        per_bg_fields = [
            ("render_prompt_appendix", str(instr.get("render_prompt_appendix") or "")),
            ("final_prompt_assembly_preview", str(instr.get("final_prompt_assembly_preview") or "")),
        ]
        for field_name, value in per_bg_fields:
            for token in forbidden_render_tokens:
                if token.lower() in value.lower():
                    render_surface_failures.append({
                        "bg_id": bg_id, "field": field_name,
                        "token": token, "snippet": value[:160],
                    })
    inv["render_surface_no_proper_noun_or_entity_instance"] = {
        "pass": not render_surface_failures,
        "detail": {
            "failures": render_surface_failures[:40],
            "failure_count": len(render_surface_failures),
            "forbidden_token_count": len(forbidden_render_tokens),
        },
    }

    # W15d: scene/shot/bg consistency audit invariant. 4 sub-checks against
    # the LLM-emitted `scene_shot_consistency_audit`. Deterministic structural
    # checks only.
    #
    # Modes:
    #   - consistency_audit is None    → caller has not opted into audit
    #                                    enforcement (e.g. legacy test path);
    #                                    invariant auto-PASS with a skip note.
    #   - consistency_audit == {}      → caller wants audit checking but the
    #                                    LLM did not emit. pass=True iff
    #                                    stage_status != "generated".
    #   - consistency_audit is dict    → full strict structural check.
    sub_audit_results: Dict[str, dict] = {}
    if consistency_audit is None:
        inv["scene_shot_consistency_audit_resolves"] = {
            "pass": True,
            "detail": {
                "skipped_reason": "consistency_audit not provided by caller",
                "stage_status": stage_status,
            },
        }
        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
            ),
            "detail": {
                "production_diff_empty": production_diff_empty,
                "db_write_count": db_write_count,
                "image_import_seen": image_import_seen,
                "image_generation_count": 0,
            },
        }
        all_pass = all(v["pass"] for v in inv.values())
        return {"invariants": inv, "all_pass": all_pass}
    audit = consistency_audit
    if not audit:
        inv["scene_shot_consistency_audit_resolves"] = {
            "pass": stage_status != "generated",
            "detail": {
                "stage_status": stage_status,
                "audit_present": False,
                "skipped_reason": (
                    "audit enforced only when stage_status='generated' "
                    "and audit dict is non-empty"
                ),
            },
        }
        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
            ),
            "detail": {
                "production_diff_empty": production_diff_empty,
                "db_write_count": db_write_count,
                "image_import_seen": image_import_seen,
                "image_generation_count": 0,
            },
        }
        all_pass = all(v["pass"] for v in inv.values())
        return {"invariants": inv, "all_pass": all_pass}

    # Sub-check (a): every target scene/shot appears in the audit.
    scene_idx_target = target_scene_idx_set or set()
    audit_scene_set = {
        c.get("scene_index") for c in (audit.get("scene_unit_claims") or [])
        if isinstance(c, dict) and isinstance(c.get("scene_index"), int)
    }
    missing_scenes_in_audit = sorted(scene_idx_target - audit_scene_set)
    extra_scenes_in_audit = sorted(audit_scene_set - scene_idx_target)
    shot_keys_target = target_shot_keys_set or set()
    audit_shot_keys = {
        c.get("shot_key") for c in (audit.get("shot_unit_claims") or [])
        if isinstance(c, dict) and c.get("shot_key")
    }
    missing_shots_in_audit = sorted(shot_keys_target - audit_shot_keys)
    extra_shots_in_audit = sorted(audit_shot_keys - shot_keys_target)
    sub_a_pass = (
        not missing_scenes_in_audit and not extra_scenes_in_audit
        and not missing_shots_in_audit and not extra_shots_in_audit
        and bool(scene_idx_target) and bool(shot_keys_target)
    )
    sub_audit_results["covers_all_target_scenes_and_shots"] = {
        "pass": sub_a_pass,
        "missing_scenes_in_audit": missing_scenes_in_audit,
        "extra_scenes_in_audit": extra_scenes_in_audit,
        "missing_shots_in_audit": missing_shots_in_audit,
        "extra_shots_in_audit": extra_shots_in_audit,
    }

    # Sub-check (b): every audit-referenced unit_id exists in the topology.
    all_topology_unit_ids: Set[str] = set()
    for fp_id, topo in (topology_by_fp or {}).items():
        for u in (topo or {}).get("spatial_units") or []:
            if isinstance(u, dict) and u.get("unit_id"):
                all_topology_unit_ids.add(u.get("unit_id"))
    unknown_unit_refs: List[dict] = []
    for c in (audit.get("scene_unit_claims") or []):
        for uid in (c or {}).get("relevant_unit_ids") or []:
            if uid not in all_topology_unit_ids:
                unknown_unit_refs.append({
                    "where": "scene_unit_claims",
                    "scene_index": (c or {}).get("scene_index"),
                    "unit_id": uid,
                })
    for c in (audit.get("shot_unit_claims") or []):
        for uid in (c or {}).get("relevant_unit_ids") or []:
            if uid not in all_topology_unit_ids:
                unknown_unit_refs.append({
                    "where": "shot_unit_claims",
                    "shot_key": (c or {}).get("shot_key"),
                    "unit_id": uid,
                })
    for c in (audit.get("bg_instruction_consistency") or []):
        for uid in ((c or {}).get("bound_unit_ids") or []) + \
                   ((c or {}).get("missing_unit_ids") or []):
            if uid not in all_topology_unit_ids:
                unknown_unit_refs.append({
                    "where": "bg_instruction_consistency",
                    "bg_id": (c or {}).get("bg_id"),
                    "unit_id": uid,
                })
    for c in (audit.get("candidate_topology_consistency") or []):
        for uid in (c or {}).get("missing_unit_ids_in_candidate") or []:
            if uid not in all_topology_unit_ids:
                unknown_unit_refs.append({
                    "where": "candidate_topology_consistency",
                    "fp_id": (c or {}).get("fp_id"),
                    "unit_id": uid,
                })
    sub_audit_results["audit_unit_ids_exist_in_topology"] = {
        "pass": not unknown_unit_refs,
        "unknown_unit_refs": unknown_unit_refs[:40],
        "unknown_unit_ref_count": len(unknown_unit_refs),
    }

    # Sub-check (c): every audit-referenced numbered element exists in the
    # revised candidate for the right fp.
    all_revised_numbers_by_fp: Dict[str, Set[int]] = {}
    for fp_id, fp in (revised_candidates or {}).items():
        nums: Set[int] = set()
        for e in (fp or {}).get("candidate_numbered_elements") or []:
            if isinstance(e, dict) and isinstance(e.get("number"), int):
                nums.add(e.get("number"))
        all_revised_numbers_by_fp[fp_id] = nums
    # Map bg_id → fp_id (from revised per_bg if present, else W12c fallback)
    bg_to_fp: Dict[str, str] = {}
    src_per_bg = revised_per_bg if revised_per_bg is not None else (w12c_per_bg or {})
    for bg_id, instr in (src_per_bg or {}).items():
        if (instr or {}).get("fp_id"):
            bg_to_fp[bg_id] = (instr or {}).get("fp_id")
    unknown_number_refs: List[dict] = []
    for c in (audit.get("shot_unit_claims") or []):
        bg_id = (c or {}).get("bg_id") or ""
        fp_for_bg = bg_to_fp.get(bg_id) or ""
        valid_nums = all_revised_numbers_by_fp.get(fp_for_bg, set())
        for n in (c or {}).get("relevant_numbered_elements") or []:
            if not isinstance(n, int) or n not in valid_nums:
                unknown_number_refs.append({
                    "shot_key": (c or {}).get("shot_key"),
                    "bg_id": bg_id, "fp_id_for_bg": fp_for_bg,
                    "missing_number": n,
                })
    sub_audit_results["audit_numbered_elements_exist_in_candidate"] = {
        "pass": not unknown_number_refs,
        "unknown_number_refs": unknown_number_refs[:40],
        "unknown_number_ref_count": len(unknown_number_refs),
    }

    # Sub-check (d): every bg with a multi-unit binding whose per_bg
    # `use_numbered_elements` doesn't cover all bound units must be
    # reported in `bg_instruction_consistency` with non-`ok` status OR
    # appear in `multi_unit_bindings`. The LLM is required to report,
    # not silently pass.
    fp_number_to_unit: Dict[str, Dict[int, str]] = {}
    for fp_id, fp in (revised_candidates or {}).items():
        m: Dict[int, str] = {}
        for e in (fp or {}).get("candidate_numbered_elements") or []:
            if isinstance(e, dict) and isinstance(e.get("number"), int):
                m[e.get("number")] = e.get("unit_id_pointer") or ""
        fp_number_to_unit[fp_id] = m
    bg_consistency_by_bg: Dict[str, dict] = {
        (c or {}).get("bg_id"): (c or {})
        for c in (audit.get("bg_instruction_consistency") or [])
        if isinstance(c, dict) and c.get("bg_id")
    }
    # Strictly enforce coverage for primary_unit_ids only. secondary_visible
    # surfaces are background-only auxiliary surfaces and are reported as
    # diagnostic via `secondary_unit_coverage_diagnostics` (no invariant
    # failure). Codex W15d guidance: "If bg_instruction_consistency
    # .missing_unit_ids is non-empty for a bg, do not silently pass" —
    # this applies to bound (primary) units, not optional secondary
    # surfaces.
    unreported_missing_coverage: List[dict] = []
    secondary_unit_coverage_diagnostics: List[dict] = []
    for bg_id, binding in (bg_unit_bindings or {}).items():
        primary_units = set((binding or {}).get("primary_unit_ids") or [])
        secondary_units = set((binding or {}).get("secondary_visible_unit_ids") or [])
        fp_for_bg = bg_to_fp.get(bg_id) or ""
        num_to_unit = fp_number_to_unit.get(fp_for_bg) or {}
        instr = (src_per_bg or {}).get(bg_id) or {}
        use_nums = [n for n in (instr.get("use_numbered_elements") or [])
                    if isinstance(n, int)]
        used_units = {num_to_unit.get(n) for n in use_nums if num_to_unit.get(n)}
        missing_primary = sorted(primary_units - used_units)
        missing_secondary = sorted(secondary_units - used_units)
        if missing_primary:
            reported = bg_consistency_by_bg.get(bg_id)
            status_ok = False
            if reported:
                reported_missing = set(reported.get("missing_unit_ids") or [])
                status = (reported.get("status") or "").lower()
                if set(missing_primary).issubset(reported_missing) and status in ("warning", "must_split"):
                    status_ok = True
            if not status_ok:
                unreported_missing_coverage.append({
                    "bg_id": bg_id,
                    "primary_unit_ids": sorted(primary_units),
                    "use_numbered_elements": use_nums,
                    "missing_primary_unit_ids": missing_primary,
                    "reported_in_audit": bool(reported),
                    "reported_status": (reported or {}).get("status"),
                })
        if missing_secondary:
            secondary_unit_coverage_diagnostics.append({
                "bg_id": bg_id,
                "missing_secondary_unit_ids": missing_secondary,
                "note": "secondary visible surface not in use; diagnostic only",
            })
    sub_audit_results["bg_instruction_missing_units_reported"] = {
        "pass": not unreported_missing_coverage,
        "unreported_missing_coverage": unreported_missing_coverage[:20],
        "unreported_count": len(unreported_missing_coverage),
        "secondary_unit_coverage_diagnostics": secondary_unit_coverage_diagnostics[:20],
    }

    audit_pass = all(v.get("pass") for v in sub_audit_results.values())
    inv["scene_shot_consistency_audit_resolves"] = {
        "pass": audit_pass,
        "detail": sub_audit_results,
    }

    # W15e: element placement + unit scale audit invariant. Same 3-mode
    # contract as the consistency audit invariant.
    placement_sub_results: Dict[str, dict] = {}
    if placement_scale_audit is None:
        inv["element_placement_and_unit_scale_audit_resolves"] = {
            "pass": True,
            "detail": {
                "skipped_reason": "placement_scale_audit not provided by caller",
                "stage_status": stage_status,
            },
        }
    elif not placement_scale_audit:
        inv["element_placement_and_unit_scale_audit_resolves"] = {
            "pass": stage_status != "generated",
            "detail": {
                "stage_status": stage_status,
                "audit_present": False,
                "skipped_reason": (
                    "placement audit enforced only when stage_status='generated' "
                    "and audit dict is non-empty"
                ),
            },
        }
    else:
        # Sub (a): unit_scale_constraints covers every spatial_unit unit_id
        all_topology_unit_ids: Set[str] = set()
        for _, topo in (topology_by_fp or {}).items():
            for u in (topo or {}).get("spatial_units") or []:
                if isinstance(u, dict) and u.get("unit_id"):
                    all_topology_unit_ids.add(u.get("unit_id"))
        unit_scale_units = {
            (c or {}).get("unit_id")
            for c in (placement_scale_audit.get("unit_scale_constraints") or [])
            if isinstance(c, dict) and c.get("unit_id")
        }
        missing_units = sorted(all_topology_unit_ids - unit_scale_units)
        extra_units = sorted(unit_scale_units - all_topology_unit_ids)
        sub_a_pass = (
            not missing_units and not extra_units
            and bool(all_topology_unit_ids)
        )
        placement_sub_results["unit_scale_constraints_cover_all_topology_units"] = {
            "pass": sub_a_pass,
            "missing_units": missing_units,
            "extra_units": extra_units,
            "covered_count": len(unit_scale_units),
        }

        # Sub (b): element_placement_constraints covers every candidate
        # number in the revised candidate_floor_plans (target fps only).
        candidate_keys_by_fp: Dict[str, Set[int]] = {}
        for fp_id in sorted(target_fp_ids):
            nums: Set[int] = set()
            for e in (
                (revised_candidates or {}).get(fp_id) or {}
            ).get("candidate_numbered_elements") or []:
                if isinstance(e, dict) and isinstance(e.get("number"), int):
                    nums.add(e.get("number"))
            candidate_keys_by_fp[fp_id] = nums
        constraints_by_fp: Dict[str, Set[int]] = {}
        unknown_number_in_audit: List[dict] = []
        unknown_unit_in_audit: List[dict] = []
        for c in (placement_scale_audit.get("element_placement_constraints") or []):
            if not isinstance(c, dict):
                continue
            fp_id = c.get("fp_id") or ""
            num = c.get("number")
            uid = c.get("unit_id_pointer") or ""
            if fp_id and isinstance(num, int):
                constraints_by_fp.setdefault(fp_id, set()).add(num)
                fp_nums = candidate_keys_by_fp.get(fp_id, set())
                if num not in fp_nums:
                    unknown_number_in_audit.append({
                        "fp_id": fp_id, "number": num,
                        "kind": "number_not_in_revised_candidate",
                    })
            if uid and uid not in all_topology_unit_ids:
                unknown_unit_in_audit.append({
                    "fp_id": fp_id, "number": num,
                    "unit_id_pointer": uid,
                    "kind": "unit_not_in_topology",
                })
        missing_constraint_by_fp: Dict[str, List[int]] = {}
        for fp_id, nums in candidate_keys_by_fp.items():
            covered = constraints_by_fp.get(fp_id, set())
            missing = sorted(nums - covered)
            if missing:
                missing_constraint_by_fp[fp_id] = missing
        sub_b_pass = (
            not missing_constraint_by_fp
            and not unknown_number_in_audit
            and not unknown_unit_in_audit
        )
        placement_sub_results["element_placement_constraints_cover_all_numbers"] = {
            "pass": sub_b_pass,
            "missing_constraint_by_fp": missing_constraint_by_fp,
            "unknown_number_in_audit": unknown_number_in_audit[:20],
            "unknown_unit_in_audit": unknown_unit_in_audit[:20],
        }

        # Sub (c): for furniture / fixture / prop / plot-device
        # elements whose unit_id_pointer participates in an
        # `open_connection` relationship, the constraint must set
        # `avoid_open_connection_boundary` true. Area labels and
        # opening markers are intentionally exempt: their job is to
        # name a unit or architectural opening, not to place a divider.
        open_conn_units: Set[str] = set()
        for _, topo in (topology_by_fp or {}).items():
            for r in (topo or {}).get("relationships") or []:
                if not isinstance(r, dict):
                    continue
                if (r.get("relation_kind") or "") == "open_connection":
                    fr = r.get("from_unit") or ""
                    to = r.get("to_unit") or ""
                    if fr:
                        open_conn_units.add(fr)
                    if to:
                        open_conn_units.add(to)
        open_conn_violations: List[dict] = []
        guard_categories = {
            "furniture", "fixture", "prop", "plot_device",
            "owned_object",
        }
        guard_modes = {
            "wall_fixture", "perimeter_furniture",
            "interior_floor_furniture", "ceiling_or_overhead",
            "floor_plot_marker", "wall_plot_marker",
        }
        for c in (placement_scale_audit.get("element_placement_constraints") or []):
            if not isinstance(c, dict):
                continue
            uid = c.get("unit_id_pointer") or ""
            if uid not in open_conn_units:
                continue
            category = str(c.get("category") or "").lower()
            placement_mode = str(c.get("placement_mode") or "")
            requires_guard = (
                category in guard_categories
                or placement_mode in guard_modes
            )
            if not requires_guard:
                continue
            if not c.get("avoid_open_connection_boundary"):
                open_conn_violations.append({
                    "fp_id": c.get("fp_id"), "number": c.get("number"),
                    "unit_id_pointer": uid,
                    "category": c.get("category"),
                    "placement_mode": c.get("placement_mode"),
                    "kind": "open_connection_unit_without_avoid_flag",
                })
        sub_c_pass = not open_conn_violations
        placement_sub_results["open_connection_boundary_guard_present"] = {
            "pass": sub_c_pass,
            "violations": open_conn_violations[:20],
        }

        audit_pass = all(v.get("pass") for v in placement_sub_results.values())
        inv["element_placement_and_unit_scale_audit_resolves"] = {
            "pass": audit_pass,
            "detail": placement_sub_results,
        }

    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
        ),
        "detail": {
            "production_diff_empty": production_diff_empty,
            "db_write_count": db_write_count,
            "image_import_seen": image_import_seen,
            "image_generation_count": 0,
        },
    }

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


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

def _render_w15_html(*, run_meta: dict,
                     topology_by_fp: Dict[str, dict],
                     bg_unit_bindings: Dict[str, dict],
                     revised_candidates: Dict[str, dict],
                     per_bg_revised: Dict[str, dict],
                     report: dict, 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'}\">{'PASS' if v['pass'] else 'FAIL'}</td>"
        f"<td><pre>{esc(json.dumps(v.get('detail'), ensure_ascii=False))[:600]}</pre></td></tr>"
        for k, v in inv.items()
    )

    topology_rows = ""
    for fp_id, topo in (topology_by_fp or {}).items():
        units = (topo or {}).get("spatial_units") or []
        unit_list = "".join(
            f"<li>#{esc(u.get('unit_id'))} "
            f"<b>{esc(u.get('unit_label'))}</b> "
            f"<small>({esc(u.get('unit_kind'))}, enclosed={'Y' if u.get('is_enclosed_room') else 'N'})</small>"
            f"<br><small>{esc(json.dumps(u.get('evidence_refs') or [], ensure_ascii=False))[:240]}</small></li>"
            for u in units
        )
        rel_list = ", ".join(
            f"{esc(r.get('from_unit'))} —[{esc(r.get('relation_kind'))}]→ {esc(r.get('to_unit'))}"
            for r in (topo or {}).get("relationships") or []
        )
        rca = (topo or {}).get("room_count_assessment") or {}
        dnc_count = len((topo or {}).get("do_not_collapse_units") or [])
        conflicts = (topo or {}).get("conflicts_and_assumptions") or []
        conflicts_html = "<br>".join(esc(c)[:300] for c in conflicts)
        topology_rows += (
            f"<tr><td>{esc(fp_id)}</td>"
            f"<td>{rca.get('standalone_enclosed_room_count')}<br><small>{esc(rca.get('rationale') or '')[:240]}</small></td>"
            f"<td><ul>{unit_list}</ul></td>"
            f"<td>{rel_list}</td>"
            f"<td>{dnc_count}</td>"
            f"<td><pre>{conflicts_html}</pre></td></tr>"
        )

    revised_rows = ""
    for fp_id, p in (revised_candidates or {}).items():
        nums = p.get("candidate_numbered_elements") or []
        nums_preview = "".join(
            f"<li>#{e.get('number')} {esc(e.get('label') or '')} "
            f"<small>({esc(e.get('category'))}; unit={esc(e.get('unit_id_pointer') or '')})</small></li>"
            for e in nums
        )
        revised_rows += (
            f"<tr><td>{esc(fp_id)}</td>"
            f"<td>{esc(p.get('group_id_pointer'))}</td>"
            f"<td>{len(nums)}<br><ul>{nums_preview}</ul></td>"
            f"<td><pre>{esc(p.get('candidate_diagram_t2i_prompt') or '')[:1500]}</pre></td>"
            f"<td><pre>{esc(p.get('reconciliation_notes_vs_production') or '')[:400]}</pre></td></tr>"
        )

    bind_rows = ""
    for bg_id, b in (bg_unit_bindings or {}).items():
        bind_rows += (
            f"<tr><td>{esc(bg_id)}</td>"
            f"<td>{esc(', '.join(b.get('primary_unit_ids') or []))}</td>"
            f"<td>{esc(', '.join(b.get('secondary_visible_unit_ids') or []))}</td>"
            f"<td>{esc(', '.join(b.get('state_delta_unit_ids') or []))}</td>"
            f"<td><pre>{esc(json.dumps(b.get('evidence_refs') or [], ensure_ascii=False))[:200]}</pre></td></tr>"
        )

    perbg_rows = ""
    for bg_id, instr in (per_bg_revised or {}).items():
        perbg_rows += (
            f"<tr><td>{esc(bg_id)}</td>"
            f"<td>{esc(instr.get('fp_id'))}</td>"
            f"<td>{esc(instr.get('floor_plan_ref_role'))}</td>"
            f"<td>{esc(', '.join(f'#{n}' for n in (instr.get('use_numbered_elements') or [])))}</td>"
            f"<td>{esc(', '.join(f'#{n}' for n in (instr.get('ignore_numbered_elements') or [])))}</td>"
            f"<td>{esc(instr.get('camera_axis_used'))}</td>"
            f"<td>{esc(instr.get('prior_bg_ref_role'))}</td>"
            f"<td><pre>{esc(instr.get('render_prompt_appendix') or '')[:400]}</pre></td></tr>"
        )

    html = f"""<!doctype html><html><head><meta charset=\"utf-8\">
<title>W15 floor_plan_topology_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}}
ul{{margin:0;padding-left:1.4em}}</style></head>
<body>
<h1>W15 — floor_plan_topology_slice {esc(run_meta.get('run_id'))}</h1>
<p>stage: <b>{esc(run_meta.get('stage'))}</b>
| run_status: <b>{esc(run_meta.get('run_status'))}</b>
| exit_code: {esc(run_meta.get('exit_code'))}
| stage_status: <b>{esc(run_meta.get('stage_status'))}</b>
| derived_from(W12c): {esc(run_meta.get('derived_from'))}
| model_used: <b>{esc(run_meta.get('model_used'))}</b>
| target_fp_ids: <b>{esc(', '.join(run_meta.get('target_fp_ids') or []))}</b>
| image_generation_count: <b>{esc(run_meta.get('image_generation_count'))}</b>
| image_generation_backend: <b>{esc(run_meta.get('image_generation_backend'))}</b></p>

<section><h2>1. Source topology by fp</h2>
<table><tr>
<th>fp_id</th><th>standalone enclosed room count</th>
<th>spatial_units (id / label / kind / enclosed / evidence)</th>
<th>relationships</th><th>do_not_collapse count</th>
<th>conflicts_and_assumptions</th>
</tr>{topology_rows}</table></section>

<section><h2>2. Revised candidate floor plans</h2>
<table><tr>
<th>fp_id</th><th>group_id_pointer</th>
<th>numbered_elements (count + preview)</th>
<th>candidate_diagram_t2i_prompt</th>
<th>reconciliation_notes_vs_production</th>
</tr>{revised_rows}</table></section>

<section><h2>3. Per-bg revised reference instructions</h2>
<table><tr>
<th>bg_id</th><th>fp_id</th><th>floor_plan_ref_role</th>
<th>use #</th><th>ignore #</th><th>camera_axis</th>
<th>prior_bg_ref_role</th><th>render_prompt_appendix</th>
</tr>{perbg_rows}</table></section>

<section><h2>4. bg → unit bindings</h2>
<table><tr>
<th>bg_id</th><th>primary_unit_ids</th><th>secondary_visible_unit_ids</th>
<th>state_delta_unit_ids</th><th>evidence_refs</th>
</tr>{bind_rows}</table></section>

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


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

def main(argv=None) -> int:
    args = _parse_args(argv)
    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_topology_from)
    if not prev_run_dir.is_absolute():
        prev_run_dir = Path.cwd() / prev_run_dir

    artifacts = _load_w12c_artifacts(prev_run_dir)
    missing = list(artifacts.get("_missing", []))
    outputs: List[str] = []
    failed: List[str] = []
    stage_status = "placeholder_dry_run"
    model_used: Optional[str] = None

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W15_STAGE,
        "plan_version": PLAN_VERSION,
        "generated_at": datetime.now(KST).isoformat(),
        "model": args.model if args.generate else None,
        "model_used": model_used,
        "image_generation_count": 0,
        "image_generation_backend": W15_IMAGE_BACKEND,
        "args": vars(args),
        "derived_from": prev_run_dir.name,
        "target_fp_ids": [],
        "outputs": outputs,
        "run_status": "unknown",
        "exit_code": 0,
        "failed_invariants": failed,
        "stage_status": stage_status,
    }

    if missing:
        failed.append("w12c_inputs_missing")
        run_meta["run_status"] = "validation_failed"
        run_meta["exit_code"] = 1
        run_meta["failed_invariants"] = failed
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return 1

    w12c_candidates = (artifacts["candidate"].get("candidate_floor_plans") or {})
    w12c_per_bg = (artifacts["per_bg"].get("per_bg_render_reference_instructions") or {})
    w12c_brief = artifacts.get("brief") or {}

    candidate_fp_ids = set(w12c_candidates.keys())
    target_fp_ids, invalid_targets = _resolve_targets(args.target_fp_ids,
                                                     candidate_fp_ids)
    run_meta["target_fp_ids"] = sorted(target_fp_ids)
    run_meta["invalid_targets"] = list(invalid_targets)

    if invalid_targets or not target_fp_ids:
        failed.append("invalid_targets")
        run_meta["run_status"] = "validation_failed"
        run_meta["exit_code"] = 1
        run_meta["failed_invariants"] = failed
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return 1

    # W12c lives in a different parent directory from W11; chain walkers
    # need a start dir whose `derived_from` chain stays within the same
    # parent directory. Resolve the W11 run dir from W12c.args first.
    source_run_meta = artifacts.get("run_meta") or {}
    w12c_args = source_run_meta.get("args") or {}
    w12c_run_dir_for_chain = prev_run_dir
    w12c_derived_from = source_run_meta.get("derived_from")
    if (
        not w12c_args.get("derive_floor_plan_candidate_from")
        and artifacts.get("_source_kind") == "w15_topology"
        and w12c_derived_from
    ):
        candidate_w12c = (
            _REPO_ROOT / "scripts_output" /
            "floor_plan_generation_slice_experiment" /
            str(w12c_derived_from)
        )
        candidate_meta_path = candidate_w12c / "run_meta.json"
        if candidate_meta_path.exists():
            candidate_meta = json.loads(candidate_meta_path.read_text())
            w12c_args = candidate_meta.get("args") or {}
            w12c_run_dir_for_chain = candidate_w12c
            w12c_derived_from = candidate_meta.get("derived_from")
    w11_run_dir = _resolve_w11_run_dir(
        w12c_args_value=w12c_args.get("derive_floor_plan_candidate_from"),
        derived_from=w12c_derived_from,
        w12c_run_dir=w12c_run_dir_for_chain,
    )

    chain_status = "unresolved"
    chain_info: Dict[str, Any] = {}
    adapter_plan: Dict[str, Any] = {}
    source_bundle: Dict[str, Any] = {}
    if w11_run_dir is None:
        chain_status = "w11_run_dir_unresolved"
        chain_info["error"] = "could not resolve W11 run dir from W12c args"
        failed.append("chain_unresolved")
    else:
        chain_info["w11_run_id"] = w11_run_dir.name
        try:
            adapter_plan = _load_w3_adapter_from_chain(w11_run_dir)
            resolved = _resolve_source_bundle_via_derived_from_chain(w11_run_dir)
            source_bundle = resolved["source_bundle"]
            chain_info["chain"] = resolved["chain"]
            chain_info["root_run_id"] = resolved["root_run_id"]
            chain_status = "resolved"
        except FileNotFoundError as exc:
            chain_status = "chain_unresolved"
            chain_info["error"] = str(exc)[:300]
            failed.append("chain_unresolved")

    run_meta["chain_status"] = chain_status
    run_meta["chain_info"] = chain_info

    project_id = source_bundle.get("project_id") or ""
    episode_id = source_bundle.get("episode_id") or ""
    fp_context = _load_production_floor_plan_context(project_id, episode_id) if chain_status == "resolved" else {}
    scene_save = _load_scene_save_segments(project_id, episode_id) if chain_status == "resolved" else {"segments": [], "by_scene_index": {}, "status": "skipped"}

    # Source evidence bundle (deterministic gather). Used in --generate AND
    # surfaced in the placeholder dry-run so reviewers can verify coverage.
    selected_shots = source_bundle.get("selected_shots") or []
    evidence_bundle = _build_source_evidence_bundle(
        target_fp_ids=target_fp_ids,
        w12c_candidates=w12c_candidates,
        w12c_per_bg=w12c_per_bg,
        adapter=adapter_plan,
        selected_shots=selected_shots,
        scene_save=scene_save,
        production_fp_context=fp_context,
    )
    # Honest failure when the evidence bundle is empty for the target. The
    # LLM cannot produce a useful topology without target shots/scenes.
    if not evidence_bundle["target_selected_shots"] or not evidence_bundle["target_scene_segments"]:
        failed.append("evidence_bundle_empty_for_target")

    # ID sets used by invariant resolvers. Scope to the target-only
    # evidence bundle (Codex W15b BLOCKING: episode-wide sets would
    # accept evidence_refs to scenes/shots not actually fed to the LLM).
    scene_idx_set: Set[int] = {
        s.get("scene_index") for s in (evidence_bundle.get("target_scene_segments") or [])
        if isinstance(s.get("scene_index"), int)
    }
    shot_keys: Set[str] = set(evidence_bundle.get("target_shot_keys") or [])

    # LLM call (only when --generate). Else placeholder shells.
    topology_by_fp: Dict[str, dict] = {}
    bg_unit_bindings: Dict[str, dict] = {}
    revised_candidates: Dict[str, dict] = {}
    per_bg_revised: Dict[str, dict] = {}
    llm_error: Optional[str] = None

    consistency_audit: Dict[str, Any] = {}
    placement_scale_audit: Dict[str, Any] = {}
    if args.generate and "evidence_bundle_empty_for_target" not in failed:
        _load_backend_env()
        try:
            llm_out = _generate_w15_via_llm(evidence_bundle, model=args.model)
            model_used = args.model
            stage_status = "generated"
            topology_by_fp = (llm_out or {}).get("source_topology_by_fp") or {}
            bg_unit_bindings = (llm_out or {}).get("bg_unit_bindings") or {}
            revised_candidates = (llm_out or {}).get("candidate_floor_plans") or {}
            per_bg_revised = (llm_out or {}).get("per_bg_render_reference_instructions") or {}
            consistency_audit = (llm_out or {}).get("scene_shot_consistency_audit") or {}
            placement_scale_audit = (llm_out or {}).get("element_placement_and_unit_scale_audit") or {}
        except Exception as exc:  # noqa: BLE001
            llm_error = str(exc)[:400]
            stage_status = "llm_failed"
            failed.append("w15_llm_failed")
    elif args.generate:
        stage_status = "skipped_due_to_empty_evidence"
    else:
        stage_status = "placeholder_dry_run"

    # Merge revised with W12c copy-through.
    merged_candidates = _merge_revised_candidates_with_w12c_copy(
        w12c_candidates=w12c_candidates,
        llm_revised=revised_candidates,
        target_fp_ids=target_fp_ids,
    )
    target_bg_ids = {
        bg for bg, instr in (w12c_per_bg or {}).items()
        if (instr or {}).get("fp_id") in target_fp_ids
    }
    merged_per_bg = _merge_revised_per_bg_with_w12c_copy(
        w12c_per_bg=w12c_per_bg,
        llm_revised=per_bg_revised,
        target_bg_ids=target_bg_ids,
    )

    # Write artifacts.
    prod_fp_baseline = (
        (evidence_bundle.get("baseline_soft_evidence") or {})
        .get("production_floor_plan_prompt") or {}
    )
    prod_fp_keys = sorted(prod_fp_baseline.keys()) if isinstance(prod_fp_baseline, dict) else []

    # Per-fp room kind summary for reviewer convenience (Codex MINOR).
    room_kind_summary_by_fp: Dict[str, dict] = {}
    for fp_id, topo in (topology_by_fp or {}).items():
        units = (topo or {}).get("spatial_units") or []
        private_count = sum(
            1 for u in units
            if isinstance(u, dict)
            and (u or {}).get("unit_kind") == "private_room"
            and (u or {}).get("is_enclosed_room") is True
        )
        service_count = sum(
            1 for u in units
            if isinstance(u, dict)
            and (u or {}).get("unit_kind") == "service_room"
            and (u or {}).get("is_enclosed_room") is True
        )
        other_enclosed = sum(
            1 for u in units
            if isinstance(u, dict)
            and (u or {}).get("is_enclosed_room") is True
            and (u or {}).get("unit_kind") not in ("private_room", "service_room")
        )
        room_kind_summary_by_fp[fp_id] = {
            "private_room_count": private_count,
            "service_room_count": service_count,
            "other_enclosed_unit_count": other_enclosed,
            "standalone_enclosed_room_count": (
                ((topo or {}).get("room_count_assessment") or {}).get("standalone_enclosed_room_count")
            ),
        }

    # Multi-unit-binding diagnostic for reviewer (Codex IMPORTANT 2).
    multi_unit_bindings: List[dict] = []
    for bg_id, b in (bg_unit_bindings or {}).items():
        primary = list((b or {}).get("primary_unit_ids") or [])
        secondary = list((b or {}).get("secondary_visible_unit_ids") or [])
        if len(primary) + len(secondary) > 1:
            multi_unit_bindings.append({
                "bg_id": bg_id,
                "primary_unit_ids": primary,
                "secondary_visible_unit_ids": secondary,
                "note": "multi_unit_background — verify per_bg use_numbered_elements covers all bound units",
            })

    (run_dir / "source_topology_brief.json").write_text(
        json.dumps(
            {
                "target_fp_ids": sorted(target_fp_ids),
                "source_topology_by_fp": topology_by_fp,
                "bg_unit_bindings": bg_unit_bindings,
                "scene_shot_consistency_audit": consistency_audit,
                "element_placement_and_unit_scale_audit": placement_scale_audit,
                "room_kind_summary_by_fp": room_kind_summary_by_fp,
                "multi_unit_binding_diagnostics": multi_unit_bindings,
                "evidence_bundle_summary_for_review": {
                    "target_bg_ids": evidence_bundle.get("target_bg_ids"),
                    "target_shot_count": len(evidence_bundle.get("target_selected_shots") or []),
                    "target_scene_count": len(evidence_bundle.get("target_scene_segments") or []),
                    "anchor_parse_diagnostics_count": len(evidence_bundle.get("anchor_parse_diagnostics") or []),
                    "production_fp_baseline_keys": prod_fp_keys,
                },
            },
            ensure_ascii=False, indent=2,
        )
    )
    outputs.append("source_topology_brief.json")
    (run_dir / "floor_plan_prompt_candidate.json").write_text(
        json.dumps({"candidate_floor_plans": merged_candidates},
                   ensure_ascii=False, indent=2)
    )
    outputs.append("floor_plan_prompt_candidate.json")
    (run_dir / "per_bg_render_reference_instruction.json").write_text(
        json.dumps({"per_bg_render_reference_instructions": merged_per_bg},
                   ensure_ascii=False, indent=2)
    )
    outputs.append("per_bg_render_reference_instruction.json")

    report = _build_w15_compatibility_report(
        w12c_per_bg=w12c_per_bg,
        w12c_candidates=w12c_candidates,
        revised_candidates=merged_candidates,
        topology_by_fp=topology_by_fp,
        bg_unit_bindings=bg_unit_bindings,
        target_fp_ids=target_fp_ids,
        scene_idx_set=scene_idx_set,
        shot_keys=shot_keys,
        production_diff_empty=_check_production_diff_empty(),
        db_write_count=0,
        image_import_seen=_check_image_imports_present(),
        missing_inputs=missing,
        stage_status=stage_status,
        prev_run_id=prev_run_dir.name,
        revised_per_bg=merged_per_bg,
        forbidden_character_terms=list(
            evidence_bundle.get("character_entity_names_from_visible_entities") or []
        ),
        consistency_audit=consistency_audit,
        target_scene_idx_set=scene_idx_set,
        target_shot_keys_set=shot_keys,
        placement_scale_audit=placement_scale_audit,
    )
    (run_dir / "w15_compatibility_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    outputs.append("w15_compatibility_report.json")

    for name, v in report["invariants"].items():
        if not v["pass"] and name not in failed:
            failed.append(name)

    if failed:
        run_status = "validation_failed"
        exit_code = 1
    elif stage_status == "placeholder_dry_run":
        run_status = "dry_run"
        exit_code = 0
    else:
        run_status = "succeeded"
        exit_code = 0

    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
    run_meta["outputs"] = outputs
    if llm_error:
        run_meta["llm_error"] = llm_error

    _render_w15_html(
        run_meta=run_meta,
        topology_by_fp=topology_by_fp,
        bg_unit_bindings=bg_unit_bindings,
        revised_candidates=merged_candidates,
        per_bg_revised=merged_per_bg,
        report=report, run_dir=run_dir,
    )
    outputs.append("index.html")
    run_meta["outputs"] = outputs
    (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__":
    raise SystemExit(main())
