# Background Place Grouping Experiment — plan (W0)

> 상태: 작성자 = claude / 검토 = codex (대기). 코드/이미지/API/DB write 0. production 미수정.

> 직전 wave: rooftop_spatial_resolution_plan W1 (keyword/gap-ledger 방식) — 1/14 resolved, 18 blocking gaps. 설계 수렴 X.

> 본 plan 의 목적: **방향 전환** — 배경 일관성을 keyword 기반 shot binding 으로 결정하지 않고, **place/set/space/state/structural_version/camera 축을 명시적으로 분리**한 grouping 으로 설계한다.

> ## ★★★ 범위 (2026-05-24 사용자 명시 지시)
>
> This is a **generic background place grouping experiment**. L05 rooftop interior is **only the sample fixture** for end-to-end verification. Do NOT copy sample-specific labels (e.g. 거실 / 수리영의_방 / 옥탑방) into production rules or generic methodology.
>
> 본 실험은 **같은 장소 / 다른 공간 / 다른 상태 / 다른 카메라** 를 일반화해 분류하는 방법론 검증이다. PlaceGroup / SetGroup / SpaceNode / StateLayer / StructuralVersion / ShotBindingCandidate / BackgroundGenerationUnitPlan 7 contract 는 모든 location/set 에 적용 가능해야 한다. 모든 hard-coded sample data (L05 keyword whitelist, 거실/주방코너 등 노드 이름) 는 SAMPLE_FIXTURE_* prefix 로 격리하고, generic rule 은 SpaceNode containment / boundary relation 기반으로 표현한다. 단일 작품·에피소드·location 전용 설계 금지 — 사용자 standing rule [[feedback-no-scenario-specific-coding]] 참조.

---

## 0. 핵심 원칙

1. **Production 미수정.** backend/app/ 수정 0. 신규 실험 script + tests + scripts_output 산출만.
2. **read-only.** DB read 만 (PostgreSQL `theroad`). DB write 0, 이미지 생성 0, Gemini/OpenAI/fal API 호출 0.
3. **cross-product 폭증 금지.** `(space, time, state, camera)` 4축 cross-product 로 plate 를 만들지 않는다. master_set_plate 1장 + per-space derivative + per-state derivative + per-camera derivative 의 sum 으로 구성.
4. **상태 변화는 새 공간이 아니다.** corpse/cleaned/vandalized 같은 state 가 발생해도 같은 room geometry continuity 안에서 layer 로 해결.
5. **structural_version 엄격.** 구조 자체가 바뀌는 사건 (리모델링, 폭파, 가벽 추가, 벽 철거) 만 새 version 생성. 일반 state 변화는 절대 새 version 아님.
6. **decision rollup.** unresolved 는 shot 별 question 으로 풀지 않고, 사용자에게 묻는 "큰 결정 2~3개" 로 rollup.

---

## 1. Data contracts (초안)

### 1-A. `PlaceGroup`

세계관/지리적 의미의 거대 공간. 같은 건물/같은 옥상 단지 같은 "장소 동일성" 단위.

```json
{
  "place_group_id": "pg_rooftop_villa",
  "label": "옥탑 빌라 (수리영/민숙)",
  "member_location_ids": ["L05", "L04"],
  "physical_place_key": "rooftop_villa@incheon_pyeongdong",
  "continuity_scope": "single_building",
  "evidence": [
    "옥탑방 외부 (L04) + 옥탑방 내부 (L05) 가 단일 빌딩의 옥상",
    "bible.condition_age = 인천 변두리 오래된 빌라 옥탑방"
  ]
}
```

### 1-B. `SetGroup`

PlaceGroup 안의 "촬영 세트" 단위. 같은 set group 안은 geometry 정합성을 공유해야 한다.

```json
{
  "set_group_id": "sg_rooftop_interior",
  "parent_place_group_id": "pg_rooftop_villa",
  "label": "옥탑방 내부 세트",
  "continuity_policy": "geometry_locked",
  "member_space_nodes": ["거실", "주방코너", "현관", "수리영의_방", "민숙의_방_안방", "욕실"],
  "structural_versions": ["sv_initial"]
}
```

`continuity_policy` enum:
- `geometry_locked`: 모든 member space 의 벽/창/문 위치가 한 master plate 에 정합.
- `loose_continuity`: 인접 공간만 정합 (outdoor 같은 곳).
- `independent`: 정합성 미보장.

### 1-C. `SpaceNode`

SetGroup 안의 개별 room/zone/boundary/exterior 노드.

```json
{
  "node_id": "거실",
  "label": "거실 (living kitchen)",
  "node_type": "room",
  "set_group_id": "sg_rooftop_interior",
  "contained_in": null,
  "connected_to": ["주방코너", "현관", "수리영의_방", "민숙의_방_안방", "욕실"],
  "visibility_to": ["주방코너", "현관"],
  "active_status": "active",
  "evidence": ["bible.sub_spaces.거실"]
}
```

`node_type` enum: `room` / `zone` (room 안의 sub-zone like 주방코너) / `boundary` (현관 같은 opening) / `exterior`.

### 1-D. `StateLayer`

같은 geometry 위에 시각적 오버레이를 추가하는 시간 의존 layer.

```json
{
  "layer_id": "sl_corpse_marks",
  "label": "corpse_marks",
  "affects": ["surface", "blood", "damage"],
  "geometry_preserving": true,
  "severity": "high",
  "applied_shots": ["S12_S4", "S12_S6", "S12_S14"],
  "evidence": ["bible.state_variants[1] 참혹한 범죄 현장 상태"]
}
```

`affects` enum: `surface` / `furniture` / `lighting` / `damage` / `blood` / `emptiness` / `weather`.
`geometry_preserving`: boolean (must be true for state — false 면 StructuralVersion 으로 reclassify).

### 1-E. `StructuralVersion`

geometry 자체가 바뀌는 사건. 새 master plate 가 필요한 분기.

```json
{
  "version_id": "sv_initial",
  "reason": "initial state — no structural change documented",
  "geometry_changes": [],
  "starts_at_scene": null,
  "evidence": []
}
```

geometry change keyword (생성 트리거): `["리모델링", "철거", "폭파", "벽을 부순", "가벽 추가", "공사", "재건축", "확장 공사"]`. state/cleaning/vandalize 는 절대 트리거 아님.

### 1-F. `ShotBindingCandidate`

shot 별 어디 set/space/state/camera 에 속하는지 후보 + 선택 정책.

```json
{
  "shot_id": "S12_S6",
  "loc_id": "L05",
  "set_group_id": "sg_rooftop_interior",
  "space_node_candidates": ["수리영의_방", "민숙의_방_안방"],
  "chosen_policy_candidate": "both_plates_needed",
  "state_layers": ["sl_corpse_marks"],
  "structural_version": "sv_initial",
  "camera_view_family_candidate": "doorway_wide",
  "unresolved_reason": "bedroom_unresolved — rollup to user_decision_bedroom"
}
```

### 1-G. `BackgroundGenerationUnitPlan`

cross-product 폭증을 방지하는 master/derived 분리 계획.

```json
{
  "set_group_id": "sg_rooftop_interior",
  "structural_version": "sv_initial",
  "master_units": [
    {"unit_id": "master_거실_wide", "space_node": "거실", "camera_view": "eye_level_wide", "state_layer": "normal"}
  ],
  "derived_state_units": [
    {"unit_id": "derived_거실_corpse", "based_on_master": "master_거실_wide", "state_layer": "sl_corpse_marks"}
  ],
  "derived_camera_units": [
    {"unit_id": "derived_거실_table_close", "based_on_master": "master_거실_wide", "camera_view": "eye_level_table_close"}
  ],
  "rejected_cross_product": [
    {"unit": "master_거실_corpse_table_close", "reason": "decomposed into derived_거실_corpse + derived_거실_table_close"}
  ]
}
```

**Cross-product 폭증 방지 invariant:** unit 개수 ≤ master + sum(per_space_derivative) + sum(per_state_derivative) + sum(per_camera_derivative). 즉 cross-product `space × state × camera` 가 아니라 합.

---

## 2. L05 existing variant decomposition (실측 비교)

DB read-only: `image_asset` 에서 project_id=`6cb862d9-590c-4dce-86e6-d10c2977db19`, entity_id=`3afbc7a8-b919-4431-a691-0a99057a26ca`, asset_type=`chain_bg` 8 rows.

| variant_type | space_node 추정 | state_layer 추정 | camera_view 추정 | 비고 |
| ------------ | --------------- | ---------------- | ---------------- | ---- |
| L05B01 | 거실 | normal | eye_level_wide (1.3m, 35mm) | 식탁 wide |
| L05B02 | 침실 | clue_macro (구겨진 사진) | floor_close (0.5m, 50mm) | dusk |
| L05B03 | 거실+현관 | normal | eye_level_wide (1.6m, 35mm) | 식탁→현관 |
| L05B04 | 거실+현관 | dusk_night | eye_level_wide (1.6m, 35mm) | 황혼+습기 |
| L05B05 | 거실→침실 (혼합) | normal | eye_level_wide (1.6m, 35mm) | 두 공간 한 프레임 ❌ continuity 위반 |
| L05B06 | 거실+주방 | normal | eye_level_wide (1.6m, 40mm) | 북서쪽 wide |
| L05B07 | 침실 | normal | doorway_wide (1.6m, 35mm) | 침실 문턱 |
| L05B08 | 침실 | cleaned | doorway_wide (1.6m, 35mm) | 깨끗해진 침실 |

**현재 방식 = 8 variants.** axes 가 섞임 (state/camera/space 가 한 variant 안에 같이 묶임).

**새 grouping 방식 (jdg target):**
- master_units: 거실_wide / 침실_doorway_wide / 욕실_mirror_close = 3장.
- derived_space_units: 거실_table_close (식탁 클로즈업) + 거실_현관_방향 = 2장.
- derived_state_units: 거실_dusk_night + 침실_cleaned + 침실_corpse + 욕실_red_circle = 4장.
- derived_camera_units: 침실_floor_close (L05B02 같은 0.5m macro) = 1장.
- 합계 ~10장 (master 3 + derived 7), cross-product 비교 시 5 space × 3 state × 3 camera = 45 대비 ~78% 절감.

`L05B05` 같은 "두 공간 한 프레임" variant 는 새 방식에서 reject — continuity 위반. 표에서 명시적으로 reject 표시.

---

## 3. Methodology — L05B variant decomposer

각 chain_bg variant prompt_used 를 deterministic 으로 3축 분해:

1. **space_node 추출:** keyword whitelist (거실 / 주방 / 현관 / 침실 / 욕실). 다중 매칭 시 `multi_space` 마킹.
2. **state_layer 추출:** keyword (clue_macro / cleaned / corpse / vandalized / dusk / night / morning / normal). 매칭 0 → normal.
3. **camera_view 추출:** height keyword (1.3m / 1.6m / 0.5m) + lens (35mm/40mm/50mm) + framing (wide/doorway_wide/floor_close/macro).

이 decomposer 는 LLM 0, 정규식 + substring + 좁은 whitelist. 결정 불가 시 `unknown` + evidence_pointer 1줄.

---

## 4. HTML report 요구

`index.html` 첫 화면 순서 (gap ledger 우선 표시 금지):

1. **요약:** 기존 chain_bg variant 수 vs 새 master/derived unit 수 (e.g. 8 → 10, cross-product 대비 45 → 10).
2. **PlaceGroup / SetGroup / SpaceNode topology table.**
3. **L05B existing variant decomposition table:** variant → space_node / state_layer / camera_view / reason / reject_flag.
4. **Shot binding table:** 14 shots → set_group / space candidates / state layers / camera candidate.
5. **BackgroundGenerationUnitPlan:** master units / derived_state units / derived_camera units / rejected cross-product.
6. **Unresolved decisions (rollup):** 2~3개 큰 결정만. 예:
   - "안방 (민숙_방) 을 set_group 의 active SpaceNode 로 포함할 것인가?"
   - "L05B05 처럼 두 공간 한 프레임 variant 를 정책상 허용할 것인가? (현재 reject)"
   - "L05B02 같은 floor_close 0.5m macro 를 derived_camera_unit 으로 인정할 것인가, 혹은 별도 detail_clue 카테고리로 분리할 것인가?"

shot 별 18개 question 표시 금지.

---

## 5. TDD invariants (≥ 12 tests)

1. `test_same_loc_same_space_different_state_groups_same_continuity` — 같은 loc + 같은 space + 다른 state → same continuity group.
2. `test_different_room_same_set_group_different_space_node` — 다른 room 두 변종 → different SpaceNode, same SetGroup.
3. `test_state_layer_geometry_preserving_boolean_for_cleaned_vandalized_corpse` — cleaned / vandalized / corpse_marks 모두 `geometry_preserving=True`.
4. `test_structural_version_only_geometry_changing_keywords` — 리모델링 / 폭파 / 가벽 추가 / 벽 철거 / 공사 만 새 StructuralVersion 트리거. cleaned/corpse/vandalized 는 트리거 X.
5. `test_l05b_variant_decomposer_returns_three_axes` — 각 L05B01..L05B08 decomposition 결과가 (space_node, state_layer, camera_view) 3개 필드 모두 채워서 반환.
6. `test_l05b05_multi_space_marked_reject` — L05B05 처럼 두 공간 한 프레임 variant 는 `multi_space` flag + reject 이유 명시.
7. `test_generation_unit_plan_avoids_cross_product_explosion` — unit 총수 ≤ master + sum(derived). cross-product 폭증 (space × state × camera) 금지.
8. `test_html_first_screen_shows_old_vs_new_unit_count` — index.html 상단 banner 에 `existing_variant_count` 와 `new_unit_count` 동시 표시.
9. `test_html_no_per_shot_unresolved_question` — index.html 의 unresolved 섹션이 shot 별 question 아닌 rollup question 만 (≤ 5개).
10. `test_static_import_guard_no_production_or_network` — 신규 script 가 backend.app.* / openai / google.genai / fal_client / PIL 등 production/network module 미 import.
11. `test_state_layer_affects_enum_strict` — `affects` 가 enum (surface/furniture/lighting/damage/blood/emptiness/weather) 만 허용.
12. `test_place_group_set_group_containment` — SetGroup.parent_place_group_id 가 존재하는 PlaceGroup 을 참조해야 함.
13. (옵션) `test_shot_binding_candidate_unresolved_reason_rollup` — unresolved shot 의 `unresolved_reason` 이 rollup_id 만 가리키고 free-text 18가지 다르지 않음.

---

## 6. 신규 script 구조

- `backend/scripts/experiment_background_place_grouping.py`
  - import 범위:
    - `app.core.database.SessionLocal` (read-only)
    - `experiment_rooftop_spatial_bg.ShotMeta`, `load_l05_shots` (sample fixture loader 가 호출 — generic engine 은 이름조차 모름)
    - `build_shot_plans` 절대 import 금지
    - openai / google.genai / fal / PIL / requests / httpx 0
  - SOT (W2c+W2d 기준):
    - `SampleFixtureSpec` (dataclass) — 한 fixture 전체. project_id/episode_id/canon_id/location_short_id/source_run_path/place_groups/set_groups/space_nodes/space_keyword_whitelist/zone_boundary_node_ids/room_node_ids/weak_room_hints/default_camera_by_space_node.
    - `build_sample_fixture_l05_spec()` — L05 fixture 한 묶음. sample-specific 모든 literal 격리.
  - 모듈 함수 (engine = location-agnostic, spec input):
    - `_load_backend_env()`, `_load_bible_and_evidence()`
    - `build_place_groups_from_spec(spec)` / `build_set_groups_from_spec(spec, place_groups)` / `build_space_nodes_from_spec(spec, set_groups)` — generic engine.
    - `build_state_layers(bible, shots)` / `build_structural_versions(bible, shots)` (default 1개 sv_initial)
    - `extract_primary_and_secondary_spaces(text, spec)` — spec-driven primary/secondary.
    - `decompose_chain_bg_variant(variant_id, prompt_text, spec)` — generic; sample fixture variant_id (예: L05B01..L05B08) 의 prompt_used 를 3축 분해.
    - `build_shot_bindings(shots, set_groups, space_nodes, state_layers, spec)` — 정책 후보 + rollup.
    - `plan_background_generation_units(set_groups, space_nodes, state_layers, l05_decomposition, spec)` — cross-product 회피.
    - `compute_acceptance_metrics(...)` — STATE_ORACLE_CASES 실측.
    - `render_html(...)`, `write_outputs(...)`, `parse_args()`, `main()`.
  - legacy aliases (호환 only): `build_place_groups(bible)` / `build_set_groups(bible, place_groups)` / `build_space_nodes(bible, set_groups)` / `decompose_l05_variant(variant_id, prompt_text)`.
- 산출 root: `scripts_output/background_place_grouping_experiment/<run_id>/`
  - `place_groups.json`, `set_groups.json`, `space_nodes.json`, `state_layers.json`, `structural_versions.json`, `shot_bindings.json`, `generation_unit_plan.json`, `chain_bg_decomposition.json+tsv`, `index.html`, `run_meta.json`.

---

## 7. W1 진입 조건 (acceptance)

1. L05 chain_bg 8 variants 의 3축 decomposition 결과가 JSON 으로 저장됨.
2. master_units + derived units 합계가 8 (현재) 보다 같거나 적거나, 같으면 분해 이유가 명확.
3. cross-product `space × state × camera` 잠정치 (예: 5×3×3=45) 와 비교한 unit 절감률 명시.
4. shot binding 14 shots 모두 set_group / space candidates / state layers / camera candidate 4 필드 채움.
5. unresolved decision rollup 이 ≤ 5개 의 큰 질문 (shot 별 question 0).
6. TDD ≥ 12 tests 전부 GREEN. backend pytest 전체 회귀 0.
7. production code 무수정 — script + tests 만.
8. DB write 0, 이미지 0, API 호출 0 — Codex 가 source grep 으로 정적 검증 가능.

---

## 8. Out of scope (이번 wave)

- 이미지 생성 (Gemini / OpenAI / fal). W2 별도 wave.
- DB write / production schema 변경.
- 다른 location (L01-L04, L06+). L05 우선.
- W1 keyword whitelist 확장 plan_v3 — 본 plan 이 대체.
- gap ledger 기반 결정 — 본 plan 이 대체 (rollup 으로 사용자에게 큰 결정만 묻기).

---

## 9. Next action

본 plan.md → Codex 1차 리뷰. 통과 후 W1 = TDD + script 구현. 미통과 시 plan_v2 patch.

---

## 10. W1 erratum — HISTORICAL (2026-05-24 — Codex 리뷰 결과)

> **★ HISTORICAL / SUPERSEDED BY W2c-W2e**: 본 §10 은 W1 dry-run (`20260524_1024_27dd99`) 의 BLOCKING/IMPORTANT 발견 기록이다. 본 섹션의 metric 이름 (`state_false_positive_count`), 산출 파일명 (`l05b_decomposition.*`), acceptance gate 정의는 모두 W2c (golden_state_oracle_passed 로 교체) + W2c (chain_bg_decomposition.* 로 rename) + W2d (spec-driven engine) 로 superseded. 현재 SOT 는 §6 + run_meta.acceptance_metrics. 본 §10 은 wave 진화 기록 / 디버깅 컨텍스트로만 보존.

W1 dry-run run `20260524_1024_27dd99` 는 사용자 검토 / 다음 wave 기준으로 사용 금지. headline 숫자 (8 → 7 units, saving 92%, rejected_continuity=5) 가 **오염됨**.

### 발견된 BLOCKING

**BLOCKING 1 — state classifier negation/priority 부재 (false positive)**

- 코드: `experiment_background_place_grouping.py:452-459` `STATE_LAYER_KEYWORDS` substring first-match. negation guard 0. shot 쪽 `:542-550` 도 동일.
- 산출: `l05b_decomposition.json` 의 L05B01..L05B08 8/8 이 `state_layer="corpse_marks"` 로 잡힘. L05B01 prompt 의 "사람, 얼굴, 시신, 피, 노골적인 폭력 흔적은 전혀 없다" 같은 negation 표현이 무시되어 "시신" substring 만 매칭.
- 영향: generation_unit_plan 의 derived_state 가 corpse_marks 중심으로만 생성, S14_S4 (cleaned) shot 에도 corpse 가 같이 붙음. 배경 상태 layer 비교가 무효.
- W2 요구: variant + shot 공통 state classifier 1개로 통합. 우선순위 `cleaned override > vandalized/vision > corpse (negation guard) > lighting > clue_macro > normal`. CORPSE_NEGATION_PATTERNS: `시신/시체/피/핏자국/폭력 흔적` + `없다 / 전혀 없다 / 흔적 없음 / 사라진 / 감쪽같이 사라`. Golden tests: L05B01=normal, L05B08=cleaned, S14_S4=cleaned(not corpse), S18_S5=no-corpse-if-negated.

**BLOCKING 2 — primary_space vs visible_secondary 미분리 (over-matching)**

- 코드: `experiment_background_place_grouping.py:443-450` 은 prompt 전체에서 모든 space keyword 수집. `:481-493` 은 room 2개 이상이면 violation reject.
- 산출: `l05b_decomposition.json` 의 L05B03 = "거실,주방코너,현관,수리영의_방,욕실", L05B06 = "거실,주방코너,현관,욕실" 모두 reject. 실제로는 거실 wide 안에 보이는 주방/현관/문틈/욕실 가장자리 등 secondary mention.
- 영향: `rejected_continuity_count=5` 는 실제 위반 수가 아니라 overmatch 수. master/derived 산정 왜곡.
- W2 요구: `extract_primary_and_secondary_spaces(text) -> (primary, secondary_list)` 헬퍼 신설.
  - primary 추출: "카메라는 ... 향해/바라본다", "중앙의 ...", "안쪽" 의 첫 매칭 keyword.
  - zone/boundary (주방코너/현관) 는 contained_in 자동 흡수.
  - multi-room violation = primary 가 2개 이상일 때만 (secondary 가 다른 room 이면 visibility constraint).
  - L05BDecomposition 에 `primary_space` + `visible_secondary_nodes` 필드 추가.
- Tests: L05B03/L05B06 거실 primary + secondary nodes (no reject), L05B08 침실 primary + 거실 secondary (no reject), 진짜 reject = 두 독립 room 을 동시에 master 로 만들려는 경우만.

### IMPORTANT

**IMPORTANT 1 — cross-product saving metric 은 acceptance gate 가 아님**

- 코드: `:839-847` 의 `cross_product_upper = active_spaces × non-normal states × 3` 은 illustrative.
- W2 요구: label 강등 `saving_vs_cross_product` → `illustrative_cross_product_upper` (서술용). acceptance 는 다음으로 변경:
  - (a) `state_false_positive_count = 0` (L05B01 normal 등 golden snippet 으로 검증).
  - (b) `valid_decomposition_count` (continuity 위반 아닌 variant 수).
  - (c) `reject_by_reason` breakdown.

**IMPORTANT 2 — golden snippet fixture 부재**

- 현재 fixture 가 negation/secondary mention 패턴을 충분히 포함하지 않아 BLOCKING 1/2 catch 실패.
- W2 요구: 실측 DB prompt 에서 ≥ 4 golden snippet fixture:
  - L05B01: negation ("시신... 전혀 없다") → state=normal.
  - L05B03: secondary room mention (거실 primary + 침실문 secondary) → no reject.
  - L05B06: visible bathroom edge → no reject.
  - L05B08: cleaned bedroom + visible living room → state=cleaned, no reject.
- shot state classifier 도 fixture 추가 (S14_S4, S18_S5, S18_S9 등).

**IMPORTANT 3 — continuity_group geometry_preserving guard**

- 현재 `continuity_group_for(space, structural_version)` 은 state 무시 — geometry_preserving 가정. 향후 non-preserving state 추가 시 silent bug.
- W2 요구: `continuity_group_for(space, structural_version, state_layer=None)` 시그니처. state_layer 가 주어지면 `geometry_preserving=False` 일 경우 assertion 또는 자동 StructuralVersion 승격 권유.

### MINOR

- `--no-serve` 옵션 unused → "reserved/no-op" 주석 또는 제거.
- `chain_bg_prompts` load 후 unused, `sqlalchemy.select` import unused, `load_l05_canon_description` import unused → 정리.
- `PLAN_VERSION="v0"` → `"bpg_w2"` (W2 patch 후).

### W2 acceptance (갱신)

1. L05 chain_bg 8 variants 의 state/primary_space false_positive=0 (golden snippet 검증).
2. `valid_decomposition_count` ≥ 6 (실제 reject 는 두 독립 room master 시도 시만).
3. shot_bindings 의 state_layers 가 negation 처리됨 (S14_S4=cleaned, S18_S5≠corpse 등).
4. acceptance metric = (a) state_false_positive_count, (b) valid_decomposition_count, (c) reject_by_reason — saving% 는 illustrative 표시만.
5. TDD ≥ 35 + 6 golden snippet test (≥ 41 total) GREEN.
6. 회귀 0.

W2 진입 = 본 §10 적용 후 dry-run 재실행 → Codex re-review APPROVED_FOR_USER_REVIEW 후 사용자 검토.

