### Task 1: geometry 카메라 대면 관계 — 스키마 + 결정론 검증

카메라 대면 관계(뒷모습/정면/측면)를 저작 LLM 이 **선언 필드**로 내고,
코드가 좌표와 대조한다. 프롬프트 문구만으로는 틀린 facing 을 잡을 수
없다 — 실측: SHOT TEXT 가 `뒷모습`인데 저작 facing 의 카메라 축 성분이
`dep=-0.211`(부호가 오히려 카메라 쪽)이었고 `_SCREEN_EPS=0.25` 미달로
문구가 침묵했다.

**Files:**
- Modify: `backend/app/modules/pipeline/outdoor_lane_plan.py:120-123`
  (`_ws_normalize` → 공개 이름 승격)
- Modify: `backend/app/modules/pipeline/outdoor_marker_map.py:51-54`
  (팩 집합), `:79-140` (스키마), `:283-302` (validator 시그니처)
- Test: `backend/tests/pipeline/test_outdoor_marker_map.py`

**Interfaces:**
- Consumes: 없음 (첫 Task)
- Produces:
  - `CAMERA_FACING_RELATIONS: Tuple[str, ...]` =
    `("toward_camera", "away_from_camera", "profile", "unspecified")`
  - `_FACING_RELATION_GEOMETRY_PACKS: Set[str]` = `{"4"}`
  - `build_marker_geometry_schema(..., include_facing_relation: bool = False)`
  - `facing_camera_depth(cam: Dict, pl: Dict) -> Optional[float]`
  - `validate_marker_geometry(geometry, *, require_anchors=False, require_view=False, require_facing_relation: bool = False, shot_text: str = "") -> List[str]`
  - `outdoor_lane_plan.ws_normalize(text: str) -> str` (공개 별칭)

- [ ] **Step 1: `_ws_normalize` 를 공개 이름으로 승격**

`outdoor_marker_map.py` 가 재사용해야 하는데 사설 이름 교차 import 는
피한다. `outdoor_lane_plan.py` 는 모듈 레벨에 프로젝트 import 가 없어
순환 위험이 없다(확인됨).

`backend/app/modules/pipeline/outdoor_lane_plan.py:120-123` 를 이렇게
바꾼다:

```python
def ws_normalize(text: str) -> str:
    """연속 whitespace(개행 포함)→단일 공백. 원문 줄바꿈을 LLM 이 공백으로
    인용하는 정상 케이스(5회차 실측 2건) 허용 — 의미 판단 없음.

    2026-07-26: outdoor_marker_map 의 camera_facing evidence provenance 도
    같은 정규화를 쓴다 — 사설 이름 교차 import 를 피해 공개로 승격.
    """
    return re.sub(r"\s+", " ", text).strip()


# 기존 호출부·테스트 호환 별칭 (동일 객체)
_ws_normalize = ws_normalize
```

- [ ] **Step 2: 승격이 기존 동작을 깨지 않는지 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_lane_plan.py -q`
Expected: PASS (기존 통과 수 유지 — 별칭이 같은 객체라 동작 불변)

- [ ] **Step 3: 실패하는 테스트를 쓴다 — 스키마 + 3분할 경계값 + provenance**

`backend/tests/pipeline/test_outdoor_marker_map.py` 끝에 추가:

```python
SAMPLE_FIXTURE_SHOT_TEXT = (
    "한쪽 발이 공중에 뜬 달리기 자세인 인물 B의 뒷모습을 배경으로, "
    "두 손을 입가에 모으고 입을 크게 벌린 인물 A의 상반신."
)


def _fixture_geometry(relation="unspecified", evidence="",
                      facing=(0.88, 0.52)):
    """카메라가 (0.64,0.72)에서 (0.56,0.575)를 보는 기준 배치.

    fwd 는 위-왼쪽을 향한다 — facing 을 바꿔 dep 부호를 조절한다.
    """
    return {
        "camera": {
            "origin": {"x": 0.64, "y": 0.72},
            "look_target": {"x": 0.56, "y": 0.575},
            "origin_anchor_en": "On the roadway in front of the site.",
            "look_target_anchor_en": "Inside the site by the sign.",
            "view_left": {"x": 0.45, "y": 0.51},
            "view_right": {"x": 0.83, "y": 0.56},
            "view_left_anchor_en": "Inside the site left of the shelter.",
            "view_right_anchor_en": "Inside the site at its right edge.",
        },
        "entity_placements": [{
            "slot": "E1",
            "subject_en": "running figure with one foot airborne",
            "x": 0.78, "y": 0.55,
            "anchor_en": "Inside the right side of the site.",
            "facing": {"x": facing[0], "y": facing[1]},
            "facing_anchor_en": "Farther right inside the site.",
            "camera_facing_relation": relation,
            "camera_facing_evidence": evidence,
        }],
        "rationale_ko": "픽스처 배치.",
    }


def test_facing_relation_schema_is_pack_gated():
    from app.modules.pipeline.outdoor_marker_map import (
        CAMERA_FACING_RELATIONS,
        _FACING_RELATION_GEOMETRY_PACKS,
        build_marker_geometry_schema,
    )

    assert _FACING_RELATION_GEOMETRY_PACKS == {"4"}
    off = build_marker_geometry_schema(
        include_anchors=True, include_view=True)
    pl_off = off["properties"]["entity_placements"]["items"]
    assert "camera_facing_relation" not in pl_off["properties"]

    on = build_marker_geometry_schema(
        include_anchors=True, include_view=True,
        include_facing_relation=True)
    pl_on = on["properties"]["entity_placements"]["items"]
    assert pl_on["properties"]["camera_facing_relation"]["enum"] == list(
        CAMERA_FACING_RELATIONS)
    assert pl_on["properties"]["camera_facing_evidence"] == {
        "type": "string"}
    for key in ("camera_facing_relation", "camera_facing_evidence"):
        assert key in pl_on["required"]


def test_facing_camera_depth_sign():
    from app.modules.pipeline.outdoor_marker_map import facing_camera_depth

    g = _fixture_geometry()
    cam, pl = g["camera"], g["entity_placements"][0]
    dep = facing_camera_depth(cam, pl)
    # facing 이 오른쪽-위: 카메라 축 성분이 살짝 카메라 쪽(음수)
    assert dep is not None and -0.25 < dep < 0


def test_facing_relation_intervals_are_mutually_exclusive():
    """away / toward / profile 은 하나의 eps 로 완전 비중첩이어야 한다.

    dep=0.1, eps=0.25 는 'dep>0'(away) 과 'abs(dep)<=eps'(profile) 를
    동시에 만족하던 구간 — away 는 반드시 거부돼야 한다.
    """
    from app.modules.pipeline.outdoor_marker_map import (
        _SCREEN_EPS,
        validate_marker_geometry,
    )

    def _v(relation, dep_target):
        # facing 을 카메라 축 방향으로 dep_target 만큼 기울인 픽스처를
        # 만드는 대신, 대표 좌표 3종으로 부호·크기 구간을 덮는다.
        g = _fixture_geometry(relation=relation, evidence="뒷모습",
                              facing=dep_target)
        return validate_marker_geometry(
            g, require_anchors=True, require_view=True,
            require_facing_relation=True,
            shot_text=SAMPLE_FIXTURE_SHOT_TEXT)

    assert _SCREEN_EPS == 0.25
    # (0.70, 0.20) → facing 이 카메라 반대쪽으로 크게: dep > +eps
    assert _v("away_from_camera", (0.70, 0.20)) == []
    assert _v("profile", (0.70, 0.20)) != []
    # (0.88, 0.52) → dep 가 0 근처: profile 만 통과
    assert _v("profile", (0.88, 0.52)) == []
    assert _v("away_from_camera", (0.88, 0.52)) != []
    assert _v("toward_camera", (0.88, 0.52)) != []
    # (0.80, 0.95) → facing 이 카메라 쪽으로 크게: dep < -eps
    assert _v("toward_camera", (0.80, 0.95)) == []
    assert _v("profile", (0.80, 0.95)) != []


def test_facing_relation_evidence_provenance_both_ways():
    from app.modules.pipeline.outdoor_marker_map import (
        validate_marker_geometry,
    )

    def _v(relation, evidence, facing=(0.70, 0.20)):
        return validate_marker_geometry(
            _fixture_geometry(relation=relation, evidence=evidence,
                              facing=facing),
            require_anchors=True, require_view=True,
            require_facing_relation=True,
            shot_text=SAMPLE_FIXTURE_SHOT_TEXT)

    # 명시 relation + 원문에 실재하는 인용 = 통과
    assert _v("away_from_camera", "뒷모습") == []
    # 명시 relation + evidence 누락 = 거부
    assert any("evidence" in v for v in _v("away_from_camera", ""))
    # 명시 relation + 원문에 없는 인용(발명) = 거부
    assert any("원문에 없음" in v
               for v in _v("away_from_camera", "정면을 향해 선다"))
    # unspecified + evidence 존재 = 거부 (근거 없는 선언 세탁 차단)
    assert any("unspecified" in v for v in _v("unspecified", "뒷모습"))
    # unspecified + 빈 evidence = 통과 (기하 검사 대상 아님)
    assert _v("unspecified", "", facing=(0.88, 0.52)) == []
```

- [ ] **Step 4: 테스트가 실패하는지 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py -q -k "facing_relation or facing_camera_depth"`
Expected: FAIL — `ImportError: cannot import name 'CAMERA_FACING_RELATIONS'`

- [ ] **Step 5: 상수·팩 집합 추가**

`outdoor_marker_map.py` 의 `_VIEW_GEOMETRY_PACKS = {"3"}` (`:54`) 바로
아래에 추가:

```python
# v4 (2026-07-26): SHOT TEXT 가 말한 카메라 대면 관계를 선언 필드로 올려
# 좌표와 결정론 대조한다. 프롬프트 문구·rationale 만으로는 틀린 facing 을
# validator 가 잡을 수 없다 — 실측(S15sh5): SHOT TEXT 가 '뒷모습'인데
# 저작 facing 의 카메라 축 성분이 dep=-0.211(부호가 오히려 카메라 쪽)
# 이었고 _SCREEN_EPS 미달로 심도 문구가 침묵해 은폐됐다.
CAMERA_FACING_RELATIONS = (
    "toward_camera", "away_from_camera", "profile", "unspecified")
_FACING_RELATION_GEOMETRY_PACKS = {"4"}
```

- [ ] **Step 6: 스키마에 게이트된 필드 추가**

`build_marker_geometry_schema` 시그니처(`:79-82`)를 바꾼다:

```python
def build_marker_geometry_schema(
    max_slots: int = MAX_SLOTS, *, include_anchors: bool = False,
    include_view: bool = False, include_facing_relation: bool = False,
) -> Dict[str, Any]:
```

docstring 끝에 한 줄 추가:

```python
    include_facing_relation (v4+): 카메라 대면 관계 선언 필드 2개를
    required 로 강제. False=기존 byte-identical.
```

`if include_view:` 블록 **다음**(`:133` `cam_req = cam_req + [...]` 뒤,
`placement = {` 앞)에 추가:

```python
    if include_facing_relation:
        # 관계는 enum, 근거는 SHOT TEXT 원문 인용 — unspecified 는 빈
        # 문자열이어야 한다(validator 가 양방향 검증).
        placement_props["camera_facing_relation"] = {
            "enum": list(CAMERA_FACING_RELATIONS)}
        placement_props["camera_facing_evidence"] = {"type": "string"}
        placement_req = placement_req + [
            "camera_facing_relation", "camera_facing_evidence"]
```

- [ ] **Step 7: `facing_camera_depth` 추가**

`outdoor_marker_map.py` 의 `_facing_screen_text` 정의(`:837`) **앞**에
추가한다 (`_screen_basis` 는 `:800` 에 이미 있다):

```python
def facing_camera_depth(
    cam: Dict[str, Any], pl: Dict[str, Any],
) -> Optional[float]:
    """피사체 facing 의 카메라 축 성분 (-1..+1).

    양수 = 카메라에서 멀어지는 쪽(카메라는 등을 본다),
    음수 = 카메라 쪽으로 도는 쪽(카메라는 앞을 본다),
    0 근처 = 카메라 축에 직각(측면).

    좌표가 없거나 facing 이 자기 위치와 같으면 None — 순수 벡터 연산이며
    의미 판단은 하지 않는다.
    """
    basis = _screen_basis(cam)
    if basis is None or not isinstance(pl.get("facing"), dict):
        return None
    fwd, _right = basis
    try:
        vec = (pl["facing"]["x"] - pl["x"], pl["facing"]["y"] - pl["y"])
    except (KeyError, TypeError):
        return None
    norm = math.hypot(*vec)
    if norm < 1e-9:
        return None
    return (vec[0] / norm) * fwd[0] + (vec[1] / norm) * fwd[1]
```

- [ ] **Step 8: 대면 관계 검증 헬퍼 추가**

바로 아래에 추가:

```python
def _check_facing_relation(
    cam: Dict[str, Any], pl: Dict[str, Any], label: str, shot_text: str,
) -> List[str]:
    """선언된 대면 관계 ↔ 좌표 정합 + 근거 provenance (v4).

    구간은 하나의 named epsilon(_SCREEN_EPS)으로 **완전 비중첩**이다:
      away_from_camera : dep > +eps
      toward_camera    : dep < -eps
      profile          : abs(dep) <= eps
    겹치게 정의하면(예: away 를 dep>0 으로) eps=0.25·dep=0.1 이 away 와
    profile 양쪽을 통과해 보증이 무너진다.

    evidence 는 필드 존재로 통과시키지 않는다 — 명시 relation 이면 SHOT
    TEXT 원문에 실재하는 인용이어야 하고, unspecified 면 비어 있어야
    한다. substring 으로 의미를 판정하는 것이 아니라 LLM 이 제시한 인용의
    진위만 확인하는 결정론 게이트다(outdoor_lane_plan._check_evidence 와
    동형).
    """
    from app.modules.pipeline.outdoor_lane_plan import ws_normalize

    rel = pl.get("camera_facing_relation")
    if rel not in CAMERA_FACING_RELATIONS:
        return [f"{label} camera_facing_relation 이 계약 값 아님: {rel!r}"]
    ev_raw = pl.get("camera_facing_evidence")
    if not isinstance(ev_raw, str):
        return [f"{label} camera_facing_evidence 가 문자열 아님"]
    ev = ws_normalize(ev_raw)
    out: List[str] = []
    if rel == "unspecified":
        if ev:
            out.append(
                f"{label} camera_facing_relation=unspecified 인데 "
                "camera_facing_evidence 가 비어 있지 않음 — 근거만 있고 "
                "선언이 없는 상태 금지"
            )
        return out
    if not ev:
        out.append(
            f"{label} camera_facing_relation={rel} 인데 "
            "camera_facing_evidence(SHOT TEXT 원문 인용) 누락"
        )
    elif ev not in ws_normalize(shot_text or ""):
        out.append(
            f"{label} camera_facing_evidence 인용이 SHOT TEXT 원문에 없음 "
            f"— 원문 그대로 인용할 것: {ev!r}"
        )
    dep = facing_camera_depth(cam, pl)
    if dep is None:
        out.append(
            f"{label} facing 으로 카메라 축 성분을 계산할 수 없음 "
            "(facing/좌표 결손 또는 자기 위치와 동일)"
        )
        return out
    ok = {
        "away_from_camera": dep > _SCREEN_EPS,
        "toward_camera": dep < -_SCREEN_EPS,
        "profile": abs(dep) <= _SCREEN_EPS,
    }[rel]
    if not ok:
        out.append(
            f"{label} camera_facing_relation={rel} 인데 facing 좌표의 "
            f"카메라 축 성분이 {dep:.3f} — 관계를 만족하지 않는다 "
            f"(away>{_SCREEN_EPS}, toward<-{_SCREEN_EPS}, "
            f"profile |dep|<={_SCREEN_EPS}). SHOT TEXT 가 말한 대면 "
            "관계대로 facing 점을 옮길 것"
        )
    return out
```

- [ ] **Step 9: validator 에 배선**

`validate_marker_geometry` 시그니처(`:283-286`)를 바꾼다:

```python
def validate_marker_geometry(
    geometry: Any, *, require_anchors: bool = False,
    require_view: bool = False, require_facing_relation: bool = False,
    shot_text: str = "",
) -> List[str]:
```

docstring 끝(`:301` `(쐐기 개폐·look_target 포함·facing 자기겹침)는 검증한다.`
뒤)에 추가:

```python
    require_facing_relation (v4+): per-placement 카메라 대면 관계 선언과
    좌표의 정합 + 근거 인용 provenance 를 필수로 검증. shot_text 는 그
    샷의 SHOT TEXT 원문(인용 대조용) — require_facing_relation=True 인데
    미공급이면 모든 명시 relation 이 거부된다(fail-closed).
```

placement 루프에서 anchor/view 검증이 끝나는 지점 뒤에 per-placement
호출을 추가한다. 카메라 dict 를 이미 확보한 뒤여야 하므로, 함수 말미의
반환 직전에 다음 블록을 넣는다:

```python
    if require_facing_relation:
        cam = geometry.get("camera")
        if not isinstance(cam, dict):
            violations.append(
                "camera 가 없어 카메라 대면 관계를 검증할 수 없음")
        else:
            for idx, pl in enumerate(placements):
                if not isinstance(pl, dict):
                    continue
                label = f"placement[{idx}]({pl.get('slot') or '?'})"
                violations.extend(
                    _check_facing_relation(cam, pl, label, shot_text))
```

- [ ] **Step 10: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py -q`
Expected: PASS — 신규 4개 포함, 기존 통과 수 감소 없음

- [ ] **Step 11: 회귀 스윕**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_lane_plan.py tests/core/test_shot_conti_light_lane.py -q`
Expected: PASS (default 인자라 기존 경로 불변)

- [ ] **Step 12: 커밋**

```bash
git add backend/app/modules/pipeline/outdoor_marker_map.py \
        backend/app/modules/pipeline/outdoor_lane_plan.py \
        backend/tests/pipeline/test_outdoor_marker_map.py
git commit -m "feat(lane): geometry 카메라 대면 관계 — 선언 필드+비중첩 3분할 검증+인용 provenance

SHOT TEXT 가 말한 뒷모습/정면/측면을 camera_facing_relation 으로 선언받아
좌표와 결정론 대조한다. 하나의 eps 로 away>+eps / toward<-eps /
profile |dep|<=eps 완전 비중첩. evidence 는 SHOT TEXT normalized substring
양방향 검증(명시=인용 필수, unspecified=빈 문자열 필수).

실측 근거: S15sh5 는 SHOT TEXT 가 '뒷모습'인데 dep=-0.211 로 부호가 반대
였고 eps 미달로 심도 문구가 침묵해 은폐됐다."
```

---

