# Task 1 리포트 — geometry 카메라 대면 관계 (스키마 + 결정론 검증)

- **상태**: DONE
- **커밋**: `88b03f41` (브랜치 `feat/w19-w20-bg-planning-cleanup`, 부모 `9364e810`)
- **푸시**: 안 함 (지시대로 local commit 만)

---

## 1. 파일별 변경

### `backend/app/modules/pipeline/outdoor_lane_plan.py`

- `_ws_normalize` → **`ws_normalize`** 공개 승격 (brief Step 1 그대로).
  docstring 에 2026-07-26 승격 사유(마커 맵 camera_facing evidence
  provenance 공유) 추가.
- 바로 아래 `_ws_normalize = ws_normalize` 호환 별칭 (동일 객체).
  기존 내부 호출부 2곳(`_check_evidence` 의 `quote`/`scene_texts`)은
  손대지 않았고, 리포 전체에서 `_ws_normalize` 를 참조하는 곳은 이 파일
  안 2줄이 전부였다(`grep -rn _ws_normalize app tests` 로 확인).

### `backend/app/modules/pipeline/outdoor_marker_map.py`

1. **import**: `from typing import Any, Dict, List` → `..., Optional`
   추가. brief 에 명시되지 않았지만 `facing_camera_depth` 의 반환
   annotation 이 `Optional[float]` 이라 필요했다 (아래 §3 참조).
2. **상수** (`_VIEW_GEOMETRY_PACKS = {"3"}` 바로 아래): brief 문구 그대로
   `CAMERA_FACING_RELATIONS` 4-tuple + `_FACING_RELATION_GEOMETRY_PACKS =
   {"4"}` + v4 주석 블록(S15sh5 실측 근거).
3. **`build_marker_geometry_schema`**: 시그니처에
   `include_facing_relation: bool = False` 추가, docstring 한 줄 추가,
   `if include_view:` 블록 다음·`placement = {` 앞에 게이트 블록 추가
   (`camera_facing_relation` enum + `camera_facing_evidence` string,
   둘 다 `placement_req` 에 append).
4. **`facing_camera_depth(cam, pl) -> Optional[float]`** 신규 —
   `_facing_screen_text` 정의 앞. `_screen_basis` 재사용, 순수 벡터 내적.
5. **`_check_facing_relation(cam, pl, label, shot_text) -> List[str]`**
   신규 — 그 바로 아래. `ws_normalize` 를 함수 내부에서 lazy import
   (모듈 레벨 순환 없음은 확인됐지만 brief 코드 그대로 유지).
6. **`validate_marker_geometry`**: 시그니처에
   `require_facing_relation: bool = False, shot_text: str = ""` 추가,
   docstring 문단 추가, 함수 말미 `return violations` 직전에
   per-placement 루프 블록 추가. 블록 위에 한 줄 주석(왜 말미인지 —
   camera dict + 검증 끝난 placements 를 모두 확보한 뒤여야 함)을 달았다.
   이 주석 한 줄만 brief 에 없는 추가분이다(주변 코드의 주석 밀도에 맞춤).

`MARKER_GEOMETRY_CONTRACT_VERSION` 은 **3 그대로 두었다** — brief 가
bump 를 지시하지 않았고, 팩 v4 selector 배선(어느 팩에서
`include_facing_relation=True` 를 넘길지)이 Task 1 범위 밖이라 아직
프로덕션 계약이 바뀌지 않았다. §4 concern 참조.

### `backend/tests/pipeline/test_outdoor_marker_map.py`

파일 끝에 brief 의 4개 테스트 + `SAMPLE_FIXTURE_SHOT_TEXT` +
`_fixture_geometry()` 를 그대로 추가. 픽스처 텍스트는 brief 그대로
"인물 A"/"인물 B" 의 시나리오 중립 문장.

유일한 문구 변경: `test_facing_relation_intervals_are_mutually_exclusive`
안의 헬퍼 파라미터명을 `dep_target` → **`facing`** 으로 rename (지시대로).
실제로 받는 값이 dep 스칼라가 아니라 facing 좌표쌍이라 이름이 거짓이었다.
헬퍼 안의 주석도 "facing 을 카메라 축 방향으로 **dep** 만큼 기울인" 으로
한 단어 맞췄다. assert 는 전부 brief 명세 그대로.

---

## 2. 실행한 테스트 명령과 결과

pytest 는 system python 에 없어 전부 `.venv/bin/python -m pytest`,
cwd = `/Users/manta/Documents/Projects/TheRoad-I1/backend`.

### 베이스라인 (변경 전)

```
$ .venv/bin/python -m pytest tests/pipeline/test_outdoor_marker_map.py -q
44 passed in 0.16s
$ .venv/bin/python -m pytest tests/pipeline/test_outdoor_lane_plan.py -q
25 passed in 0.16s
$ .venv/bin/python -m pytest tests/core/test_shot_conti_light_lane.py -q
21 passed in 0.21s
```

### Step 2 — `ws_normalize` 승격이 기존 동작을 깨지 않는지

```
$ .venv/bin/python -m pytest tests/pipeline/test_outdoor_lane_plan.py -q
25 passed in 0.17s          ← 베이스라인 동수, PASS
```

### Step 4 — RED 확인 (구현 전, 테스트만 추가한 상태)

```
$ .venv/bin/python -m pytest tests/pipeline/test_outdoor_marker_map.py -q \
    -k "facing_relation or facing_camera_depth"
FAILED ...::test_facing_relation_schema_is_pack_gated
FAILED ...::test_facing_camera_depth_sign
FAILED ...::test_facing_relation_intervals_are_mutually_exclusive
FAILED ...::test_facing_relation_evidence_provenance_both_ways
4 failed, 44 deselected in 0.35s
```

실패 사유는 brief 예상대로 `ImportError: cannot import name
'CAMERA_FACING_RELATIONS'` (앞 2개) / `TypeError:
validate_marker_geometry() got an unexpected keyword argument
'require_facing_relation'` (뒤 2개).

### Step 10 — GREEN

```
$ .venv/bin/python -m pytest tests/pipeline/test_outdoor_marker_map.py -q
48 passed in 0.17s          ← 44 기존 + 4 신규, 기존 감소 0
```

### Step 11 — 회귀 스윕

```
$ .venv/bin/python -m pytest tests/pipeline/test_outdoor_lane_plan.py \
    tests/core/test_shot_conti_light_lane.py -q
46 passed in 0.23s          ← 25 + 21, 동수
```

### 커밋 직전 3파일 합산 재확인

```
$ .venv/bin/python -m pytest tests/pipeline/test_outdoor_marker_map.py \
    tests/pipeline/test_outdoor_lane_plan.py \
    tests/core/test_shot_conti_light_lane.py -q
94 passed in 0.40s          ← 베이스라인 90 + 신규 4
```

### 광역 스윕 (`tests/pipeline` + `tests/core`) — 사전 존재 실패 격리

brief 에 없지만 공유 모듈을 건드렸으므로 추가로 돌렸다.

```
변경 후 : 10 failed, 3098 passed, 7 skipped, 2 deselected in 33.88s
변경 전 : 10 failed, 3094 passed, 7 skipped, 2 deselected in 32.87s
          (git stash 로 3파일만 되돌린 트리에서 동일 명령)
```

**실패 10건은 실패 목록까지 완전히 동일한 사전 존재 실패**이며 내 변경과
무관하다(마커/레인 계열이 아니다):

- `tests/core/test_background_prompt_step_v7.py::test_v7_selector_missing_overlay_cp_marks_bg_failed_without_llm_call`
- `tests/core/test_d6_hash_isolation.py::test_applies_to_shots_change_only_drifts_binding_hash`
- `tests/core/test_d6_hash_isolation.py::test_state_class_change_drifts_catalog_hash`
- `tests/core/test_d6_master_plan_determinism.py::test_d6_post_process_5_reruns_stable_hashes`
- `tests/core/test_d6_master_plan_determinism.py::test_d6_post_process_intent_order_shuffle_stable_hashes`
- `tests/core/test_d6_master_plan_determinism.py::test_d6_post_process_handshake_updates_plan_backgrounds_bg_id`
- `tests/core/test_outdoor_place_spec_step.py::test_outdoor_group_assembled_with_full_scene_text`
- `tests/core/test_visible_entities_validator.py::test_validate_fail_when_entity_name_anchored_to_other_id`
- `tests/core/test_visible_entities_validator.py::test_validate_fail_when_entity_name_outside_window`
- `tests/core/test_visible_entities_validator.py::test_validate_fail_when_entity_name_in_separate_sentence`

(passed 차이 3098-3094=4 = 신규 테스트 4개.)

### 스타일

`.venv` 에 flake8/pycodestyle/ruff/black 이 전부 없다(확인함). 대신 diff 의
추가 라인 전수를 **문자 길이**로 검사해 79 초과 0 건임을 확인했다
(Korean UTF-8 이라 byte 길이로 재면 오탐).

---

## 3. brief 에서 틀렸거나 각색이 필요했던 것

1. **`Optional` import 누락 (brief 의 실제 결손)** — Step 7 의
   `facing_camera_depth` 가 `Optional[float]` 을 쓰는데
   `outdoor_marker_map.py` 는 `from typing import Any, Dict, List` 만
   갖고 있었다. `from __future__ import annotations` 가 있어 annotation
   자체는 지연 평가돼 import 없이도 모듈은 로드되지만, 이름이 정의되지
   않은 채 남으면 `typing.get_type_hints`/런타임 introspection 에서
   깨지므로 import 에 `Optional` 을 추가했다. brief 의 유일한 실제 오류.

2. **`dep_target` 파라미터명** (부모가 미리 지시한 각색) — 실제로는
   facing 좌표쌍을 받는다. `facing` 으로 rename. assert 는 불변.

3. **`require_facing_relation` 블록 위치** (부모 지시대로) — 함수 말미
   `return violations` 직전. brief 본문이 "placement 루프에서 …" 라고
   운을 뗐다가 곧바로 "함수 말미의 반환 직전에 넣는다" 로 자기수정하는데,
   후자가 맞다. 루프 안에 넣으면 `cam` 이 아직 없다.

4. **픽스처 수치 사전 검증** — 코드를 쓰기 전에 픽스처가
   `require_view=True` 의 쐐기 검사를 통과하는지 별도로 계산해 확인했다:
   시야 쐐기 span **92.04°** (열림 4°~176° 안), `look_target` 쐐기 안,
   placement (0.78,0.55) 쐐기 안, `origin↔look_target` dist 0.1656
   (> `_MIN_CAMERA_DIST` 0.02), 3종 facing 의 자기거리 0.359/0.104/0.401
   (> `_MIN_FACING_DIST` 0.02). dep 값도 부모가 준 대로
   **+0.9612 / -0.2111 / -0.8986**. 즉 `== []` assert 가 facing 관계
   이외의 위반으로 오염되지 않는다. brief 는 이 점을 명시하지 않았지만
   결과적으로 정확했다.

5. brief 가 지목한 라인 번호(`:51-54`, `:79-140`, `:283-302`)는 현재
   파일과 일치했다. 프롬프트 팩은 하나도 건드리지 않았다(Task 1 범위대로).

---

## 4. 우려 사항

1. **`_FACING_RELATION_GEOMETRY_PACKS = {"4"}` 는 아직 어디에서도 읽히지
   않는다.** `outdoor_marker_map.py:513-514` 의 selector 는
   `_ANCHOR_GEOMETRY_PACKS`/`_VIEW_GEOMETRY_PACKS` 만 보고
   `include_anchors`/`include_view` 를 넘긴다. 또 `PROMPT_VERSION_MAP` 에
   geometry 팩 `"4"` 자체가 없어서 `resolve_prompt_version("4")` 는
   `ValueError` 다. 즉 **현재는 dead constant + 미배선 플래그**이고,
   프로덕션 경로는 default False 로 완전히 종전과 동일하다. 이건 Task 1
   설계상 의도된 상태로 읽히지만, MEMORY 의 `project-world-context-
   outdoor-wiring` 교훈("팩 발행=selector 배선까지가 한 단위, 미배선 팩
   2건 실측")과 정면으로 닿는 지점이라 **후속 Task 가 반드시 (a) 팩 v4
   프롬프트 발행 (b) selector 에서 `include_facing_relation` 배선 (c)
   `run_marker_geometry_shot` 에서 `shot_text` 공급 (d)
   `MARKER_GEOMETRY_CONTRACT_VERSION` bump 를 한 묶음으로 처리해야
   한다.** 특히 (c) 를 빠뜨리면 `shot_text=""` fail-closed 때문에 모든
   명시 relation 이 거부되어 재시도 무한 루프가 된다.

2. **`MARKER_GEOMETRY_CONTRACT_VERSION` 을 3 에 두었다.** 이 상수는
   config hash·sidecar 지문에 스탬프되고
   `shot_conti_light_step.py:163/765/1081` 이 읽는다. 지금 bump 하면
   실제 저작 계약이 하나도 안 바뀐 채 전 샷 재렌더를 유발한다. 팩 v4
   배선 Task 에서 bump 하는 게 맞다고 판단했다 — 후속 Task 브리프에
   이 항목이 없으면 누락이다.

3. **`validate_marker_geometry` 안에서 `cam` 이 재대입된다.** 새 블록의
   `cam = geometry.get("camera")` 가 함수 앞부분(`:372` 부근)에서
   `camera` 가 dict 가 아닐 때 `{}` 로 정규화해 둔 같은 이름의 지역변수를
   덮어쓴다. 새 블록이 스스로 `isinstance` 를 다시 검사하므로 동작상
   버그는 없고 brief 코드 그대로지만, 이후 이 함수에 코드를 더 붙이는
   사람이 `cam` 을 정규화된 값으로 착각할 여지가 있다.

4. **`_check_facing_relation` 은 첫 두 게이트에서 early-return 한다** —
   relation 이 계약 밖이거나 evidence 가 non-string 이면 좌표 정합
   검사를 아예 안 돌고 위반 1건만 낸다. 재시도 루프에는 이게 맞지만
   (형식부터 고쳐야 함), "위반 전수 열거" 를 기대하면 부분 보고다.
   brief 의도대로 유지했다.

5. **광역 스윕의 사전 존재 실패 10건** 은 이 Task 와 무관하지만
   `tests/core/test_outdoor_place_spec_step.py` 는 야외 계열이라
   후속 Task 에서 다시 마주칠 가능성이 있다. Task 1 에서는 손대지 않았다.


---

## 리뷰 지적 수정

리뷰 지적 6건(Important 2 + Minor 4) 전부 수용·구현. 범위는 브리프와
동일하게 `backend/app/modules/pipeline/outdoor_marker_map.py` +
`backend/tests/pipeline/test_outdoor_marker_map.py` 2파일.

### Important 1 — 상호배타 테스트가 상호배타를 검사하지 않았다

`test_facing_relation_intervals_are_mutually_exclusive` 의 픽스처 3종은
dep = +0.961 / -0.211 / -0.899 로 **(0, +eps] 밴드가 비어 있었다**.
그래서 `away_from_camera` 를 `dep > 0` 으로 잘못 구현해도 7개 단언이 전부
통과했다(리뷰어 실측 재현됨).

- facing `(0.86, 0.49)` → dep = **+0.13888** 픽스처를 추가하고
  `profile` 통과 / `away_from_camera` 거부 2단언을 넣었다.
- 헬퍼의 과대 주석("대표 좌표 3종으로 부호·크기 구간을 덮는다")을
  실제 커버리지(4종·네 구간, +0.139 밴드의 역할 명시)로 교체하고,
  테스트 docstring 의 `dep=0.1` 도 실제 값 `+0.139` 로 정정했다.

**깨진 구현 실측 (요구된 검증):**

| 조건 | 결과 |
|---|---|
| `"away_from_camera": dep > 0` 로 임시 변경 | **FAILED** — `tests/pipeline/test_outdoor_marker_map.py:1083`, `assert [] != []` at `_v("away_from_camera", (0.86, 0.49))` (새로 추가한 바로 그 단언에서 정확히 터짐) |
| `dep > _SCREEN_EPS` 로 원복 | **97 passed** |

즉 이번 단언은 실제로 문다. 원복 후 `TEMP-BROKEN` 잔재 0 을 grep 으로
확인했다.

### Important 2 — flag-off 경로가 오염된 relation 을 그냥 통과시켰다

모듈은 앞선 두 게이트에서 정반대 관례를 문서화·구현하고 있었다
("False 여도 anchor 필드가 존재하면 형식…은 검증한다 — 오염된 선택 필드
통과 차단"). v4 블록만 맨 `if require_facing_relation:` 이라, 팩 v4 가
출시되면 기본 플래그 소비자(`build_geometry_text_lines`,
`render_marker_map`, `build_marker_sketch_prompt`,
`build_marker_check_user_lines`, `build_marker_annotate_prompt`)가 좌표와
어긋난 선언을 무검증 통과시킨다 — 이 기능이 잡으려던 바로 그 실패.

관례에 맞추되 **provenance 절반만 조건부**로 분리했다:

- 블록 실행 조건 = `require_facing_relation` 이 True **또는** 어느
  placement 든 `camera_facing_relation` 이 non-None.
- `_check_facing_relation` 에 `check_provenance: bool = True` 키워드 추가.
  enum 값·evidence 필드 타입·`unspecified` 는 빈 문자열 규칙·**내적 좌표
  정합**은 항상 검증한다.
- 인용이 SHOT TEXT 원문에 실재하는지(그리고 인용 누락) 검사는
  `require_facing_relation` 이 True 이거나 non-empty `shot_text` 가
  공급됐을 때만. 둘 다 아니면 침묵 — 기본 소비자는 SHOT TEXT 를 갖지
  않으므로 대조 자체가 성립하지 않고, 켜 두면 모든 명시 relation 을
  거부하게 된다.
- 저작 경로 fail-closed 는 그대로: `require_facing_relation=True` +
  `shot_text=""` 는 명시 relation 을 여전히 거부한다.

신규 테스트 `test_facing_relation_checked_even_when_flag_off` 가
(a) `require=False`·`shot_text` 없이 오염 relation(선언 `away_from_camera`,
좌표는 dep=-0.211) 이 잡히는지, (b) 같은 호출이 evidence 인용에 대해서는
불평하지 않는지를 모두 단언한다. 더불어 relation 필드가 없는 geometry
(`_geometry()`, `_geometry_view()`)가 여전히 `[]` 인지도 같은 테스트에서
고정해 하위호환을 못박았다.

### Minor 3 — depth 부호 테스트가 비정규화 내적을 허용했다

`-0.25 < dep < 0` 은 정규화(`vec / norm`)를 빠뜨린 구현이 내는 **-0.02204**
도 통과시켜, docstring 이 약속한 "-1..+1 단위 투영"이 풀려 있었다. 게다가
`-0.25` 는 `_SCREEN_EPS` 의 하드코딩 복사본이었다.
→ `assert dep == pytest.approx(-0.2111, abs=1e-3)` 로 교체(정확값 고정 +
경계 상수 중복 제거).

### Minor 4 — `cam` 재조회·섀도잉

새 블록의 `cam = geometry.get("camera")` 를 제거하고, 함수 앞부분에서 이미
정규화된(비-dict → `{}`, 위반 기록 완료) 지역변수를 재사용한다. 카메라가
없으면 `if cam:` 에서 조용히 건너뛰고 "camera 가 없어…" 메시지는 뺐다 —
`"camera 가 객체 아님"` 이 이미 보고했으므로 재시도 힌트에 같은 사실이 두
번 실리던 문제가 사라진다.

### Minor 5 — `_SCREEN_EPS` 계약 주석

상수 주석 블록 끝에 "v4 부터 이 값은 산문 임계값만이 아니라
`_check_facing_relation` 의 away/toward/profile accept·reject 경계를
정의하는 validator 계약이며, 바꾸면 세 구간이 함께 움직이므로
`_check_facing_relation` 과 구간 테스트를 반드시 재검증할 것"을 명시했다.

### Minor 6 — 문서화만 되고 테스트 없던 동작 2건

- `test_facing_relation_authoring_path_needs_shot_text` —
  `require_facing_relation=True` + `shot_text=""` 가 좌표·인용이 모두
  정상인 명시 relation 을 거부함을 고정. 다음 Task 가 저작 경로를
  배선하며 SHOT TEXT 공급을 빠뜨리면 재시도 루프를 소진하는데, 그 함정을
  테스트가 먼저 잡는다. (`unspecified` 는 영향 없음도 함께 단언.)
- `test_facing_relation_skipped_when_camera_invalid` — camera=None 및
  camera 키 자체 부재 두 경우 모두, 카메라 위반이 **정확히 1건**이고
  대면 관계 쪽에서 카메라를 재차 문제 삼지 않음을 단언(Minor 4 회귀 가드
  겸용). 예외가 아니라 문자열 위반 리스트로 반환되는 것도 확인.

### 검증

```
$ cd /Users/manta/Documents/Projects/TheRoad-I1/backend
$ .venv/bin/python -m pytest tests/pipeline/test_outdoor_marker_map.py \
    tests/pipeline/test_outdoor_lane_plan.py \
    tests/core/test_shot_conti_light_lane.py -q
........................................................................ [ 74%]
.........................                                                [100%]
97 passed in 0.30s
```

수정 전 기준선 94 passed → 신규 3건 추가로 97 passed. 두 파일 모두 79컬럼
초과 라인 0. `outdoor_marker_map` 을 import 하는 테스트 파일은
`tests/pipeline/test_outdoor_marker_map.py` 와
`tests/core/test_shot_conti_light_lane.py` 뿐이며 둘 다 위 명령에 포함된다.

### 남은 판단 근거 (직전 보고 항목 갱신)

- 직전 보고 "남은 우려 3" (`cam` 재대입) 은 이번에 해소됐다.
- 직전 보고 "남은 우려 2" (`MARKER_GEOMETRY_CONTRACT_VERSION` 을 3 에
  유지) 는 그대로 유효하다 — 팩 v4 배선 Task 에서 bump 해야 한다.
- Important 2 로 flag-off 검증이 켜졌지만 **트리거는 `camera_facing_relation`
  non-None 존재 여부**다(브리프 명시). `camera_facing_evidence` 만 오염된
  채 relation 이 없는 geometry 는 여전히 무검증 통과한다 — 스키마가 두
  필드를 함께 required 로 묶으므로 정상 경로에서는 발생하지 않지만,
  엄밀히는 남아 있는 구멍이다.
