"""s22 — 코드 렌더 BD v3: 관계 데이터 + 결정론 솔버/검증 (2026-07-07).

사용자 질문 "코드로 bd 를 만들 때 LLM 이 데이터를 제대로 만들어 줄까?"
→ 2026 연구 조사 결론을 반영한 재설계 (s13 v2 는 LLM 절대좌표 직출 = 연구가
지목하는 실패 모드):
  - SpatialGrammar (arXiv 2604.27555, 2026-04): raw JSON 좌표 충돌 66.7% →
    저차원 DSL 13.6% → 결정론 컴파일러의 구조화 에러 피드백 루프 0%.
  - RLVR 도면 (arXiv 2605.14117, 2026-05): 최상위 LLM 도 few-shot 좌표 JSON
    비일관 → 결정론 검증+수리 루프 필수.
  - Holodeck (CVPR'24): LLM=공간 관계만, 좌표=솔버 (의미↔기하 분리).
  - PSDL (arXiv 2510.16147): 위치=선행 요소 기준 상대 표현식.
설계:
  ① LLM(gpt) = 씬 원문 전체+멤버+샷+CREATOR CORRECTIONS → **관계 데이터만**
     (parent 포함 + 부모 내 9방위 anchor + 부모 대비 크기% + 부착 변 +
     변 위 위치%) — 절대 좌표 0.
  ② 결정론 솔버 = 부모 트리 순회로 좌표 계산 (100×100 캔버스).
  ③ 결정론 검증 = 겹침/포함/부착/개구부 위반 → 구조화 메시지.
  ④ bounded 수리(≤2회) = 위반 메시지를 LLM 에 되먹여 관계 데이터 수정.
  ⑤ 렌더 = s13 결정론 PIL 렌더러 **그대로 재사용** (마커/레이어 색/범례).
사용: backend/.venv/bin/python s22_bd_code.py [--only all|llm|repair|html]
산출: plans/bd_code_rel_v1.json(+rounds), out/blockset/bd_code_r*.png,
      bd_code.html (8897)
"""
import argparse
import html as _html
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
import forest_lib as F  # noqa: E402
import s13_blockset_v2 as S13  # noqa: E402  (렌더러/마커/입력 직렬화 재사용)
import s14_blockgen as S14  # noqa: E402  (사용자 정정 override 재사용)

OUTB = F.OUT / "blockset"
PAGE = F.EXP / "bd_code.html"
ANCHORS = ["NW", "N", "NE", "W", "C", "E", "SW", "S", "SE"]
SIDES = ["none", "N", "S", "E", "W"]

REL_SYSTEM = """You are a set-layout analyst for film pre-production. You get one
real-world place's member descriptions, the FULL ORIGINAL TEXT of every scene
that plays there, all shots, and creator corrections (which override any
conflicting text). Build ONE top-down BLOCK LAYOUT of the whole property as
RELATIONAL data for a deterministic solver — you never output coordinates;
you only state what contains what, where inside its container, how big
relative to its container, and what is attached to which side.

Placement model (the solver computes all geometry from this):
- Every element names a parent: "canvas" (the whole 100x100 site, canvas top
  = the property's rear, canvas bottom = the street/approach side unless the
  texts imply otherwise — state your choice in layout_notes_ko) or another
  element's id (true containment: the child sits ON/INSIDE the parent area).
- anchor: one of NW,N,NE,W,C,E,SW,S,SE — where the element sits INSIDE its
  parent.
- w_pct,h_pct: the element's width/height as a PERCENT of its parent's
  width/height (5..100). For shape "circle", w_pct alone sets the diameter
  as a percent of the parent's smaller side.
- attach_side: none, or N/S/E/W — the element is snapped flush against that
  side of its parent (stairs on an outside wall, walls on an edge, and ALL
  openings). along_pct (0..100) positions it along that side (left→right or
  top→bottom); use it whenever attach_side is not "none".
- Openings (door/window/gate) MUST have attach_side = the wall side of their
  host parent that the texts support, and repeat = how many identical
  openings the strip contains.

Element kinds: building_mass, upper_shell (enclosed upper unit — outline
only, never interior), terrace, yard, road, alley, stair, wall, neighbor_mass
(BLOCK kinds) and door, window, gate, fixture_round, fixture_rect, equipment
(POINT kinds).
- ids: short lowercase slugs, unique.
- Simplify ALL neighbouring buildings to plain neighbor_mass rects (parent =
  canvas, no openings). Include the road/alley approach.
- COUNT-CRITICAL: opening counts/sides must match the texts; if the texts
  give no exact count choose a modest count and set certainty="inferred".
- EXTERIOR ONLY: this is an OUTDOOR site diagram — ground areas, building
  outer shells, roof-level surfaces, OUTDOOR fixtures, and boundary openings
  (doors/windows/gates in OUTER walls) only. NEVER include interior
  furniture, appliances or fixtures of any enclosed unit (no beds, tables,
  stoves, counters, mirrors, lamps, TVs); every enclosed volume stays
  sealed — outline and outer-wall openings only.
- Include permanent OUTDOOR fixtures the scenes rely on. NO story props, no
  vehicles, no people, nothing temporary.
- 10 to 26 elements. name_en: 1-3 generic English words, no proper names.
  desc_ko: one short Korean sentence — what it PHYSICALLY is, its form and
  key spatial relation, present tense, PHYSICAL VISIBLE FACTS ONLY: never
  events, actions, characters or animals, usage stories, moments of motion,
  or authorial phrasing (verbatim shown to the human reviewer).
- evidence: source = scene index / shot key / member id, quote = exact
  phrase copied from the given texts. certainty="text" needs a real quote.

HEIGHT LAYERS (3-6): you define them; each = layer_id, name_en, desc_ko (one
Korean sentence: what physical height), rel_height (0 = ground, increasing
upward). Every element references one layer_id.

Solid blocks that share a parent must not overlap — place siblings at
different anchors with sizes that leave room (the solver will report any
overlap back to you for correction).

connections: pairs of element ids that the texts say PHYSICALLY adjoin or
connect — a stair to the ground area it rises from AND to the level it
serves, a gate between the two areas it joins, an alley to the yard/road it
links. why_ko = one short Korean line quoting-level grounding. The solver
verifies each pair actually touches (gap <= 1 unit) and reports failures —
so place connected elements so they really meet (matching anchors/sides).

Also output layout_notes_ko: 3-6 Korean sentences on the arrangement logic
(verbatim shown to the human reviewer)."""

_EVIDENCE = {"type": "array", "items": {"type": "object", "properties": {
    "source": {"type": "string"}, "quote": {"type": "string"}},
    "required": ["source", "quote"], "additionalProperties": False}}

REL_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "properties": {
        "layers": {"type": "array", "items": {"type": "object", "properties": {
            "layer_id": {"type": "string"}, "name_en": {"type": "string"},
            "desc_ko": {"type": "string"}, "rel_height": {"type": "number"}},
            "required": ["layer_id", "name_en", "desc_ko", "rel_height"],
            "additionalProperties": False}},
        "elements": {"type": "array", "items": {"type": "object", "properties": {
            "id": {"type": "string"},
            "name_en": {"type": "string"},
            "desc_ko": {"type": "string"},
            "kind": {"type": "string", "enum": S13.KINDS},
            "layer_id": {"type": "string"},
            "shape": {"type": "string", "enum": ["rect", "circle"]},
            "parent": {"type": "string"},
            "anchor": {"type": "string", "enum": ANCHORS},
            "w_pct": {"type": "number"}, "h_pct": {"type": "number"},
            "attach_side": {"type": "string", "enum": SIDES},
            "along_pct": {"type": "number"},
            "repeat": {"type": "integer"},
            "importance": {"type": "string", "enum": ["major", "minor"]},
            "certainty": {"type": "string", "enum": ["text", "inferred"]},
            "evidence": _EVIDENCE},
            "required": ["id", "name_en", "desc_ko", "kind", "layer_id",
                         "shape", "parent", "anchor", "w_pct", "h_pct",
                         "attach_side", "along_pct", "repeat", "importance",
                         "certainty", "evidence"],
            "additionalProperties": False}},
        "connections": {"type": "array", "items": {
            "type": "object",
            "properties": {"a": {"type": "string"}, "b": {"type": "string"},
                           "why_ko": {"type": "string"}},
            "required": ["a", "b", "why_ko"],
            "additionalProperties": False}},
        "layout_notes_ko": {"type": "string"},
    },
    "required": ["layers", "elements", "connections", "layout_notes_ko"],
}

REPAIR_SYSTEM = REL_SYSTEM + """

REPAIR MODE: you previously produced the RELATIONAL data below and the
deterministic solver found the listed violations. Return the FULL corrected
data (same schema): change ONLY what is needed to resolve every violation —
adjust anchors, sizes, attach sides or containment; keep every element,
meaning, desc_ko and evidence unless a violation forces a change."""


# ── ② 결정론 솔버 — 관계 → 좌표 (LLM 판단 0) ──

def solve(rel):
    """parent 트리 순회 배치. 반환: s13 렌더러 포맷 layout + id→rect 맵."""
    rects = {"canvas": (0.0, 0.0, 100.0, 100.0)}
    order, pending = [], list(rel["elements"])
    guard = 0
    while pending and guard < 100:
        guard += 1
        rest = []
        for e in pending:
            if e["parent"] in rects:
                order.append(e)
                rects[e["id"]] = _place(e, rects[e["parent"]])
            else:
                rest.append(e)
        pending = rest
    structural = [f"STRUCTURE: '{e['id']}' has unknown/cyclic parent"
                  f" '{e['parent']}'" for e in pending]
    out_els = []
    for e in order:
        x, y, w, h = rects[e["id"]]
        s13e = {k: e[k] for k in ("name_en", "desc_ko", "kind", "layer_id",
                                  "shape", "repeat", "importance",
                                  "certainty", "evidence")}
        s13e["marker"] = ""
        s13e["rot_deg"] = 0
        if e["shape"] == "circle":
            s13e.update(x=x + w / 2, y=y + h / 2, w=w, h=h)  # s13 원=중심
        else:
            s13e.update(x=x, y=y, w=w, h=h)
        s13e["_id"] = e["id"]
        out_els.append(s13e)
    layout = {"canvas": {"width": 100, "height": 100,
                         "orientation_note": rel.get("layout_notes_ko", "")},
              "layers": rel["layers"], "elements": out_els,
              "layout_notes_ko": rel.get("layout_notes_ko", "")}
    S13._fix_markers(layout)
    return layout, rects, structural


def _place(e, parent):
    px, py, pw, ph = parent
    if e["shape"] == "circle":
        d = min(pw, ph) * max(2.0, e["w_pct"]) / 100.0
        w = h = d
    else:
        w = pw * max(2.0, e["w_pct"]) / 100.0
        h = ph * max(2.0, e["h_pct"]) / 100.0
    side = e.get("attach_side") or "none"
    along = min(100.0, max(0.0, e.get("along_pct") or 50.0)) / 100.0
    opening = e["kind"] in ("door", "window", "gate")
    if side in ("N", "S"):
        x = px + (pw - w) * along
        edge = py if side == "N" else py + ph
        y = edge - h / 2 if opening else (py if side == "N" else py + ph - h)
    elif side in ("W", "E"):
        y = py + (ph - h) * along
        edge = px if side == "W" else px + pw
        x = edge - w / 2 if opening else (px if side == "W" else px + pw - w)
    else:
        fx = {"NW": 0, "W": 0, "SW": 0, "N": .5, "C": .5, "S": .5,
              "NE": 1, "E": 1, "SE": 1}[e["anchor"]]
        fy = {"NW": 0, "N": 0, "NE": 0, "W": .5, "C": .5, "E": .5,
              "SW": 1, "S": 1, "SE": 1}[e["anchor"]]
        x, y = px + (pw - w) * fx, py + (ph - h) * fy
    return (x, y, w, h)


# ── ③ 결정론 검증 — 구조화 위반 메시지 (SpatialGrammar 패턴) ──

def validate(rel, rects, structural):
    v = list(structural)
    els = {e["id"]: e for e in rel["elements"]}

    def anc(i):
        chain = set()
        cur = i
        while cur != "canvas" and cur in els:
            cur = els[cur]["parent"]
            chain.add(cur)
        return chain

    opening = lambda e: e["kind"] in ("door", "window", "gate")
    for e in rel["elements"]:
        if e["id"] not in rects:
            continue
        x, y, w, h = rects[e["id"]]
        pxr = rects.get(e["parent"])
        if pxr and not opening(e):
            px, py, pw, ph = pxr
            if (x < px - .5 or y < py - .5 or x + w > px + pw + .5
                    or y + h > py + ph + .5):
                v.append(f"CONTAINMENT: '{e['id']}' sticks out of its parent"
                         f" '{e['parent']}' — reduce w_pct/h_pct or change"
                         f" anchor")
        if opening(e) and (e.get("attach_side") or "none") == "none":
            v.append(f"OPENING: '{e['id']}' ({e['kind']}) must have"
                     f" attach_side = its host wall side")
    ids = [e["id"] for e in rel["elements"]
           if e["kind"] in S13.BLOCK_KINDS and e["id"] in rects]
    for i, a in enumerate(ids):
        for b in ids[i + 1:]:
            if a in anc(b) or b in anc(a) or els[a]["parent"] != els[b]["parent"]:
                continue
            ax, ay, aw, ah = rects[a]
            bx, by, bw, bh = rects[b]
            ow = min(ax + aw, bx + bw) - max(ax, bx)
            oh = min(ay + ah, by + bh) - max(ay, by)
            if ow > 1 and oh > 1 and ow * oh > 4:
                v.append(f"OVERLAP: solid blocks '{a}' and '{b}' (same parent"
                         f" '{els[a]['parent']}') overlap by"
                         f" {ow * oh:.0f} sq-units — move anchors apart or"
                         f" shrink sizes")
    for c in rel.get("connections") or []:
        a, b = c["a"], c["b"]
        if a not in rects or b not in rects:
            v.append(f"CONNECTION: pair ('{a}', '{b}') references an unknown"
                     f" element id")
            continue
        ax, ay, aw, ah = rects[a]
        bx, by, bw, bh = rects[b]
        gx = max(0.0, max(bx - (ax + aw), ax - (bx + bw)))
        gy = max(0.0, max(by - (ay + ah), ay - (by + bh)))
        gap = max(gx, gy)
        if gap > 1.0:
            v.append(f"CONNECTION: '{a}' and '{b}' must physically adjoin"
                     f" ({c['why_ko']}) but are {gap:.0f} units apart —"
                     f" move/resize one of them (anchor, size or"
                     f" attach_side/along_pct) so they touch")
    return v


# ── ① 생성 + ④ bounded 수리 루프 ──

def gen_rel(recon):
    ov = S14.load_overrides()
    corrections = "\n".join(ov.get("facts_extra") or [])
    members = F.members_block(recon["members"])
    idxs, scenes = S13.scene_texts(recon)
    shot_blocks = "\n\n".join(
        F.shot_block(k, s) for k, s in sorted(recon["shots"].items()))
    user = ("PLACE MEMBERS:\n" + members
            + "\n\nFULL ORIGINAL SCENE TEXTS (every scene at this place):\n\n"
            + scenes
            + "\n\nALL SHOTS AT THIS PLACE (full content):\n\n" + shot_blocks)
    if corrections:
        user += ("\n\nCREATOR CORRECTIONS (override any conflicting text):\n"
                 + corrections)
    user += "\n\nBuild the relational block layout now."
    rel = F.llm("s22_bd_rel_v1", REL_SYSTEM, user, REL_SCHEMA, model="gpt")
    print(f"rel: layers {len(rel['layers'])} | elements"
          f" {len(rel['elements'])} | scenes {idxs}")
    return rel


def repair_rel(rel, violations):
    user = ("PREVIOUS RELATIONAL DATA:\n"
            + json.dumps(rel, ensure_ascii=False)
            + "\n\nSOLVER VIOLATIONS (each must be resolved):\n"
            + "\n".join(f"- {x}" for x in violations)
            + "\n\nReturn the full corrected data.")
    return F.llm("s22_bd_rel_repair", REPAIR_SYSTEM, user, REL_SCHEMA,
                 model="gpt")


def run(recon, max_repairs=2):
    plan = {"rounds": []}
    rel = gen_rel(recon)
    for rnd in range(max_repairs + 1):
        layout, rects, structural = solve(rel)
        violations = validate(rel, rects, structural)
        img = OUTB / f"bd_code_r{rnd}.png"
        if img.exists():
            img.rename(OUTB / f"bd_code_r{rnd}_prev.png")
        S13.render(layout, img)
        plan["rounds"].append({"round": rnd, "img": img.name,
                               "violations": violations, "rel": rel,
                               "layout": layout})
        F.save_plan("bd_code_rel_v1", plan)
        build_page(plan)
        print(f"round {rnd}: violations={len(violations)} -> {img.name}")
        for x in violations:
            print("   -", x)
        if not violations or rnd == max_repairs:
            break
        rel = repair_rel(rel, violations)
    return plan


# ── ⑤ 갤러리 (blockset.html 양식 계승) ──

def build_page(plan):
    def _esc(t):
        return _html.escape(str(t or ""))

    secs = []
    for r in plan["rounds"]:
        rel = r["rel"]
        lay_rows = "".join(
            f"<tr><td>{_esc(l['layer_id'])}</td><td>{l['rel_height']:g}</td>"
            f"<td>{_esc(l['name_en'])}</td><td>{_esc(l['desc_ko'])}</td></tr>"
            for l in sorted(rel["layers"], key=lambda l: l["rel_height"]))
        mk = {e.get("_id"): e["marker"] for e in r["layout"]["elements"]}

        def _rel_txt(e):
            t = (f"parent={e['parent']} · {e['anchor']}"
                 f" · {e['w_pct']:g}%×{e['h_pct']:g}%")
            if e["attach_side"] != "none":
                t += f" · attach {e['attach_side']} @{e['along_pct']:g}%"
            return t + f" · rep={e['repeat']} · {e['certainty']}"

        el_rows = "".join(
            f"<tr><td><b>{_esc(mk.get(e['id'], '?'))}</b></td>"
            f"<td>{_esc(e['name_en'])}<br><span class='mini'>{_esc(e['id'])}"
            f"</span></td>"
            f"<td>{_esc(e['desc_ko'])}</td>"
            f"<td class='mini'>{_esc(_rel_txt(e))}</td>"
            f"<td class='mini'>{'<br>'.join(_esc(v['source']) + ': ' + _esc(v['quote']) for v in e['evidence'])}</td></tr>"
            for e in rel["elements"])
        conns = rel.get("connections") or []
        conn_table = ""
        if conns:
            conn_rows = "".join(
                f"<tr><td>{_esc(c['a'])} ↔ {_esc(c['b'])}</td>"
                f"<td>{_esc(c['why_ko'])}</td></tr>" for c in conns)
            conn_table = ("<table><tr><th>연결 (물리 접촉 검증 대상)</th>"
                          f"<th>근거 (why_ko)</th></tr>{conn_rows}</table>")
        vio = "".join(f"<li>{_esc(x)}</li>" for x in r["violations"])
        secs.append(f"""
<h2>round {r['round']} — 검증 위반 {len(r['violations'])}건
{'(수리 후 재솔브)' if r['round'] else '(최초 생성)'}</h2>
<img src='out/blockset/{_esc(r['img'])}?v={r['round']}'>
{'<ul class=vio>' + vio + '</ul>' if vio else
 "<p class='ok'>위반 0 — 결정론 검증 통과</p>"}
<details><summary>레이어 / 요소 관계 데이터 / 근거 인용</summary>
<table><tr><th>layer</th><th>h</th><th>name</th><th>desc_ko</th></tr>
{lay_rows}</table>
<table><tr><th>마커</th><th>요소</th><th>desc_ko</th><th>관계 (LLM 산출
그대로)</th><th>근거</th></tr>{el_rows}</table>
{conn_table}
<p class='mini'>layout_notes_ko: {_esc(rel['layout_notes_ko'])}</p>
</details>
""")
    PAGE.write_text(f"""<!doctype html><meta charset='utf-8'>
<title>s22 — 코드 렌더 BD v3 (관계 데이터+결정론 솔버)</title>
<style>
body{{font-family:system-ui,'Apple SD Gothic Neo',sans-serif;margin:24px;
background:#fafafa;color:#222;max-width:1500px}}
h1{{font-size:20px}} h2{{font-size:16px;margin-top:30px;border-bottom:2px
solid #ddd;padding-bottom:4px}}
img{{width:100%;display:block;background:#fff;border:1px solid #ddd;
padding:6px;box-sizing:border-box}}
table{{border-collapse:collapse;font-size:12.5px;background:#fff;width:100%;
margin:8px 0}}
td,th{{border:1px solid #e0e0e0;padding:4px 8px;vertical-align:top}}
.mini{{font-size:11px;color:#667}}
.vio{{background:#fdecea;border:1px solid #e5b4ae;padding:8px 26px;
font-size:12.5px;color:#7a2e26}}
.ok{{background:#e8f4e8;border:1px solid #b6d7b6;padding:8px 12px;
font-size:13px;color:#2e6b2e;font-weight:600}}
.note{{background:#fff8e6;border:1px solid #eeddaa;padding:10px 14px;
font-size:13px}}
details{{margin:8px 0}} summary{{font-size:12.5px;color:#557;cursor:pointer}}
</style>
<h1>s22 — 코드 렌더 BD v3: LLM=관계 데이터만 → 결정론 솔버(좌표)+검증 →
위반 되먹임 수리(≤2회) → s13 PIL 렌더</h1>
<div class='note'>재설계 근거(2026 조사): raw 좌표 직출=충돌 66.7% →
저차원 관계 DSL 13.6% → 결정론 검증 피드백 루프 0%
(<a href='https://arxiv.org/abs/2604.27555'>SpatialGrammar 2026-04</a> ·
<a href='https://arxiv.org/abs/2605.14117'>RLVR floor plan 2026-05</a> ·
<a href='https://arxiv.org/abs/2312.09067'>Holodeck</a> ·
<a href='https://arxiv.org/abs/2510.16147'>PSDL</a>).
LLM 산출=포함(parent)/9방위(anchor)/부모 대비 크기%/부착 변+변 위 위치%,
절대 좌표 0. 좌표·마커·색·범례=결정론 코드(s13 렌더러 재사용).
씬 원문 전체+멤버+샷+CREATOR CORRECTIONS 입력, 근거 인용 필수.</div>
{''.join(secs)}
""", encoding="utf-8")
    print(f"page -> {PAGE}")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", default="all", choices=["all", "html"])
    args = ap.parse_args()
    if args.only == "html":
        build_page(F.load_plan("bd_code_rel_v1"))
        return
    recon = F.load_recon()
    run(recon)
    F.runlog({"kind": "stage", "stage": "s22_bd_code", "done": args.only})


if __name__ == "__main__":
    main()
