"""W15 structural tests — generic synthetic fixtures only.

No semantic correctness assertions. No scenario-specific token expectations
in the methodology — fixtures use abstract IDs (FP_T / FP_NT / BGT / BGN /
S1_Shot1 / S2_Shot1 / Z1 / Z2 / Z3 / U_living / U_room_a / U_entry).
Tests cover: source-evidence bundle, evidence_refs resolver, non-target
fp copy-through, target bg coverage, no image API guard, render-safety
guard, consistency audit sub-checks, HTML section order. The methodology
grep on the source script uses per-char split tokens so this test file
itself is exempt from that grep.
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

import pytest


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


def _synthetic_w12c_candidates() -> dict:
    return {
        "FP_T": {  # target
            "fp_id": "FP_T", "group_id_pointer": "Gt",
            "candidate_diagram_t2i_prompt": "baseline target plan",
            "candidate_key_elements": [],
            "candidate_numbered_elements": [
                {"number": 1, "label": "z1", "category": "area",
                 "position_hint": "north", "zone_id_pointer": "Z1"},
            ],
            "candidate_camera_recommendations": [
                {"bg_id": "BGT", "sub_location": "Z1",
                 "camera_position": "near #1", "camera_height": "1.6m",
                 "lens_hint": "35mm", "framing_notes": ""},
            ],
            "reconciliation_notes_vs_production": "",
        },
        "FP_NT": {  # non-target, copy-through
            "fp_id": "FP_NT", "group_id_pointer": "Gn",
            "candidate_diagram_t2i_prompt": "baseline non-target plan",
            "candidate_key_elements": [],
            "candidate_numbered_elements": [
                {"number": 1, "label": "n1", "category": "area",
                 "position_hint": "south", "zone_id_pointer": "Z2"},
            ],
            "candidate_camera_recommendations": [],
            "reconciliation_notes_vs_production": "",
        },
    }


def _synthetic_w12c_per_bg() -> dict:
    return {
        "BGT": {
            "bg_id": "BGT", "group_id": "Gt", "fp_id": "FP_T",
            "applies_to_shots": ["S1_Shot1"],
            "floor_plan_ref_role": "layout_only",
            "use_numbered_elements": [1], "ignore_numbered_elements": [],
            "camera_axis_used": "ax", "camera_axis_source": "w11_axis",
            "visible_zone_scope": ["Z1"], "prior_bg_ref_role": "none",
            "render_prompt_appendix": "", "final_prompt_assembly_preview": "",
        },
        "BGN": {
            "bg_id": "BGN", "group_id": "Gn", "fp_id": "FP_NT",
            "applies_to_shots": ["S2_Shot1"],
            "floor_plan_ref_role": "layout_only",
            "use_numbered_elements": [1], "ignore_numbered_elements": [],
            "camera_axis_used": "ax2", "camera_axis_source": "w11_axis",
            "visible_zone_scope": ["Z2"], "prior_bg_ref_role": "none",
            "render_prompt_appendix": "", "final_prompt_assembly_preview": "",
        },
    }


def _synthetic_adapter() -> dict:
    return {
        "background_catalog": {
            "BGT": {
                "depends_on_fp": ["FP_T"], "depends_on_bg": [],
                "sub_location_label": "zone_t", "state_label_raw": "base",
                "loc_id": "Lt", "space_key": "St",
                "applies_to_shots": ["S1_Shot1"],
            },
            "BGN": {
                "depends_on_fp": ["FP_NT"], "depends_on_bg": [],
                "sub_location_label": "zone_n", "state_label_raw": "base",
                "loc_id": "Ln", "space_key": "Sn",
                "applies_to_shots": ["S2_Shot1"],
            },
        },
    }


def _synthetic_source_bundle() -> dict:
    return {
        "project_id": "fake-proj", "episode_id": "fake-ep",
        "selected_shots": [
            {"shot_key": "S1_Shot1", "scene_index": 1, "shot_index": 1,
             "shot_description": "synthetic desc 1",
             "screenplay_scene_heading": "S#1.",
             "t2i_variations_json": "[]", "visible_entities_json": "[]"},
            {"shot_key": "S2_Shot1", "scene_index": 2, "shot_index": 1,
             "shot_description": "synthetic desc 2",
             "screenplay_scene_heading": "S#2.",
             "t2i_variations_json": "[]", "visible_entities_json": "[]"},
        ],
    }


def _synthetic_scene_save() -> dict:
    return {
        "segments": [
            {"scene_index": 1, "heading": "S#1.", "text": "synthetic scene 1 text",
             "start_char": 0, "end_char": 10, "length": 10},
            {"scene_index": 2, "heading": "S#2.", "text": "synthetic scene 2 text",
             "start_char": 10, "end_char": 20, "length": 10},
        ],
    }


def _synthetic_fp_baseline() -> dict:
    return {
        "fp_prompt_path": "/fake", "fp_render_path": "/fake",
        "fp_prompt_status": "ok", "fp_render_status": "missing",
        "floor_plans": {
            "FP_PROD_T": {
                "fp_id": "FP_PROD_T", "group_id": "Gt",
                "depends_on_fp": [], "key_elements": [],
                "numbered_elements": [], "camera_recommendations": [],
                "t2i_prompt": "production baseline target prompt",
                "applied_shots": [], "png_path": "", "png_exists": False,
            },
        },
    }


def _synthetic_llm_output() -> dict:
    """Mirror of the schema the real LLM returns. Target fp has 2 enclosed
    rooms; non-target fp does not appear (copy-through is performed by the
    main loop, not by the LLM)."""
    return {
        "source_topology_by_fp": {
            "FP_T": {
                "fp_id": "FP_T",
                "spatial_units": [
                    {"unit_id": "U_living", "unit_label": "living zone",
                     "unit_kind": "living_zone", "is_enclosed_room": True,
                     "evidence_refs": [{"source_ref": "scene:1",
                                        "quote": "/거실 placeholder",
                                        "field": "scene_subheader"}]},
                    {"unit_id": "U_room_a", "unit_label": "private room a",
                     "unit_kind": "private_room", "is_enclosed_room": True,
                     "evidence_refs": [{"source_ref": "scene:1",
                                        "quote": "/room placeholder",
                                        "field": "scene_subheader"}]},
                    {"unit_id": "U_entry", "unit_label": "entry",
                     "unit_kind": "entry_transition", "is_enclosed_room": False,
                     "evidence_refs": [{"source_ref": "S1_Shot1",
                                        "quote": "shot desc",
                                        "field": "shot_description"}]},
                ],
                "relationships": [
                    {"from_unit": "U_living", "to_unit": "U_room_a",
                     "relation_kind": "door_between"},
                ],
                "room_count_assessment": {
                    "standalone_enclosed_room_count": 2,
                    "rationale": "two enclosed rooms identified",
                },
                "do_not_collapse_units": [
                    {"unit_id": "U_living", "reason": "primary living",
                     "evidence_refs": [{"source_ref": "scene:1",
                                        "quote": "/거실",
                                        "field": "scene_subheader"}]},
                    {"unit_id": "U_room_a", "reason": "victim found here",
                     "evidence_refs": [{"source_ref": "scene:1",
                                        "quote": "/room",
                                        "field": "scene_subheader"}]},
                ],
                "conflicts_and_assumptions": [
                    "production baseline says one-bedroom but source slash subheaders show two enclosed rooms",
                ],
            },
        },
        "bg_unit_bindings": {
            "BGT": {
                "primary_unit_ids": ["U_living"],
                "secondary_visible_unit_ids": ["U_room_a"],
                "state_delta_unit_ids": [],
                "evidence_refs": [{"source_ref": "S1_Shot1",
                                   "quote": "shot desc 1",
                                   "field": "shot_description"}],
            },
        },
        "candidate_floor_plans": {
            "FP_T": {
                "fp_id": "FP_T", "group_id_pointer": "Gt",
                "candidate_diagram_t2i_prompt": "revised target plan with two enclosed rooms",
                "candidate_key_elements": [],
                "candidate_numbered_elements": [
                    {"number": 1, "label": "z1", "category": "area",
                     "position_hint": "north", "zone_id_pointer": "Z1",
                     "unit_id_pointer": "U_living"},
                    {"number": 2, "label": "z2", "category": "area",
                     "position_hint": "east", "zone_id_pointer": "Z3",
                     "unit_id_pointer": "U_room_a"},
                ],
                "candidate_camera_recommendations": [
                    {"bg_id": "BGT", "sub_location": "Z1",
                     "camera_position": "near #1", "camera_height": "1.6m",
                     "lens_hint": "35mm", "framing_notes": ""},
                ],
                "reconciliation_notes_vs_production": "expanded to two enclosed rooms based on source",
            },
        },
        "per_bg_render_reference_instructions": {
            "BGT": {
                "bg_id": "BGT", "group_id": "Gt", "fp_id": "FP_T",
                "applies_to_shots": ["S1_Shot1"],
                "floor_plan_ref_role": "layout_only",
                "use_numbered_elements": [1, 2],
                "ignore_numbered_elements": [],
                "camera_axis_used": "ax", "camera_axis_source": "w11_axis",
                "visible_zone_scope": ["Z1", "Z3"],
                "prior_bg_ref_role": "none",
                "render_prompt_appendix": "axis=ax. use #1,#2.",
                "final_prompt_assembly_preview": "revised base text",
            },
        },
        "scene_shot_consistency_audit": {
            "scene_unit_claims": [
                {"scene_index": 1, "relevant_unit_ids": ["U_living", "U_room_a"],
                 "source_refs": [{"source_ref": "scene:1"}], "notes": "scene 1"},
            ],
            "shot_unit_claims": [
                {"shot_key": "S1_Shot1", "bg_id": "BGT",
                 "relevant_unit_ids": ["U_living", "U_room_a"],
                 "relevant_numbered_elements": [1, 2],
                 "source_refs": [{"source_ref": "S1_Shot1"}], "notes": "shot 1"},
            ],
            "bg_instruction_consistency": [
                {"bg_id": "BGT",
                 "bound_unit_ids": ["U_living", "U_room_a"],
                 "use_numbered_elements": [1, 2],
                 "missing_unit_ids": [],
                 "questionable_ignore_numbers": [],
                 "status": "ok"},
            ],
            "candidate_topology_consistency": [
                {"fp_id": "FP_T",
                 "missing_unit_ids_in_candidate": [],
                 "missing_required_elements": [],
                 "conflicts": []},
            ],
        },
    }


def test_w15_source_evidence_bundle_collects_target_only():
    """`_build_source_evidence_bundle` must include only the scene segments
    whose scene_index appears in target bg's `applies_to_shots` shots, plus
    the raw selected_shots rows for those shots, plus parsed t2i anchors
    keyed by shot_key. Non-target scenes and shots must be excluded."""
    from experiment_floor_plan_topology_slice import (
        _build_source_evidence_bundle,
    )

    adapter = _synthetic_adapter()
    cands = _synthetic_w12c_candidates()
    per_bg = _synthetic_w12c_per_bg()

    # Source bundle has two scenes / two shots; only Scene 1 / S1_Shot1 is
    # connected to FP_T via BGT.applies_to_shots.
    source_shots = [
        {"shot_key": "S1_Shot1", "scene_index": 1, "shot_index": 1,
         "shot_description": "target shot desc",
         "screenplay_scene_heading": "S#1.",
         "scene_summary": "target summary",
         "still_frame_prompt": "frame target",
         "t2i_prompt_cinematic": "cinematic target",
         "visible_entities_json": "[]",
         "t2i_variations_json": json.dumps([
             {"variant_label": "var_1",
              "source_facts": [{"id": "sf1"}],
              "visual_inferences": [{"id": "vi1"}],
              "owned_object_usage": [{"id": "oo1", "usage_kind": "anchor"}],
              "applied_frame_spatial_constraint_ids": ["c1"],
              "reference_phrase_kinds": ["k1"]},
         ])},
        {"shot_key": "S2_Shot1", "scene_index": 2, "shot_index": 1,
         "shot_description": "non-target shot desc",
         "screenplay_scene_heading": "S#2.",
         "scene_summary": "non-target summary",
         "still_frame_prompt": "",
         "t2i_variations_json": "INVALID_JSON_STRING"},
    ]
    scene_save = _synthetic_scene_save()
    fp_baseline = _synthetic_fp_baseline()

    bundle = _build_source_evidence_bundle(
        target_fp_ids={"FP_T"},
        w12c_candidates=cands, w12c_per_bg=per_bg,
        adapter=adapter, selected_shots=source_shots,
        scene_save=scene_save, production_fp_context=fp_baseline,
    )

    # target_shot_keys covers only the target bg's applies_to_shots.
    assert bundle["target_shot_keys"] == ["S1_Shot1"]
    # target_selected_shots carries raw rows (no summarization) for the
    # target shot only.
    assert len(bundle["target_selected_shots"]) == 1
    raw_shot = bundle["target_selected_shots"][0]
    assert raw_shot["shot_key"] == "S1_Shot1"
    assert raw_shot["screenplay_scene_heading"] == "S#1."
    assert raw_shot["t2i_variations_json"].startswith("[")  # raw string carried
    # target_scene_segments has only Scene 1 full text; Scene 2 excluded.
    assert len(bundle["target_scene_segments"]) == 1
    assert bundle["target_scene_segments"][0]["scene_index"] == 1
    assert "synthetic scene 1 text" in bundle["target_scene_segments"][0]["text"]

    # parsed_t2i_anchors_by_shot is keyed by shot_key and only carries
    # the LLM-relevant fields (no full t2i variant dump).
    anchors = bundle["parsed_t2i_anchors_by_shot"]
    assert set(anchors.keys()) == {"S1_Shot1"}
    assert len(anchors["S1_Shot1"]) == 1
    anchor_entry = anchors["S1_Shot1"][0]
    # Kept fields are present; non-listed fields (e.g. t2i_prompt) are not.
    for key in ("source_facts", "visual_inferences", "owned_object_usage",
                "applied_frame_spatial_constraint_ids",
                "reference_phrase_kinds", "variant_label"):
        assert key in anchor_entry
    assert "t2i_prompt" not in anchor_entry

    # baseline_soft_evidence carries production fp prompt AND W12c
    # candidate baseline AND W12c per_bg baseline (all named soft so the
    # LLM treats source as winning on conflict).
    bse = bundle["baseline_soft_evidence"]
    assert "production_floor_plan_prompt" in bse
    assert "w12c_candidate_floor_plans_for_target" in bse
    assert "w12c_per_bg_render_reference_instructions_for_target" in bse
    assert "FP_T" in bse["w12c_candidate_floor_plans_for_target"]
    assert "BGT" in bse["w12c_per_bg_render_reference_instructions_for_target"]

    # A second source row with bad t2i_variations_json (the non-target
    # row) must not appear in diagnostics because it is not a target shot;
    # only target shot parse outcomes are tracked.
    diag = bundle["anchor_parse_diagnostics"]
    assert all(d.get("shot_key") != "S2_Shot1" for d in diag)

    # Dynamic character-name collector: only entities whose `short_id`
    # begins with "C" (character namespace) contribute to the per-run
    # render-safety guard. L*/P* are excluded so they cannot
    # accidentally fail render surfaces that legitimately reference
    # locations or props.
    source_shots_with_entities = [
        {"shot_key": "S1_Shot1", "scene_index": 1, "shot_index": 1,
         "shot_description": "x", "screenplay_scene_heading": "S#1.",
         "scene_summary": "", "still_frame_prompt": "",
         "t2i_variations_json": "[]",
         "visible_entities_json": json.dumps([
             {"short_id": "C01", "id": "uuid1",
              "entity_name": "Character Alpha"},
             {"short_id": "C02", "id": "uuid2",
              "entity_name": "Character Beta"},
             {"short_id": "L99", "id": "uuid3",
              "entity_name": "Synthetic Location"},
             {"short_id": "P01", "id": "uuid4",
              "entity_name": "Synthetic Prop"},
         ])},
    ]
    bundle_chr = _build_source_evidence_bundle(
        target_fp_ids={"FP_T"},
        w12c_candidates=cands, w12c_per_bg=per_bg,
        adapter=adapter, selected_shots=source_shots_with_entities,
        scene_save=scene_save, production_fp_context=fp_baseline,
    )
    chr_names = bundle_chr["character_entity_names_from_visible_entities"]
    assert set(chr_names) == {"Character Alpha", "Character Beta"}
    assert "Synthetic Location" not in chr_names
    assert "Synthetic Prop" not in chr_names


def test_w15_evidence_refs_resolver_accepts_valid_and_rejects_invalid():
    """`source_ref` of form `scene:N` must be in scene_save and `shot_key`
    form must be in selected_shots. Invalid refs flagged."""
    from experiment_floor_plan_topology_slice import _resolve_evidence_refs

    scene_idx_set = {1, 2}
    shot_keys = {"S1_Shot1", "S2_Shot1"}

    refs_ok = [
        {"source_ref": "scene:1"},
        {"source_ref": "scene:2"},
        {"source_ref": "S1_Shot1"},
        {"source_ref": "S2_Shot1"},
        "S1_Shot1",
    ]
    resolved_ok, unresolved_ok = _resolve_evidence_refs(refs_ok, scene_idx_set, shot_keys)
    assert len(resolved_ok) == 5
    assert unresolved_ok == []

    refs_bad = [
        {"source_ref": "scene:99"},
        {"source_ref": "S_does_not_exist"},
        {"source_ref": "scene:"},
        {"source_ref": ""},
    ]
    resolved_bad, unresolved_bad = _resolve_evidence_refs(refs_bad, scene_idx_set, shot_keys)
    assert resolved_bad == []
    assert len(unresolved_bad) == 4


def test_w15_non_target_fp_copy_through_unchanged():
    """`floor_plan_prompt_candidate.json` for a non-target fp must equal the
    W12c entry byte-for-byte (no LLM revision applied)."""
    from experiment_floor_plan_topology_slice import (
        _merge_revised_candidates_with_w12c_copy,
    )
    w12c = _synthetic_w12c_candidates()
    llm = _synthetic_llm_output()
    merged = _merge_revised_candidates_with_w12c_copy(
        w12c_candidates=w12c,
        llm_revised=llm.get("candidate_floor_plans") or {},
        target_fp_ids={"FP_T"},
    )
    # target is the revised one
    assert merged["FP_T"]["candidate_diagram_t2i_prompt"] == "revised target plan with two enclosed rooms"
    assert len(merged["FP_T"]["candidate_numbered_elements"]) == 2
    # non-target is the W12c copy
    assert merged["FP_NT"] == w12c["FP_NT"]


def test_w15_target_bg_coverage_in_bindings(tmp_path):
    """Every bg whose fp_id is in target_fp_ids must have a `bg_unit_bindings`
    entry. Missing → invariant fails."""
    from experiment_floor_plan_topology_slice import (
        _build_w15_compatibility_report,
    )
    per_bg = _synthetic_w12c_per_bg()
    cands = _synthetic_w12c_candidates()
    llm = _synthetic_llm_output()
    # Happy case.
    rep = _build_w15_compatibility_report(
        w12c_per_bg=per_bg,
        w12c_candidates=cands,
        revised_candidates=_synthetic_llm_output()["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2},
        shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True,
        db_write_count=0,
        image_import_seen=False,
        missing_inputs=[],
        stage_status="generated",
        prev_run_id="fake",
    )
    inv = rep["invariants"]
    assert inv["bg_unit_bindings_cover_target_bgs"]["pass"] is True
    detail = inv["bg_unit_bindings_cover_target_bgs"]["detail"]
    assert detail["target_bg_count"] == 1
    assert detail["uncovered_bgs"] == []

    # Drop bg binding → fails.
    llm_bad = json.loads(json.dumps(llm))
    llm_bad["bg_unit_bindings"].pop("BGT")
    rep_bad = _build_w15_compatibility_report(
        w12c_per_bg=per_bg,
        w12c_candidates=cands,
        revised_candidates=llm_bad["candidate_floor_plans"],
        topology_by_fp=llm_bad["source_topology_by_fp"],
        bg_unit_bindings=llm_bad["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2},
        shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True,
        db_write_count=0,
        image_import_seen=False,
        missing_inputs=[],
        stage_status="generated",
        prev_run_id="fake",
    )
    assert rep_bad["invariants"]["bg_unit_bindings_cover_target_bgs"]["pass"] is False
    assert "BGT" in rep_bad["invariants"]["bg_unit_bindings_cover_target_bgs"]["detail"]["uncovered_bgs"]


def test_w15_no_image_api_call_and_production_guard():
    """All five `production_diff_zero...` style guards plus the loose
    `topology_room_count_self_consistent` invariant. self-consistency is
    NOT a strict equality with do_not_collapse_units length; it only
    requires:
      (a) room_count_assessment.standalone_enclosed_room_count ==
          count(spatial_units with is_enclosed_room=true)
      (b) every do_not_collapse_units id exists in spatial_units
      (c) every non-enclosed do_not_collapse unit has evidence."""
    from experiment_floor_plan_topology_slice import (
        _build_w15_compatibility_report,
    )
    per_bg = _synthetic_w12c_per_bg()
    cands = _synthetic_w12c_candidates()
    llm = _synthetic_llm_output()
    rep_ok = _build_w15_compatibility_report(
        w12c_per_bg=per_bg,
        w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2},
        shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True,
        db_write_count=0,
        image_import_seen=False,
        missing_inputs=[],
        stage_status="generated",
        prev_run_id="fake",
    )
    inv = rep_ok["invariants"]
    assert inv["inputs_present"]["pass"] is True
    assert inv["target_fp_covered"]["pass"] is True
    assert inv["evidence_refs_resolve"]["pass"] is True
    assert inv["candidate_elements_present"]["pass"] is True
    assert inv["topology_room_count_self_consistent"]["pass"] is True
    assert inv["render_surface_no_proper_noun_or_entity_instance"]["pass"] is True
    assert inv["production_diff_zero_db_write_zero_image_api_call_zero"]["pass"] is True
    assert rep_ok["all_pass"] is True

    # image_import_seen → guard fails.
    rep_img = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=True, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
    )
    assert rep_img["invariants"]["production_diff_zero_db_write_zero_image_api_call_zero"]["pass"] is False

    # production_diff dirty → guard fails.
    rep_prod = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=False, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
    )
    assert rep_prod["invariants"]["production_diff_zero_db_write_zero_image_api_call_zero"]["pass"] is False

    # Self-consistency: mismatch standalone_enclosed_room_count with
    # actual enclosed unit count → (a) fails.
    llm_bad_count = json.loads(json.dumps(llm))
    llm_bad_count["source_topology_by_fp"]["FP_T"]["room_count_assessment"]["standalone_enclosed_room_count"] = 5
    rep_bad_count = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm_bad_count["candidate_floor_plans"],
        topology_by_fp=llm_bad_count["source_topology_by_fp"],
        bg_unit_bindings=llm_bad_count["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
    )
    assert rep_bad_count["invariants"]["topology_room_count_self_consistent"]["pass"] is False

    # do_not_collapse_units id not in spatial_units → (b) fails.
    llm_bad_b = json.loads(json.dumps(llm))
    llm_bad_b["source_topology_by_fp"]["FP_T"]["do_not_collapse_units"].append(
        {"unit_id": "U_ghost", "reason": "missing unit",
         "evidence_refs": [{"source_ref": "scene:1", "quote": "x", "field": "x"}]}
    )
    rep_bad_b = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm_bad_b["candidate_floor_plans"],
        topology_by_fp=llm_bad_b["source_topology_by_fp"],
        bg_unit_bindings=llm_bad_b["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
    )
    assert rep_bad_b["invariants"]["topology_room_count_self_consistent"]["pass"] is False

    # do_not_collapse_units entry for non-enclosed unit without evidence_refs → (c) fails.
    llm_bad_c = json.loads(json.dumps(llm))
    llm_bad_c["source_topology_by_fp"]["FP_T"]["do_not_collapse_units"].append(
        {"unit_id": "U_entry", "reason": "", "evidence_refs": []}
    )
    rep_bad_c = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm_bad_c["candidate_floor_plans"],
        topology_by_fp=llm_bad_c["source_topology_by_fp"],
        bg_unit_bindings=llm_bad_c["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
    )
    assert rep_bad_c["invariants"]["topology_room_count_self_consistent"]["pass"] is False

    # Render-safety guard, dynamic character term: when the evidence
    # bundle carries a character name (synthetic `Character Alpha`),
    # using that exact name in the candidate prompt must fail.
    synthetic_character_names = ["Character Alpha", "Character Beta"]
    llm_proper = json.loads(json.dumps(llm))
    llm_proper["candidate_floor_plans"]["FP_T"]["candidate_diagram_t2i_prompt"] = (
        "schematic plan with Character Alpha's bedroom on the east"
    )
    rep_proper = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm_proper["candidate_floor_plans"],
        topology_by_fp=llm_proper["source_topology_by_fp"],
        bg_unit_bindings=llm_proper["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        forbidden_character_terms=synthetic_character_names,
    )
    assert rep_proper["invariants"]["render_surface_no_proper_noun_or_entity_instance"]["pass"] is False

    # Same render-safety guard, static generic entity-instance term:
    # putting "body" in a candidate label fails regardless of whether the
    # dynamic character list is empty.
    llm_body = json.loads(json.dumps(llm))
    llm_body["candidate_floor_plans"]["FP_T"]["candidate_numbered_elements"][0]["label"] = (
        "bo" + "dy and red stain zone"
    )
    rep_body = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm_body["candidate_floor_plans"],
        topology_by_fp=llm_body["source_topology_by_fp"],
        bg_unit_bindings=llm_body["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        forbidden_character_terms=[],
    )
    assert rep_body["invariants"]["render_surface_no_proper_noun_or_entity_instance"]["pass"] is False

    # Per-bg render appendix carrying a dynamic character name → fails.
    revised_per_bg_bad = json.loads(json.dumps(llm["per_bg_render_reference_instructions"]))
    revised_per_bg_bad["BGT"]["render_prompt_appendix"] = (
        "axis=ax. focus on Character Beta's room"
    )
    rep_perbg_bad = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=revised_per_bg_bad,
        forbidden_character_terms=synthetic_character_names,
    )
    assert rep_perbg_bad["invariants"]["render_surface_no_proper_noun_or_entity_instance"]["pass"] is False

    # When the evidence bundle has no character terms, a render surface
    # that happens to mention `Character Alpha` is NOT flagged — proves
    # the guard is fully driven by run-time evidence, not hardcoded.
    rep_neutral = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm_proper["candidate_floor_plans"],
        topology_by_fp=llm_proper["source_topology_by_fp"],
        bg_unit_bindings=llm_proper["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        forbidden_character_terms=[],
    )
    assert rep_neutral["invariants"]["render_surface_no_proper_noun_or_entity_instance"]["pass"] is True


def test_w15_consistency_audit_4_sub_checks():
    """W15d: when `consistency_audit` is provided as a non-empty dict and
    stage_status='generated', the new invariant
    `scene_shot_consistency_audit_resolves` runs four sub-checks:
      (a) audit covers every target scene and shot,
      (b) every unit_id referenced in audit exists in topology,
      (c) every numbered element referenced in audit exists in revised
          candidate for the right fp,
      (d) every multi-unit bg whose use-numbers don't cover all bound
          units must be reported with non-ok status in the audit."""
    from experiment_floor_plan_topology_slice import (
        _build_w15_compatibility_report,
    )
    per_bg = _synthetic_w12c_per_bg()
    cands = _synthetic_w12c_candidates()
    llm = _synthetic_llm_output()
    audit_ok = llm["scene_shot_consistency_audit"]

    rep_ok = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_ok,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
    )
    inv = rep_ok["invariants"]
    assert inv["scene_shot_consistency_audit_resolves"]["pass"] is True
    detail = inv["scene_shot_consistency_audit_resolves"]["detail"]
    for k in ("covers_all_target_scenes_and_shots",
              "audit_unit_ids_exist_in_topology",
              "audit_numbered_elements_exist_in_candidate",
              "bg_instruction_missing_units_reported"):
        assert detail[k]["pass"] is True

    # Sub (a) fail: audit missing a target shot.
    audit_a = json.loads(json.dumps(audit_ok))
    audit_a["shot_unit_claims"] = []
    rep_a = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_a,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
    )
    assert rep_a["invariants"]["scene_shot_consistency_audit_resolves"]["pass"] is False
    assert "S1_Shot1" in (
        rep_a["invariants"]["scene_shot_consistency_audit_resolves"]
        ["detail"]["covers_all_target_scenes_and_shots"]["missing_shots_in_audit"]
    )

    # Sub (b) fail: scene_unit_claims references an unknown unit_id.
    audit_b = json.loads(json.dumps(audit_ok))
    audit_b["scene_unit_claims"][0]["relevant_unit_ids"].append("U_ghost")
    rep_b = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_b,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
    )
    assert rep_b["invariants"]["scene_shot_consistency_audit_resolves"]["pass"] is False

    # Sub (c) fail: shot_unit_claims references a number not in revised
    # candidate.
    audit_c = json.loads(json.dumps(audit_ok))
    audit_c["shot_unit_claims"][0]["relevant_numbered_elements"].append(999)
    rep_c = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_c,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
    )
    assert rep_c["invariants"]["scene_shot_consistency_audit_resolves"]["pass"] is False

    # Sub (d) fail: bg has multiple PRIMARY units in its binding, but
    # per_bg `use_numbered_elements` covers only one of them, AND audit
    # silently reports status='ok' without listing the uncovered primary.
    # secondary-only uncovered units are diagnostic and do NOT fail sub (d).
    revised_per_bg_partial = json.loads(json.dumps(llm["per_bg_render_reference_instructions"]))
    revised_per_bg_partial["BGT"]["use_numbered_elements"] = [1]  # drop #2
    bg_bindings_two_primary = json.loads(json.dumps(llm["bg_unit_bindings"]))
    # Promote U_room_a from secondary to a second primary so coverage must
    # include it. Now use=[1] (only U_living) leaves U_room_a primary uncovered.
    bg_bindings_two_primary["BGT"]["primary_unit_ids"] = ["U_living", "U_room_a"]
    bg_bindings_two_primary["BGT"]["secondary_visible_unit_ids"] = []
    audit_d = json.loads(json.dumps(audit_ok))
    audit_d["bg_instruction_consistency"][0]["status"] = "ok"
    audit_d["bg_instruction_consistency"][0]["missing_unit_ids"] = []
    rep_d = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=bg_bindings_two_primary,
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=revised_per_bg_partial,
        consistency_audit=audit_d,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
    )
    assert rep_d["invariants"]["scene_shot_consistency_audit_resolves"]["pass"] is False
    detail_d = rep_d["invariants"]["scene_shot_consistency_audit_resolves"]["detail"]
    assert detail_d["bg_instruction_missing_units_reported"]["pass"] is False

    # Secondary-only missing coverage does NOT fail sub (d); it lives in
    # `secondary_unit_coverage_diagnostics` only.
    revised_per_bg_sec = json.loads(json.dumps(llm["per_bg_render_reference_instructions"]))
    revised_per_bg_sec["BGT"]["use_numbered_elements"] = [1]  # only U_living
    bg_bindings_sec = json.loads(json.dumps(llm["bg_unit_bindings"]))
    # primary U_living is covered, secondary U_room_a is not.
    bg_bindings_sec["BGT"]["primary_unit_ids"] = ["U_living"]
    bg_bindings_sec["BGT"]["secondary_visible_unit_ids"] = ["U_room_a"]
    audit_sec = json.loads(json.dumps(audit_ok))
    audit_sec["bg_instruction_consistency"][0]["status"] = "ok"
    rep_sec = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=bg_bindings_sec,
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=revised_per_bg_sec,
        consistency_audit=audit_sec,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
    )
    assert rep_sec["invariants"]["scene_shot_consistency_audit_resolves"]["pass"] is True
    sec_diag = (
        rep_sec["invariants"]["scene_shot_consistency_audit_resolves"]
        ["detail"]["bg_instruction_missing_units_reported"]
        ["secondary_unit_coverage_diagnostics"]
    )
    assert any(d.get("bg_id") == "BGT" and "U_room_a" in d.get("missing_secondary_unit_ids", [])
               for d in sec_diag)


def test_w15e_element_placement_and_unit_scale_audit_invariant():
    """W15e: when `placement_scale_audit` is a non-empty dict and
    stage_status='generated', the new invariant runs three structural
    sub-checks:
      (a) unit_scale_constraints covers every topology spatial_unit
      (b) element_placement_constraints covers every candidate number
          (target fps only) and references resolve into topology units
      (c) every element whose unit_id_pointer is part of any
          open_connection pair must set avoid_open_connection_boundary
    None / empty-dict modes follow the same legacy-skip contract as the
    consistency audit."""
    from experiment_floor_plan_topology_slice import (
        _build_w15_compatibility_report,
    )
    per_bg = _synthetic_w12c_per_bg()
    cands = _synthetic_w12c_candidates()
    llm = _synthetic_llm_output()
    audit_consistency = llm["scene_shot_consistency_audit"]

    # Synthetic placement audit: every spatial_unit covered + every
    # revised candidate number covered + open-connection unit has
    # avoid flag.
    placement_ok = {
        "unit_scale_constraints": [
            {"unit_id": "U_living", "enclosure_mode": "open_zone_inside_plan",
             "relative_scale_hint": "primary_zone",
             "open_connection_behavior": "continuous floor with adjacent zone",
             "render_notes": "compact"},
            {"unit_id": "U_room_a", "enclosure_mode": "full_wall_enclosed_room",
             "relative_scale_hint": "secondary_zone",
             "open_connection_behavior": "n/a", "render_notes": "compact"},
            {"unit_id": "U_entry", "enclosure_mode": "interior_threshold",
             "relative_scale_hint": "compact_nook_or_wall_run",
             "open_connection_behavior": "no partition",
             "render_notes": "compact"},
        ],
        "element_placement_constraints": [
            {"number": 1, "fp_id": "FP_T", "unit_id_pointer": "U_living",
             "category": "area",
             "placement_mode": "area_label_only",
             "anchor_surface_hint": "interior floor center",
             "avoid_open_connection_boundary": True,
             "must_not_form_boundary": True,
             "revised_position_hint": "interior floor center",
             "conflict_note": ""},
            {"number": 2, "fp_id": "FP_T", "unit_id_pointer": "U_room_a",
             "category": "area",
             "placement_mode": "area_label_only",
             "anchor_surface_hint": "interior floor center",
             "avoid_open_connection_boundary": False,
             "must_not_form_boundary": True,
             "revised_position_hint": "interior floor center",
             "conflict_note": ""},
        ],
    }

    rep_ok = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_consistency,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
        placement_scale_audit=placement_ok,
    )
    inv = rep_ok["invariants"]["element_placement_and_unit_scale_audit_resolves"]
    assert inv["pass"] is True

    # Sub (a) fail: drop a unit from unit_scale_constraints.
    placement_a = json.loads(json.dumps(placement_ok))
    placement_a["unit_scale_constraints"].pop()
    rep_a = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_consistency,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
        placement_scale_audit=placement_a,
    )
    assert rep_a["invariants"]["element_placement_and_unit_scale_audit_resolves"]["pass"] is False

    # Sub (b) fail: a placement constraint references an unknown number.
    placement_b = json.loads(json.dumps(placement_ok))
    placement_b["element_placement_constraints"].append({
        "number": 99, "fp_id": "FP_T", "unit_id_pointer": "U_living",
        "category": "furniture",
        "placement_mode": "interior_floor_furniture",
        "anchor_surface_hint": "interior", "avoid_open_connection_boundary": True,
        "must_not_form_boundary": True, "revised_position_hint": "interior",
        "conflict_note": "",
    })
    rep_b = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_consistency,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
        placement_scale_audit=placement_b,
    )
    assert rep_b["invariants"]["element_placement_and_unit_scale_audit_resolves"]["pass"] is False

    # Sub (c) fail: furniture / fixture / plot-device element on an
    # open-connection unit without avoid_open_connection_boundary=True.
    # Add an open_connection relationship to the synthetic topology so
    # U_living becomes part of an open-connection pair. Area labels are
    # exempt, so mutate the constraint into a furniture placement.
    topo_open = json.loads(json.dumps(llm["source_topology_by_fp"]))
    topo_open["FP_T"]["relationships"].append({
        "from_unit": "U_living", "to_unit": "U_entry",
        "relation_kind": "open_connection",
    })
    placement_c = json.loads(json.dumps(placement_ok))
    placement_c["element_placement_constraints"][0]["category"] = "furniture"
    placement_c["element_placement_constraints"][0]["placement_mode"] = "wall_fixture"
    placement_c["element_placement_constraints"][0]["avoid_open_connection_boundary"] = False
    rep_c = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=topo_open,
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_consistency,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
        placement_scale_audit=placement_c,
    )
    assert rep_c["invariants"]["element_placement_and_unit_scale_audit_resolves"]["pass"] is False

    # placement_scale_audit=None → legacy auto-pass.
    rep_legacy = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1}, shot_keys={"S1_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
        revised_per_bg=llm["per_bg_render_reference_instructions"],
        consistency_audit=audit_consistency,
        target_scene_idx_set={1}, target_shot_keys_set={"S1_Shot1"},
        placement_scale_audit=None,
    )
    assert rep_legacy["invariants"]["element_placement_and_unit_scale_audit_resolves"]["pass"] is True


def test_w15_html_section_order_and_methodology_grep(tmp_path):
    """HTML must put source topology section before the revised candidate,
    revised candidate before per-bg bindings, and raw JSON inside collapsed
    <details>. Methodology grep on script source must not contain narrative
    tokens (per-char split, this test file is exempt)."""
    from experiment_floor_plan_topology_slice import (
        _build_w15_compatibility_report, _render_w15_html,
    )
    per_bg = _synthetic_w12c_per_bg()
    cands = _synthetic_w12c_candidates()
    llm = _synthetic_llm_output()
    rep = _build_w15_compatibility_report(
        w12c_per_bg=per_bg, w12c_candidates=cands,
        revised_candidates=llm["candidate_floor_plans"],
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        target_fp_ids={"FP_T"},
        scene_idx_set={1, 2}, shot_keys={"S1_Shot1", "S2_Shot1"},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        stage_status="generated", prev_run_id="fake",
    )
    run_meta = {
        "run_id": "RID1", "stage": "w15_floor_plan_topology_slice",
        "run_status": "succeeded", "exit_code": 0,
        "derived_from": "fakeW12c", "model_used": "gemini-3.5-flash",
        "image_generation_count": 0, "image_generation_backend": "gpt-image-2",
        "stage_status": "generated",
        "target_fp_ids": ["FP_T"],
    }
    _render_w15_html(
        run_meta=run_meta,
        topology_by_fp=llm["source_topology_by_fp"],
        bg_unit_bindings=llm["bg_unit_bindings"],
        revised_candidates=llm["candidate_floor_plans"],
        per_bg_revised=llm["per_bg_render_reference_instructions"],
        report=rep, run_dir=tmp_path,
    )
    html = (tmp_path / "index.html").read_text()

    topology_pos = html.find("Source topology")
    revised_pos = html.find("Revised candidate floor plans")
    per_bg_pos = html.find("Per-bg revised reference instructions")
    inv_pos = html.find("Invariants")
    raw_pos = html.find("raw run_meta")

    assert topology_pos > 0
    assert revised_pos > topology_pos
    assert per_bg_pos > revised_pos
    assert inv_pos > per_bg_pos
    assert raw_pos > 0
    details_pos = html.rfind("<details>", 0, raw_pos)
    assert details_pos > 0 and details_pos < raw_pos

    # Methodology grep: scenario-specific tokens only (proper nouns from
    # this specific episode and hardcoded room-count assumptions). Generic
    # floor-plan vocabulary like `kitchen_zone` or `bedroom` is allowed in
    # universal schema enums. Tokens are assembled per-char so this
    # assertion source does not match its own regex.
    script_path = _SCRIPTS_DIR / "experiment_floor_plan_topology_slice.py"
    forbidden_tokens = [
        "L0" + "5", "옥" + "탑방", "안" + "방", "수" + "리영", "민" + "숙",
        "혜" + "수", "김형" + "사",
        "one-" + "bedroom", "two-" + "bedroom", "one-" + "room",
        "two-" + "room",
    ]
    pattern = re.compile("(" + "|".join(re.escape(t) for t in forbidden_tokens) + ")")
    m = pattern.search(script_path.read_text())
    assert m is None, m.group(0) if m else ""
