# 구조 파이프 기반 — 정책 SOT + 후보 풀 수명 분리 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 야외 구조물 파이프 편입의 기반 두 가지를 놓는다 — ①조합 유효성을 소유하는 중앙 정책 SOT ②검색 후보 획득과 선택의 수명 분리(라운드 불변 경로 + 지문 2분리).

**Architecture:** 이미지·LLM 동작을 바꾸지 않는다. 순수하게 계약·경로·지문만 다룬다. 정책 모듈은 새 파일 하나로 격리하고, `outdoor_structure_form_reference` 스텝은 후보를 지문별 불변 디렉토리에 쓰고 `round.json` 에 목록을 남겨 **선택만 다시 도는 경로**가 가능하게 만든다.

**Tech Stack:** Python 3 · FastAPI/SQLAlchemy 백엔드 · pytest · pydantic-settings (`app.core.config.settings`)

## Global Constraints

설계 SOT = `docs/superpowers/specs/2026-07-31-structure-skeleton-look-and-place-mark-production-merge-design.md`

- **하드코딩·하드프롬프팅 금지.** 작품 고유명사·업종 이름·장소 표시물의 통칭을 코드·프롬프트에 넣지 않는다. 대상 그룹 상수 배열 금지.
- **DB·프로젝트 파일 삭제 금지.** 이미지 파일을 지우거나 덮어쓰지 않는다.
- **프롬프트 파일 덮어쓰기 금지.** 팩은 새 버전 디렉토리로만 만든다. 버전 형식 `4.202607301950`.
- **LLM 에 전달하는 원문을 자르지 않는다** (`[:400]` 류 금지).
- **TDD 는 결정론 영역만.** 이 계획은 전부 결정론 영역이다 — LLM·이미지 품질 주장을 하지 않는다.
- 테스트 실행 위치는 `backend/`. `pytest.ini` 의 `testpaths = tests`.
- 커밋 메시지는 한국어, 끝에 `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.

---

## File Structure

| 파일 | 책임 |
|---|---|
| `backend/app/core/structure_pipeline_policy.py` (신규) | 조합 유효성의 단일 소유자. settings → 검증된 정책 객체. |
| `backend/tests/core/test_structure_pipeline_policy.py` (신규) | 정책 해석·거부 계약. |
| `backend/app/core/config.py` (수정) | 정책 3필드 추가. |
| `backend/tests/core/test_banned_vocabulary_inventory.py` (신규) | 활성 경로 금지 어휘 ratchet. |
| `backend/app/core/steps/outdoor_structure_form_reference_step.py` (수정) | 라운드 불변 경로 + 지문 2분리 + 선택 전용 재실행. |
| `backend/tests/core/test_form_reference_round_isolation.py` (신규) | 경로·지문·재사용 경계. |

---

## Task 1: 중앙 정책 SOT

**Files:**
- Create: `backend/app/core/structure_pipeline_policy.py`
- Modify: `backend/app/core/config.py` (설정 3필드 추가 — `outdoor_lane_pipe_enabled` 정의 직후)
- Test: `backend/tests/core/test_structure_pipeline_policy.py`

**Interfaces:**
- Consumes: `app.core.config.settings` · `app.core.errors.AppError`
- Produces:
  - `StructurePipelinePolicy` (frozen dataclass) — 필드 `seed_recipe: str` · `place_mark_mode: str` · `seed_critique_mode: str`, 속성 `skeleton_look_on: bool` · `place_mark_on: bool` · `structure_critique_on: bool`, 메서드 `as_hash_payload() -> dict`
  - `resolve_structure_pipeline_policy(settings_obj: Any = None) -> StructurePipelinePolicy`
  - 상수 `POLICY_VERSION` · `SEED_RECIPE_LEGACY` · `SEED_RECIPE_SKELETON_LOOK_V1` · `PLACE_MARK_OFF` · `PLACE_MARK_V1` · `CRITIQUE_OFF` · `CRITIQUE_STRUCTURE_PRESERVING_V1`

- [ ] **Step 1: 실패하는 테스트를 쓴다**

`backend/tests/core/test_structure_pipeline_policy.py` 를 만든다:

```python
"""야외 구조물 파이프 정책 SOT — 조합 유효성 계약 (2026-07-31).

설계 §5.1: 조합 유효성을 config_hash 나 manifest applicability 에 맡기지
않는다. hash 는 무효화·감사 장치이지 validity gate 가 아니다. 불가능한
조합은 실행·claim **이전에** 중앙 validator 가 거부한다.
"""
from __future__ import annotations

from types import SimpleNamespace

import pytest

from app.core.errors import AppError
from app.core.structure_pipeline_policy import (
    CRITIQUE_OFF,
    CRITIQUE_STRUCTURE_PRESERVING_V1,
    PLACE_MARK_OFF,
    PLACE_MARK_V1,
    POLICY_VERSION,
    SEED_RECIPE_LEGACY,
    SEED_RECIPE_SKELETON_LOOK_V1,
    resolve_structure_pipeline_policy,
)


def _settings(**over):
    base = dict(
        structure_seed_recipe=SEED_RECIPE_LEGACY,
        place_mark_mode=PLACE_MARK_OFF,
        seed_critique_mode=CRITIQUE_OFF,
        structure_seed_variants_enabled=False,
        outdoor_lane_pipe_enabled=False,
        outdoor_lane_plan_enabled=False,
    )
    base.update(over)
    return SimpleNamespace(**base)


def _full_on(**over):
    base = dict(
        structure_seed_recipe=SEED_RECIPE_SKELETON_LOOK_V1,
        place_mark_mode=PLACE_MARK_OFF,
        seed_critique_mode=CRITIQUE_OFF,
        structure_seed_variants_enabled=True,
        outdoor_lane_pipe_enabled=True,
        outdoor_lane_plan_enabled=True,
    )
    base.update(over)
    return SimpleNamespace(**base)


def test_default_is_legacy_and_everything_off():
    """기본값 = 오늘의 경로. 새 스텝은 아무것도 켜지지 않는다."""
    p = resolve_structure_pipeline_policy(_settings())
    assert p.seed_recipe == SEED_RECIPE_LEGACY
    assert p.skeleton_look_on is False
    assert p.place_mark_on is False
    assert p.structure_critique_on is False


def test_skeleton_look_requires_variant_authoring():
    """변형 저작이 꺼져 있으면 skeleton_look 은 성립하지 않는다.

    스케치는 저작된 conformance brief 에서 나온다 — 저작이 없으면 입력
    자체가 없다.
    """
    with pytest.raises(AppError) as exc:
        resolve_structure_pipeline_policy(
            _full_on(structure_seed_variants_enabled=False))
    assert "structure_seed_variants_enabled" in str(exc.value.message)


def test_skeleton_look_requires_outdoor_lane_pipe():
    """야외 파이프가 꺼져 있으면 레시피가 조용히 죽는다 — 거부한다."""
    with pytest.raises(AppError):
        resolve_structure_pipeline_policy(
            _full_on(outdoor_lane_pipe_enabled=False))


def test_place_mark_requires_skeleton_look_recipe():
    """표시면 재작화는 seed_plan 의 목표 문안을 소비한다 — legacy 에는 없다."""
    with pytest.raises(AppError) as exc:
        resolve_structure_pipeline_policy(
            _settings(place_mark_mode=PLACE_MARK_V1))
    assert "place_mark_mode" in str(exc.value.message)


def test_structure_critique_requires_skeleton_look_recipe():
    with pytest.raises(AppError):
        resolve_structure_pipeline_policy(
            _settings(seed_critique_mode=CRITIQUE_STRUCTURE_PRESERVING_V1))


def test_unknown_value_is_rejected_not_coerced():
    """오타를 기본값으로 흡수하면 사용자가 켠 줄 알고 있는데 꺼져 있다."""
    with pytest.raises(AppError) as exc:
        resolve_structure_pipeline_policy(
            _settings(structure_seed_recipe="skeleton_look"))
    assert "structure_seed_recipe" in str(exc.value.message)


def test_full_stack_on_is_accepted():
    p = resolve_structure_pipeline_policy(
        _full_on(place_mark_mode=PLACE_MARK_V1,
                 seed_critique_mode=CRITIQUE_STRUCTURE_PRESERVING_V1))
    assert p.skeleton_look_on is True
    assert p.place_mark_on is True
    assert p.structure_critique_on is True


def test_hash_payload_carries_policy_version_and_all_three():
    """각 스텝 config_hash 는 resolved policy 를 **기록**만 한다."""
    p = resolve_structure_pipeline_policy(_full_on())
    payload = p.as_hash_payload()
    assert payload["policy_version"] == POLICY_VERSION
    assert payload["seed_recipe"] == SEED_RECIPE_SKELETON_LOOK_V1
    assert payload["place_mark_mode"] == PLACE_MARK_OFF
    assert payload["seed_critique_mode"] == CRITIQUE_OFF


def test_policy_is_frozen():
    """정책 객체가 실행 중에 바뀌면 hash 와 동작이 어긋난다."""
    import dataclasses

    p = resolve_structure_pipeline_policy(_settings())
    with pytest.raises(dataclasses.FrozenInstanceError):
        p.seed_recipe = SEED_RECIPE_SKELETON_LOOK_V1  # type: ignore[misc]
```

- [ ] **Step 2: 테스트를 돌려 실패를 확인한다**

Run: `cd backend && python -m pytest tests/core/test_structure_pipeline_policy.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.core.structure_pipeline_policy'`

- [ ] **Step 3: 정책 모듈을 만든다**

`backend/app/core/structure_pipeline_policy.py`:

```python
"""야외 구조물 파이프 정책 SOT — 조합 유효성의 단일 소유자.

설계 = docs/superpowers/specs/
2026-07-31-structure-skeleton-look-and-place-mark-production-merge-design.md §5.1

## 왜 중앙 SOT 인가

플래그를 boolean 으로 늘리면 조합 수가 곱으로 늘고, 유효성 판단이
`config_hash` 나 manifest applicability 로 새어 나간다. **hash 는 무효화·
감사 장치이지 validity gate 가 아니다** — hash 가 달라도 실행은 그대로
되고, 불가능한 조합은 조용히 no-op 이 되거나 중간에서 터진다.

그래서 유효성은 이 모듈 하나가 소유한다. 스텝은 여기서 검증된 객체를
받아 쓰고, manifest applicability 는 이 객체에서 파생하며, 각 스텝의
config_hash 는 `as_hash_payload()` 를 **기록**만 한다.
"""
from __future__ import annotations

import dataclasses
from typing import Any, Dict, List, Optional

# 정책 계약 자체의 버전 — 값의 의미가 바뀌면 올린다(각 스텝 hash 에 실린다).
POLICY_VERSION = "1"

SEED_RECIPE_LEGACY = "legacy"
SEED_RECIPE_SKELETON_LOOK_V1 = "skeleton_look_v1"
PLACE_MARK_OFF = "off"
PLACE_MARK_V1 = "v1"
CRITIQUE_OFF = "off"
CRITIQUE_STRUCTURE_PRESERVING_V1 = "structure_preserving_v1"

_SEED_RECIPES = (SEED_RECIPE_LEGACY, SEED_RECIPE_SKELETON_LOOK_V1)
_PLACE_MARK_MODES = (PLACE_MARK_OFF, PLACE_MARK_V1)
_CRITIQUE_MODES = (CRITIQUE_OFF, CRITIQUE_STRUCTURE_PRESERVING_V1)


@dataclasses.dataclass(frozen=True)
class StructurePipelinePolicy:
    """검증을 통과한 정책. 생성 경로는 resolve_* 하나뿐이다."""

    seed_recipe: str
    place_mark_mode: str
    seed_critique_mode: str

    @property
    def skeleton_look_on(self) -> bool:
        return self.seed_recipe == SEED_RECIPE_SKELETON_LOOK_V1

    @property
    def place_mark_on(self) -> bool:
        return self.place_mark_mode == PLACE_MARK_V1

    @property
    def structure_critique_on(self) -> bool:
        return self.seed_critique_mode == CRITIQUE_STRUCTURE_PRESERVING_V1

    def as_hash_payload(self) -> Dict[str, Any]:
        """각 스텝 config_hash 가 기록할 정책 스탬프."""
        return {
            "policy_version": POLICY_VERSION,
            "seed_recipe": self.seed_recipe,
            "place_mark_mode": self.place_mark_mode,
            "seed_critique_mode": self.seed_critique_mode,
        }


def _enum(value: Any, allowed: tuple, field: str, violations: List[str]) -> str:
    """미지 값을 기본값으로 흡수하지 않는다 — 오타가 조용한 OFF 가 된다."""
    s = str(value or "").strip()
    if s not in allowed:
        violations.append(
            f"{field}={s!r} 는 허용되지 않는다 (허용: {', '.join(allowed)})")
    return s


def resolve_structure_pipeline_policy(
    settings_obj: Optional[Any] = None,
) -> StructurePipelinePolicy:
    """settings 를 검증된 정책으로 해석한다. 불가능한 조합은 AppError.

    ★실행·claim **이전에** 부른다. 스텝 안에서 늦게 부르면 이미 락을 잡고
    부분 산출을 남긴 뒤에 터진다.
    """
    from app.core.errors import AppError

    if settings_obj is None:
        from app.core.config import settings as settings_obj  # noqa: PLW0127

    violations: List[str] = []
    recipe = _enum(getattr(settings_obj, "structure_seed_recipe", ""),
                   _SEED_RECIPES, "structure_seed_recipe", violations)
    place_mark = _enum(getattr(settings_obj, "place_mark_mode", ""),
                       _PLACE_MARK_MODES, "place_mark_mode", violations)
    critique = _enum(getattr(settings_obj, "seed_critique_mode", ""),
                     _CRITIQUE_MODES, "seed_critique_mode", violations)

    if recipe == SEED_RECIPE_SKELETON_LOOK_V1:
        # 스케치는 저작된 conformance brief 에서 나온다 — 저작이 없으면
        # 입력 자체가 없다.
        if not getattr(settings_obj, "structure_seed_variants_enabled", False):
            violations.append(
                "structure_seed_recipe=skeleton_look_v1 인데 "
                "structure_seed_variants_enabled 가 꺼져 있다 — 스케치의 "
                "입력인 저작 브리프가 생성되지 않는다")
        # 야외 파이프가 꺼져 있으면 스텝 전체가 not-applicable 이라 레시피가
        # 조용히 죽는다.
        for flag in ("outdoor_lane_pipe_enabled", "outdoor_lane_plan_enabled"):
            if not getattr(settings_obj, flag, False):
                violations.append(
                    f"structure_seed_recipe=skeleton_look_v1 인데 {flag} 가 "
                    "꺼져 있다 — 야외 스텝이 not-applicable 이라 레시피가 "
                    "적용되지 않는다")

    if place_mark == PLACE_MARK_V1 and recipe != SEED_RECIPE_SKELETON_LOOK_V1:
        violations.append(
            "place_mark_mode=v1 은 structure_seed_recipe=skeleton_look_v1 을 "
            "요구한다 — 목표 문안이 seed_plan 산출이다")

    if (critique == CRITIQUE_STRUCTURE_PRESERVING_V1
            and recipe != SEED_RECIPE_SKELETON_LOOK_V1):
        violations.append(
            "seed_critique_mode=structure_preserving_v1 은 "
            "structure_seed_recipe=skeleton_look_v1 을 요구한다")

    if violations:
        raise AppError(
            code="structure_pipeline.policy_invalid",
            message="구조 파이프 정책 조합이 유효하지 않다: "
                    + "; ".join(violations),
            status_code=422,
        )
    return StructurePipelinePolicy(
        seed_recipe=recipe,
        place_mark_mode=place_mark,
        seed_critique_mode=critique,
    )
```

- [ ] **Step 4: settings 에 3필드를 추가한다**

`backend/app/core/config.py` 의 `outdoor_lane_pipe_enabled: bool = False` 정의 **직후**에 넣는다:

```python
    # ── 구조 파이프 정책 (2026-07-31 편입 설계 §5.1) ────────────────
    # 조합 유효성의 소유자는 app.core.structure_pipeline_policy 이다.
    # 여기서는 값만 받고 검증하지 않는다 — 검증을 두 군데 두면 갈린다.
    # 기본값 = 오늘의 경로(legacy · 전부 off).
    structure_seed_recipe: str = "legacy"
    place_mark_mode: str = "off"
    seed_critique_mode: str = "off"
```

- [ ] **Step 5: 테스트가 통과하는지 확인한다**

Run: `cd backend && python -m pytest tests/core/test_structure_pipeline_policy.py -v`
Expected: PASS — 8 passed

- [ ] **Step 6: 기존 유닛이 깨지지 않았는지 확인한다**

Run: `cd backend && python -m pytest tests/core -q`
Expected: 기존 통과 수 유지, 신규 8 추가. 실패 0.

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

```bash
git add backend/app/core/structure_pipeline_policy.py \
        backend/app/core/config.py \
        backend/tests/core/test_structure_pipeline_policy.py
git commit -m "$(cat <<'EOF'
feat(structure): 파이프 정책 SOT — 조합 유효성을 한 곳이 소유한다

플래그를 늘리면 유효성 판단이 config_hash 나 manifest applicability 로
새어 나간다. hash 는 무효화·감사 장치이지 validity gate 가 아니라서,
불가능한 조합이 조용한 no-op 이 되거나 중간에서 터진다.

실행·claim 이전에 검증하고 불가능한 조합을 AppError 로 거부한다.
미지 값도 기본값으로 흡수하지 않는다 — 오타가 조용한 OFF 가 된다.
기본값은 legacy·전부 off 라 오늘의 경로가 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: 금지 어휘 inventory ratchet

**Files:**
- Create: `backend/tests/core/test_banned_vocabulary_inventory.py`

**Interfaces:**
- Consumes: 없음 (소스 트리 스캔)
- Produces: `KNOWN_DEBT` 상수 — 후속 계획이 항목을 지워 나가는 ratchet 목록

**왜 지금 만드는가.** 어휘 이행(설계 §4)은 후속 계획이지만, 검사 장치를 먼저 놓아야 **새 위반이 늘지 않는다.** 지금 있는 위반은 날짜와 함께 명시 부채로 등록하고, 후속 계획이 하나씩 지운다.

**★실측 결과 (2026-07-31).** 부채는 13건이고, **씨드 경로 밖으로도 번져 있다** — 배경 렌더·항공뷰·프롬프트 서비스·포즈 제공자·연속성 앵커. 어휘 이행이 씨드 계보만의 일이 아니라는 뜻이다.

- [ ] **Step 1: 실측 목록이 아직 유효한지 확인한다**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1 && grep -rln --include="*.py" -iE "signage|signboard" backend/app/ | sort && grep -rln -iE "signage|signboard" prompts/_base/seed_prompt_variants/13.202607310424/ prompts/_base/search_grounded_ref/4.202607301950/ | sort
```
Expected: 아래 Step 2 의 `KNOWN_DEBT` 13항목과 정확히 일치. 다르면 그 차이를 `KNOWN_DEBT` 에 반영한 뒤 진행한다.

- [ ] **Step 2: 실패하는 테스트를 쓴다**

`backend/tests/core/test_banned_vocabulary_inventory.py` 를 만든다:

```python
"""활성 경로 금지 어휘 ratchet (2026-07-31).

## 왜 이 테스트가 있는가

사용자 절대 제약: 작품 고유명사는 물론 **업종 이름도, 장소를 알리는
표시물의 통칭 자체도** 코드·프롬프트에 박지 않는다. 일반화된 표현은
"장소를 알리는 활자나 표시가 들어가는 면" 이고, 그런 면이 있는지·읽힐
크기인지·고칠 값어치가 있는지는 VLM 이 판정한다.

## ratchet 인 이유

어휘 이행은 후속 계획이다. 지금 있는 위반을 당장 없앨 수는 없지만,
**새 위반이 느는 것은 지금부터 막을 수 있다.** 그래서 현재 위반을 날짜와
함께 명시 부채로 등록하고 그 밖의 위반만 실패시킨다. 후속 계획이 부채
목록에서 항목을 지운다 — 목록이 비면 이 테스트가 완전한 금지가 된다.

## 발행된 구 팩은 대상이 아니다

프로젝트 규칙상 발행된 프롬프트 팩은 덮어쓰지 않는다. 활성 팩(로더가
현재 해석하는 버전)만 본다.
"""
from __future__ import annotations

import re
from pathlib import Path

import pytest

REPO = Path(__file__).resolve().parents[3]
APP = REPO / "backend" / "app"

# 장소를 알리는 표시물의 통칭 — 개념 자체를 코드·프롬프트에 박지 않는다.
BANNED = re.compile(r"signage|signboard", re.IGNORECASE)

# ── 명시 부채 (2026-07-31 실측 13건) ────────────────────────────────
# 어휘 이행 계획이 이 목록을 지운다. 여기 없는 파일에서 금지 어휘가
# 나오면 **새 위반**이므로 실패한다.
#
# ★씨드 경로 밖으로도 번져 있다 — 배경 렌더·항공뷰·프롬프트 서비스·
#   포즈 제공자·연속성 앵커. 이행 범위가 씨드 계보만이 아니다.
KNOWN_DEBT = {
    # 활성 파이썬 모듈
    "backend/app/core/steps/outdoor_structure_seed_step.py",
    "backend/app/modules/pipeline/background_render.py",
    "backend/app/modules/pipeline/indoor_shared_pose_provider.py",
    "backend/app/modules/pipeline/location_aerial.py",
    "backend/app/modules/pipeline/seed_prompt_variants.py",
    "backend/app/modules/pipeline/visual_continuity_anchor_provider.py",
    "backend/app/services/prompt_service.py",
    # 활성 팩 (로더가 현재 해석하는 버전)
    "prompts/_base/search_grounded_ref/4.202607301950/brief_system.md",
    "prompts/_base/search_grounded_ref/4.202607301950/form_only_clause.md",
    "prompts/_base/seed_prompt_variants/13.202607310424/author_sys.md",
    "prompts/_base/seed_prompt_variants/13.202607310424/critique_sys.md",
    "prompts/_base/seed_prompt_variants/13.202607310424/judge_sys.md",
    "prompts/_base/seed_prompt_variants/13.202607310424/signage_clause.md",
}


def _active_pack_dirs() -> list[Path]:
    """로더가 현재 해석하는 팩 디렉토리만.

    발행된 구 팩은 덮어쓰지 않는 것이 규칙이므로 검사 대상이 아니다.
    """
    from app.modules.pipeline.search_grounded_ref import (
        REF_PACK_MODULE,
        resolve_ref_pack_version,
    )
    from app.core.steps.outdoor_structure_seed_step import (
        SEED_VARIANTS_PACK_VERSION,
    )
    from app.modules.pipeline.seed_prompt_variants import (
        VARIANTS_PACK_MODULE,
        resolve_variants_pack_version,
    )

    base = REPO / "prompts" / "_base"
    out = []
    for module, resolved in (
        (REF_PACK_MODULE, resolve_ref_pack_version()),
        (VARIANTS_PACK_MODULE,
         resolve_variants_pack_version(SEED_VARIANTS_PACK_VERSION)),
    ):
        d = base / module / resolved
        if d.is_dir():
            out.append(d)
    return out


def _rel(p: Path) -> str:
    return str(p.relative_to(REPO))


def test_no_new_banned_vocabulary_in_active_modules():
    """활성 파이썬 모듈에 새 금지 어휘가 들어오지 않는다."""
    offenders = set()
    for p in APP.rglob("*.py"):
        if BANNED.search(p.read_text(encoding="utf-8", errors="ignore")):
            offenders.add(_rel(p))
    new = sorted(offenders - KNOWN_DEBT)
    assert not new, (
        "금지 어휘가 새로 들어왔다 — 장소를 알리는 표시물의 통칭은 "
        f"코드에 박지 않는다: {new}")


def test_known_debt_entries_still_exist():
    """부채 목록이 현실과 어긋나면 ratchet 이 헐거워진다.

    이행이 끝난 항목은 목록에서 지운다 — 남겨 두면 그 파일에 어휘가
    다시 들어와도 통과한다.
    """
    stale = [d for d in sorted(KNOWN_DEBT)
             if not (REPO / d).is_file()
             or not BANNED.search(
                 (REPO / d).read_text(encoding="utf-8", errors="ignore"))]
    assert not stale, (
        f"부채 목록에 있으나 이미 깨끗하다 — 목록에서 지울 것: {stale}")


def test_active_pack_discovery_is_not_empty():
    """★팩 목록이 비면 parametrize 가 조용히 0건이 되어 아무것도 검사하지
    않는다. 통과를 검사로 착각하지 않도록 별도로 잠근다."""
    dirs = _active_pack_dirs()
    assert dirs, "활성 팩 디렉토리를 하나도 못 찾았다 — 검사가 무력화된다"
    for d in dirs:
        assert list(d.rglob("*.md")), f"{d} 에 팩 파일이 없다"


@pytest.mark.parametrize("pack_dir", _active_pack_dirs(), ids=lambda p: p.name)
def test_active_prompt_packs_are_scanned(pack_dir: Path):
    """활성 팩의 위반도 같은 ratchet 을 탄다."""
    offenders = sorted(
        _rel(f) for f in pack_dir.rglob("*.md")
        if BANNED.search(f.read_text(encoding="utf-8", errors="ignore"))
    )
    new = [o for o in offenders if o not in KNOWN_DEBT]
    assert not new, f"활성 팩에 새 금지 어휘: {new}"
```

- [ ] **Step 3: 테스트를 돌린다**

Run: `cd backend && python -m pytest tests/core/test_banned_vocabulary_inventory.py -v`
Expected: PASS — 4개 테스트 + 활성 팩 parametrize 2건 = 6 passed.

실패하면 메시지가 정확히 어느 파일이 어긋났는지 말해 준다:
- `test_no_new_banned_vocabulary_in_active_modules` 실패 → 목록에 없는 위반 파일이 있다. `KNOWN_DEBT` 에 추가.
- `test_known_debt_entries_still_exist` 실패 → 이미 깨끗한 항목이다. `KNOWN_DEBT` 에서 제거.

- [ ] **Step 4: ratchet 이 실제로 작동하는지 손으로 확인한다**

Run:
```bash
cd backend && printf '\n# signage\n' >> app/core/structure_pipeline_policy.py && python -m pytest tests/core/test_banned_vocabulary_inventory.py::test_no_new_banned_vocabulary_in_active_modules -q ; git checkout app/core/structure_pipeline_policy.py
```
Expected: 테스트가 FAIL 하고 `structure_pipeline_policy.py` 를 새 위반으로 지목한다. 그 뒤 `git checkout` 으로 원복된다.

★이 확인을 건너뛰지 말 것 — 통과만 보고 넘어가면 아무것도 검사하지 않는 테스트를 통과로 착각한다.

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

```bash
git add backend/tests/core/test_banned_vocabulary_inventory.py
git commit -m "$(cat <<'EOF'
test(structure): 활성 경로 금지 어휘 ratchet

장소를 알리는 표시물의 통칭을 코드·활성 팩에 박지 않는다는 제약을
검사 장치로 만든다. 어휘 이행 자체는 후속 계획이라 현재 위반은 날짜와
함께 명시 부채로 등록하고, 그 밖의 위반만 실패시킨다 — 새 위반이 느는
것은 지금부터 막힌다.

부채 항목이 이미 깨끗해지면 목록에서 지우도록 별도 테스트가 강제한다.
남겨 두면 그 파일에 어휘가 다시 들어와도 통과하기 때문이다.

발행된 구 팩은 덮어쓰지 않는 규칙이라 검사 대상이 아니다 — 로더가
현재 해석하는 팩만 본다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: 후보 라운드 불변 경로

**Files:**
- Modify: `backend/app/core/steps/outdoor_structure_form_reference_step.py`
- Test: `backend/tests/core/test_form_reference_round_isolation.py`

**Interfaces:**
- Consumes: `StructurePipelinePolicy` (Task 1)
- Produces:
  - `OutdoorStructureFormReferenceStep._ref_dir(group_id: str, pool_fp: str) -> Path` — `.../structure_form_refs/<gid>/<pool_fp>/`
  - `OutdoorStructureFormReferenceStep._candidate_pool_fp(*, structure_desc: str, world_facts_block: str, source_text_sha: str) -> str` (16자 hex)
  - `round.json` 스키마 — `{"pool_fp": str, "candidates": [{"index": int, "path": str, "sha256": str, "caption": str|None, "image_url": str|None, "source_website_url": str|None}]}`

**무엇이 문제였나.** 현행 `_ref_dir` 은 그룹당 고정 경로이고 다운로드가 `cand_01.png…` 고정 파일명을 쓴다. **재검색이 이전 라운드 후보를 파괴한다** — 갤러리 manifest 에서 344건이 `overwritten` 으로 확인됐다. 표면 재판정이 후보 풀에 의존하기 시작하므로 그 전에 고친다.

- [ ] **Step 1: 실패하는 테스트를 쓴다**

`backend/tests/core/test_form_reference_round_isolation.py` 를 만든다:

```python
"""검색 후보 라운드 불변 경로 (2026-07-31).

## 무엇을 막는가

현행은 그룹당 고정 경로 + 고정 파일명(cand_01.png…)이라 **재검색이 이전
라운드 후보를 파괴한다**. 갤러리 manifest 실측에서 344건이 overwritten
으로 나왔다(R1 106 · R2 100 · R3 69 · R4 69).

설계 §3.1.0: 후보 획득은 `candidate_pool_fp` 가 지배하고, 선택 계약이
바뀌어도 후보는 그대로 남아야 한다. 라운드별 경로 도입이 어떤 재판정보다
먼저다.
"""
from __future__ import annotations

from pathlib import Path

from app.core.steps.outdoor_structure_form_reference_step import (
    OutdoorStructureFormReferenceStep as Step,
)


class _Stub(Step):
    """DB·러너 없이 경로·지문 계산만 부르기 위한 최소 스텁."""

    def __init__(self, tmp: Path):
        self.project_id = "P"
        self.episode_id = "E"
        self._tmp = tmp

    def _projects_root(self) -> Path:  # noqa: D401
        return self._tmp


def test_pool_fingerprint_is_stable_for_same_inputs(tmp_path):
    s = _Stub(tmp_path)
    a = s._candidate_pool_fp(
        structure_desc="d", world_facts_block="w", source_text_sha="s")
    b = s._candidate_pool_fp(
        structure_desc="d", world_facts_block="w", source_text_sha="s")
    assert a == b and len(a) == 16


def test_pool_fingerprint_changes_when_structure_desc_changes(tmp_path):
    s = _Stub(tmp_path)
    a = s._candidate_pool_fp(
        structure_desc="d", world_facts_block="w", source_text_sha="s")
    b = s._candidate_pool_fp(
        structure_desc="OTHER", world_facts_block="w", source_text_sha="s")
    assert a != b


def test_different_rounds_never_share_a_directory(tmp_path):
    """다른 지문 = 다른 디렉토리. 덮어쓸 수가 없다."""
    s = _Stub(tmp_path)
    fp1 = s._candidate_pool_fp(
        structure_desc="d", world_facts_block="w", source_text_sha="s")
    fp2 = s._candidate_pool_fp(
        structure_desc="OTHER", world_facts_block="w", source_text_sha="s")
    assert s._ref_dir("g", fp1) != s._ref_dir("g", fp2)


def test_ref_dir_is_nested_under_group_and_pool_fingerprint(tmp_path):
    s = _Stub(tmp_path)
    d = s._ref_dir("g", "abc123")
    assert d.name == "abc123"
    assert d.parent.name == "g"
    assert d.parent.parent.name == "structure_form_refs"


def test_write_then_new_round_leaves_old_bytes_intact(tmp_path):
    """실제 파일로 확인한다 — 새 라운드가 옛 후보를 건드리지 않는다."""
    s = _Stub(tmp_path)
    old = s._ref_dir("g", "fp_old")
    old.mkdir(parents=True)
    (old / "cand_01.png").write_bytes(b"OLD")

    new = s._ref_dir("g", "fp_new")
    new.mkdir(parents=True)
    (new / "cand_01.png").write_bytes(b"NEW")

    assert (old / "cand_01.png").read_bytes() == b"OLD"


def test_round_manifest_roundtrips(tmp_path):
    """round.json 이 후보 목록을 그대로 되살린다 — 선택 전용 재실행의 입력."""
    s = _Stub(tmp_path)
    d = s._ref_dir("g", "fp")
    d.mkdir(parents=True)
    cands = [{"index": 1, "path": str(d / "cand_01.png"), "sha256": "aa",
              "caption": "c", "image_url": "u", "source_website_url": "w"}]
    s._save_round_manifest(d, pool_fp="fp", candidates=cands)
    assert s._load_round_manifest(d, pool_fp="fp") == cands


def test_round_manifest_rejects_mismatched_pool_fingerprint(tmp_path):
    """다른 지문의 매니페스트를 읽으면 엉뚱한 라운드를 재판정하게 된다."""
    s = _Stub(tmp_path)
    d = s._ref_dir("g", "fp")
    d.mkdir(parents=True)
    s._save_round_manifest(d, pool_fp="fp", candidates=[])
    assert s._load_round_manifest(d, pool_fp="OTHER") is None


def test_round_manifest_missing_returns_none(tmp_path):
    s = _Stub(tmp_path)
    d = s._ref_dir("g", "fp")
    d.mkdir(parents=True)
    assert s._load_round_manifest(d, pool_fp="fp") is None
```

- [ ] **Step 2: 테스트를 돌려 실패를 확인한다**

Run: `cd backend && python -m pytest tests/core/test_form_reference_round_isolation.py -v`
Expected: FAIL — `TypeError: _ref_dir() takes 2 positional arguments but 3 were given` 및 `AttributeError: _candidate_pool_fp`

- [ ] **Step 3: 스텝에 경로·매니페스트 메서드를 넣는다**

`outdoor_structure_form_reference_step.py` 의 `_ref_dir` 을 아래로 **교체**한다 (현재 위치 = `_upsert_ref_asset` 바로 앞):

```python
    # ── 산출 경로 (프로젝트 루트 안 — DB 경로 CHECK 제약) ──────────
    def _projects_root(self) -> Path:
        """테스트가 갈아끼울 수 있는 단일 지점."""
        from app.core.config import settings

        return Path(settings.projects_dir)

    def _ref_dir(self, group_id: str, pool_fp: str) -> Path:
        """후보 라운드의 **불변** 디렉토리.

        ★고정 파일명 + 고정 디렉토리였을 때 재검색이 이전 라운드를
        파괴했다(갤러리 manifest 344건 overwritten 실측). 획득 지문을
        경로에 넣으면 다른 라운드가 같은 자리를 쓸 수 없다.
        """
        return (
            self._projects_root() / self.project_id / "images"
            / self.episode_id / "structure_form_refs" / group_id / pool_fp
        )

    def _candidate_pool_fp(
        self, *, structure_desc: str, world_facts_block: str,
        source_text_sha: str,
    ) -> str:
        """**후보 획득**을 지배하는 입력만의 해시 (설계 §3.1.0).

        선택 계약(심판 구성·pick 프롬프트)은 여기 넣지 않는다 — 넣으면
        판정 문구 한 줄을 고쳤을 뿐인데 재검색이 돌고 후보 풀이 파괴된다.
        """
        import hashlib

        from app.modules.pipeline.search_grounded_ref import (
            MAX_PICK_CANDIDATES,
            SAFE_DOWNLOAD_POLICY_VERSION,
            SEARCH_IMAGE_RESULTS,
            SEARCH_ORCHESTRATOR,
            search_contract_sha,
        )

        payload = {
            "structure_desc": structure_desc,
            "world_facts_block": world_facts_block,
            "source_text_sha": source_text_sha,
            "search_contract": search_contract_sha(),
            "search_image_results": SEARCH_IMAGE_RESULTS,
            "candidate_cap": MAX_PICK_CANDIDATES,
            "orchestrator": SEARCH_ORCHESTRATOR,
            "safe_download_policy": SAFE_DOWNLOAD_POLICY_VERSION,
            "brief_model": "gpt",
        }
        return hashlib.sha256(
            json.dumps(payload, sort_keys=True,
                       ensure_ascii=False).encode("utf-8")
        ).hexdigest()[:16]

    # ── 라운드 매니페스트 (선택 전용 재실행의 입력) ────────────────
    def _round_manifest_path(self, ref_dir: Path) -> Path:
        return ref_dir / "round.json"

    def _save_round_manifest(
        self, ref_dir: Path, *, pool_fp: str,
        candidates: List[Dict[str, Any]],
    ) -> None:
        self._round_manifest_path(ref_dir).write_text(
            json.dumps({"pool_fp": pool_fp, "candidates": candidates},
                       ensure_ascii=False, indent=2),
            encoding="utf-8")

    def _load_round_manifest(
        self, ref_dir: Path, *, pool_fp: str,
    ) -> Optional[List[Dict[str, Any]]]:
        """이 라운드의 후보 목록. 지문이 어긋나면 None.

        지문 대조 없이 읽으면 엉뚱한 라운드를 재판정하게 된다.
        """
        p = self._round_manifest_path(ref_dir)
        if not p.is_file():
            return None
        try:
            data = json.loads(p.read_text(encoding="utf-8"))
        except Exception as exc:  # noqa: BLE001
            logger.warning("%s: round.json 손상 — %s", STEP_ID, exc)
            return None
        if not isinstance(data, dict) or data.get("pool_fp") != pool_fp:
            return None
        cands = data.get("candidates")
        return cands if isinstance(cands, list) else None
```

- [ ] **Step 4: 호출부를 새 시그니처에 맞춘다**

`_run_group` 의 시그니처에 `pool_fp` 를 더하고 다운로드 경로를 바꾼다. `_run_group` 정의부를 아래로 바꾼다:

```python
    def _run_group(
        self, *, group_id: str, structure_desc: str,
        world_facts_block: str, source_text: str, client: Any,
        pool_fp: str, narrow: bool = False,
    ) -> Dict[str, Any]:
```

그리고 그 안의 `ref_dir = self._ref_dir(group_id)` 를 아래로 바꾼다:

```python
        ref_dir = self._ref_dir(group_id, pool_fp)
```

후보 수집 루프가 끝난 직후(`if not candidates:` 바로 **앞**)에 매니페스트 저장을 넣는다:

```python
        # 라운드 매니페스트 — 선택 전용 재실행이 이 목록만 읽는다.
        self._save_round_manifest(ref_dir, pool_fp=pool_fp,
                                  candidates=candidates)
```

- [ ] **Step 5: `_execute` 의 호출을 고친다**

`_execute` 안에서 `fingerprint = self._group_fingerprint(...)` 를 계산하는 자리 **바로 뒤**에 `pool_fp` 를 계산하고, `_run_group` 두 호출(1차·narrow 재시도)에 넘긴다:

```python
                pool_fp = self._candidate_pool_fp(
                    structure_desc=desc,
                    world_facts_block=world_facts_block,
                    source_text_sha=source_text_sha)
```

`rec = self._run_group(...)` → `rec = self._run_group(..., pool_fp=pool_fp)`
`rec2 = self._run_group(..., narrow=True)` → `rec2 = self._run_group(..., pool_fp=pool_fp, narrow=True)`

★narrow 재시도는 검색어가 달라져 **다른 후보 집합**이 된다. 같은 `pool_fp` 디렉토리에 이어 쓰면 1차 후보를 덮는다 — narrow 호출에는 `pool_fp=pool_fp + "_narrow"` 를 넘긴다:

```python
                    rec2 = self._run_group(
                        group_id=gid, structure_desc=desc,
                        world_facts_block=world_facts_block,
                        source_text=source_text, client=client,
                        pool_fp=pool_fp + "_narrow", narrow=True)
```

- [ ] **Step 6: 테스트가 통과하는지 확인한다**

Run: `cd backend && python -m pytest tests/core/test_form_reference_round_isolation.py -v`
Expected: PASS — 8 passed

- [ ] **Step 7: 기존 form_reference 유닛이 깨지지 않았는지 확인한다**

Run: `cd backend && python -m pytest tests/core/test_form_reference_target_parity.py tests/modules/test_search_grounded_ref_v3.py -q`
Expected: PASS — 실패 0

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

```bash
git add backend/app/core/steps/outdoor_structure_form_reference_step.py \
        backend/tests/core/test_form_reference_round_isolation.py
git commit -m "$(cat <<'EOF'
fix(form_ref): 후보를 라운드 불변 경로에 쓴다 — 재검색이 이전 라운드를 파괴하던 것

그룹당 고정 디렉토리 + 고정 파일명(cand_01.png…)이라 재검색이 이전
라운드 후보를 덮어썼다. 갤러리 manifest 실측에서 344건이 overwritten
이었다(R1 106 · R2 100 · R3 69 · R4 69).

획득 지문(candidate_pool_fp)을 경로에 넣어 다른 라운드가 같은 자리를
쓸 수 없게 한다. 지문에는 **획득을 지배하는 입력만** 넣는다 — 선택
계약을 넣으면 판정 문구 한 줄에 재검색이 돌아 같은 파괴가 재발한다.

round.json 에 후보 목록을 남겨 선택 전용 재실행의 입력으로 쓴다.
지문이 어긋난 매니페스트는 읽지 않는다 — 엉뚱한 라운드를 재판정하게
된다.

narrow 재시도는 검색어가 달라 다른 후보 집합이므로 별도 라운드다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: 지문 2분리 — 선택만 다시 도는 경로

**Files:**
- Modify: `backend/app/core/steps/outdoor_structure_form_reference_step.py`
- Test: `backend/tests/core/test_form_reference_round_isolation.py` (테스트 추가)

**Interfaces:**
- Consumes: Task 3 의 `_candidate_pool_fp` · `_load_round_manifest` · `_ref_dir`
- Produces:
  - `_form_pick_fp() -> str` (16자 hex) — 형태 선택 계약만의 해시
  - `_reuse_decision(prior: dict, *, pool_fp: str, form_pick_fp: str) -> str` — `"full"` | `"reselect"` | `"none"`
  - CP entry 추가 필드 — `candidate_pool_fp: str` · `form_pick_fp: str`

**무엇을 막는가.** 현행 `_group_fingerprint` 는 심판 구성까지 한 해시에 담는다. 그래서 **선택 계약만 바꿔도 그룹 전체가 stale** 이 되고 재검색이 돈다 — "재검색 없이 재판정" 결정과 라운드 보존을 동시에 깬다.

- [ ] **Step 1: 실패하는 테스트를 추가한다**

`backend/tests/core/test_form_reference_round_isolation.py` 끝에 이어 붙인다:

```python
# ─────────────────────────────────────────────────────────────────────
# 지문 2분리 — 선택 계약 변경이 재검색을 부르지 않는다
# ─────────────────────────────────────────────────────────────────────
def _prior(pool_fp: str, form_pick_fp: str, path: Path, sha: str) -> dict:
    return {"status": "ok", "form_ref_path": str(path),
            "form_ref_sha256": sha,
            "candidate_pool_fp": pool_fp, "form_pick_fp": form_pick_fp}


def _write_ref(tmp_path: Path) -> tuple[Path, str]:
    import hashlib

    p = tmp_path / "ref.png"
    p.write_bytes(b"PNG")
    return p, hashlib.sha256(b"PNG").hexdigest()


def test_pick_fingerprint_does_not_depend_on_structure_desc(tmp_path):
    """선택 계약 지문은 그룹 입력과 무관하다 — 전 그룹 공통이다."""
    s = _Stub(tmp_path)
    assert len(s._form_pick_fp()) == 16


def test_unchanged_everything_reuses_fully(tmp_path):
    s = _Stub(tmp_path)
    p, sha = _write_ref(tmp_path)
    prior = _prior("POOL", s._form_pick_fp(), p, sha)
    assert s._reuse_decision(prior, pool_fp="POOL",
                             form_pick_fp=s._form_pick_fp()) == "full"


def test_changed_pick_contract_reselects_without_research(tmp_path):
    """★핵심 — 선택 계약만 바뀌면 재검색이 아니라 재판정이다."""
    s = _Stub(tmp_path)
    p, sha = _write_ref(tmp_path)
    prior = _prior("POOL", "OLD_PICK", p, sha)
    assert s._reuse_decision(prior, pool_fp="POOL",
                             form_pick_fp="NEW_PICK") == "reselect"


def test_changed_pool_fingerprint_requires_full_run(tmp_path):
    s = _Stub(tmp_path)
    p, sha = _write_ref(tmp_path)
    prior = _prior("OLD_POOL", "PICK", p, sha)
    assert s._reuse_decision(prior, pool_fp="NEW_POOL",
                             form_pick_fp="PICK") == "none"


def test_missing_file_forces_full_run_even_if_fingerprints_match(tmp_path):
    """지문이 맞아도 파일이 없으면 재사용할 것이 없다."""
    s = _Stub(tmp_path)
    prior = _prior("POOL", s._form_pick_fp(), tmp_path / "gone.png", "ab")
    assert s._reuse_decision(prior, pool_fp="POOL",
                             form_pick_fp=s._form_pick_fp()) == "none"


def test_tampered_file_forces_full_run(tmp_path):
    """sha 불일치 = 우리가 고른 그 사진이 아니다."""
    s = _Stub(tmp_path)
    p, _ = _write_ref(tmp_path)
    prior = _prior("POOL", s._form_pick_fp(), p, "0" * 64)
    assert s._reuse_decision(prior, pool_fp="POOL",
                             form_pick_fp=s._form_pick_fp()) == "none"


def test_failed_prior_is_not_reused(tmp_path):
    s = _Stub(tmp_path)
    p, sha = _write_ref(tmp_path)
    prior = dict(_prior("POOL", s._form_pick_fp(), p, sha), status="failed")
    assert s._reuse_decision(prior, pool_fp="POOL",
                             form_pick_fp=s._form_pick_fp()) == "none"


def test_legacy_entry_without_split_fingerprints_requires_full_run(tmp_path):
    """구 CP(v3 shape)에는 분리 지문이 없다 — 조용히 재사용하지 않는다."""
    s = _Stub(tmp_path)
    p, sha = _write_ref(tmp_path)
    prior = {"status": "ok", "form_ref_path": str(p), "form_ref_sha256": sha}
    assert s._reuse_decision(prior, pool_fp="POOL",
                             form_pick_fp=s._form_pick_fp()) == "none"
```

- [ ] **Step 2: 테스트를 돌려 실패를 확인한다**

Run: `cd backend && python -m pytest tests/core/test_form_reference_round_isolation.py -v -k "fingerprint or reuse or reselect or legacy or tampered or failed_prior"`
Expected: FAIL — `AttributeError: '_Stub' object has no attribute '_form_pick_fp'`

- [ ] **Step 3: 선택 지문과 재사용 판정을 넣는다**

`_candidate_pool_fp` 정의 **바로 뒤**에 추가한다:

```python
    def _form_pick_fp(self) -> str:
        """**형태 선택**을 지배하는 계약만의 해시 (설계 §3.1.0).

        그룹 입력이 들어가지 않는다 — 선택 계약은 전 그룹 공통이다.
        이 값이 바뀌면 **재검색이 아니라 재판정**을 돈다.
        """
        import hashlib

        from app.modules.prompt_loader import load_prompt
        from app.modules.pipeline.search_grounded_ref import (
            PICK_JUDGES,
            REF_PACK_MODULE,
            resolve_ref_pack_version,
        )

        payload = {
            "pick_system": load_prompt(
                REF_PACK_MODULE, "pick_system",
                version=resolve_ref_pack_version()),
            "pick_judges": list(PICK_JUDGES),
        }
        return hashlib.sha256(
            json.dumps(payload, sort_keys=True,
                       ensure_ascii=False).encode("utf-8")
        ).hexdigest()[:16]

    def _reuse_decision(
        self, prior: Dict[str, Any], *, pool_fp: str, form_pick_fp: str,
    ) -> str:
        """이전 산출을 어디까지 쓸 수 있는가.

        "full"     = 그대로 (검색·판정 없음)
        "reselect" = 후보는 그대로, **판정만** 다시 (검색 없음)
        "none"     = 처음부터

        구 CP(분리 지문 없음)는 "none" 이다. 조용히 재사용하면 어떤
        계약으로 고른 참조인지 아무도 모르게 된다.
        """
        if (prior or {}).get("status") != "ok":
            return "none"
        if prior.get("candidate_pool_fp") != pool_fp:
            return "none"
        path_s = prior.get("form_ref_path") or ""
        sha = prior.get("form_ref_sha256") or ""
        if not path_s or not sha:
            return "none"
        p = Path(path_s)
        if not p.is_file() or _sha_file(p) != sha:
            return "none"
        if prior.get("form_pick_fp") != form_pick_fp:
            return "reselect"
        return "full"
```

- [ ] **Step 4: `_execute` 를 새 판정으로 갈아끼운다**

`_execute` 의 그룹 루프에서 `fingerprint = self._group_fingerprint(...)` 부터 `continue` 까지의 재사용 블록을 아래로 **교체**한다:

```python
                pool_fp = self._candidate_pool_fp(
                    structure_desc=desc,
                    world_facts_block=world_facts_block,
                    source_text_sha=source_text_sha)
                pick_fp = self._form_pick_fp()
                prior = prev_groups.get(gid) or {}
                decision = ("none" if mode == "force"
                            else self._reuse_decision(
                                prior, pool_fp=pool_fp, form_pick_fp=pick_fp))
                if decision == "full":
                    groups[gid] = dict(prior, candidate_pool_fp=pool_fp,
                                       form_pick_fp=pick_fp, reused=True)
                    done += 1
                    reused += 1
                    logger.info("%s: %s 재사용 (지문 일치·파일 검증 통과)",
                                STEP_ID, gid)
                    continue
                if decision == "reselect":
                    # 후보는 그대로. 판정 계약만 바뀌었으므로 **검색하지
                    # 않는다** — 재검색하면 라운드가 하나 더 생기고 비용도
                    # 무의미하다 (설계 §3.1.0).
                    logger.info("%s: %s 선택 계약 변경 — 후보 재판정만",
                                STEP_ID, gid)
                rec = self._run_group(
                    group_id=gid, structure_desc=desc,
                    world_facts_block=world_facts_block,
                    source_text=source_text, client=client,
                    pool_fp=pool_fp,
                    reselect_only=(decision == "reselect"))
```

`_run_group` 실패 후 narrow 재시도 블록의 `rec["group_fingerprint"] = fingerprint` 를 아래로 바꾼다:

```python
                rec["candidate_pool_fp"] = pool_fp
                rec["form_pick_fp"] = pick_fp
```

- [ ] **Step 5: `_run_group` 에 선택 전용 경로를 넣는다**

`_run_group` 시그니처에 `reselect_only: bool = False` 를 더하고, 저작·검색·다운로드 구간을 아래로 감싼다. `# 1) 원본어 검색 지시문 저작` 부터 후보 수집 루프 끝까지를 다음으로 교체한다:

```python
        ref_dir = self._ref_dir(group_id, pool_fp)
        brief: Dict[str, Any] = {}
        search: Dict[str, Any] = {}
        candidates: List[Dict[str, Any]] = []

        if reselect_only:
            # 후보는 이미 이 라운드 디렉토리에 있다. **읽기만** 한다.
            cached = self._load_round_manifest(ref_dir, pool_fp=pool_fp)
            if cached:
                candidates = [dict(c) for c in cached]
                logger.info("%s: %s 후보 %d장 재사용 (검색 없음)",
                            STEP_ID, group_id, len(candidates))
            else:
                logger.warning(
                    "%s: %s 라운드 매니페스트 결손 — 검색부터 다시 한다",
                    STEP_ID, group_id)
                reselect_only = False

        if not reselect_only:
            # 1) 원본어 검색 지시문 저작 (씬 원문은 언어 판정 전용 — 격리)
            brief = call_structured(
                "structure_form_ref_brief", load_brief_system(),
                (build_search_brief_user(
                    structure_desc=structure_desc,
                    world_facts_block=world_facts_block,
                    source_text=source_text)
                 + ("\n\n" + load_narrow_retry_hint() if narrow else "")),
                build_search_brief_schema(),
                project_config={"structure_form_ref_brief": {"model": "gpt"}},
                schema_name="structure_form_ref_brief",
                opik_metadata={"project_id": self.project_id,
                               "episode_id": self.episode_id,
                               "step": STEP_ID},
            )

            # 2) 웹 이미지 검색 (사진 결과)
            search = search_reference_images(
                client,
                directive_native=brief.get("search_directive_native") or "",
                terms_native=brief.get("search_terms_native") or [],
            )

            # 3) 후보 다운로드 (안전 정책)
            for img in search.get("images") or []:
                if len(candidates) >= MAX_PICK_CANDIDATES:
                    break
                dest = ref_dir / f"cand_{len(candidates) + 1:02d}.png"
                if not download_candidate(img.get("image_url") or "", dest,
                                          img.get("thumbnail_url") or ""):
                    continue
                candidates.append({
                    "index": len(candidates) + 1,
                    "path": str(dest),
                    "sha256": _sha_file(dest),
                    "caption": img.get("caption"),
                    "image_url": img.get("image_url"),
                    "source_website_url": img.get("source_website_url"),
                })
            # 라운드 매니페스트 — 선택 전용 재실행이 이 목록만 읽는다.
            self._save_round_manifest(ref_dir, pool_fp=pool_fp,
                                      candidates=candidates)
```

- [ ] **Step 6: 구 `_group_fingerprint` · `_reusable` 을 제거한다**

두 메서드를 삭제한다. 남겨 두면 어느 쪽이 진짜 재사용 판정인지 다음 사람이 알 수 없다.

Run: `cd backend && grep -n "_group_fingerprint\|_reusable" app/ -r`
Expected: 출력 없음

- [ ] **Step 7: CP 산출에 분리 지문을 싣는다**

`_execute` 의 반환 `data` 블록에서 `"target"` 딕셔너리에 추가한다:

```python
                    "form_pick_fp": self._form_pick_fp(),
```

- [ ] **Step 8: 테스트가 통과하는지 확인한다**

Run: `cd backend && python -m pytest tests/core/test_form_reference_round_isolation.py -v`
Expected: PASS — 16 passed

- [ ] **Step 9: 회귀 확인**

Run: `cd backend && python -m pytest tests/core tests/modules -q`
Expected: 실패 0

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

```bash
git add backend/app/core/steps/outdoor_structure_form_reference_step.py \
        backend/tests/core/test_form_reference_round_isolation.py
git commit -m "$(cat <<'EOF'
fix(form_ref): 획득 지문과 선택 지문을 나눠 재판정이 재검색을 부르지 않게

_group_fingerprint 하나가 검색 계약과 심판 구성을 함께 담고 있었다.
그래서 선택 계약만 고쳐도 그룹 전체가 stale 이 되어 재검색이 돌고,
고정 파일명 시절이라면 후보 풀까지 파괴했다 — "재검색 없이 재판정"
결정과 라운드 보존을 동시에 깨는 경로였다.

candidate_pool_fp(획득) / form_pick_fp(선택)로 나누고 재사용 판정을
full · reselect · none 세 갈래로 만든다. reselect 는 round.json 만
읽고 검색을 건너뛴다.

구 CP 는 분리 지문이 없으므로 none 이다. 조용히 재사용하면 어떤
계약으로 고른 참조인지 아무도 모르게 된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## 후속 계획 (이 계획의 범위 밖)

스펙이 5개 스텝을 걸치므로 계획을 나눴다. 각각이 독립적으로 동작·검증 가능하다.

| 계획 | 내용 | 선행 |
|---|---|---|
| **1 (이 문서)** | 정책 SOT · 어휘 ratchet · 라운드 분리 · 지문 2분리 | — |
| 2 | **사전조사 편입** — `typology_prior` production 배선, 4축(siting·scale·composition·naming) + 표면 pick (§3.1·§3.1.1) | 1 |
| 3 | `seed_plan` 신규 — 저작 이관 · 어휘 이행 · 전략 tri-state · `structure_checks[]` · **evidence-bound 입지·규모·이름 + 근거 기록** (§3.2·§4) | 2 |
| 4 | `skeleton` 신규 + `seed` 개정 — 구조 게이트 · 두-권위 관할절 주입 (§3.3·§3.4) | 2·3 |
| 5 | `place_mark` 신규 — typed no-op · effective map · 소비자 배선 (§3.5) | 4 + **판정 변동성 해소** |

★계획 3이 계획 2에 매달린다 — 사전조사는 저작의 **입력**이므로 저작
개정보다 먼저다. 특히 `siting` 축이 저작의 입지 확정보다 앞서야 한다.
저작이 입지를 먼저 지어내면 사전조사가 **틀린 유형을 조사해서 도장을
찍는다**(§3.1.1 실측).

## 이 계획이 하지 않는 것

- 이미지·LLM 동작을 바꾸지 않는다. 산출물의 **내용**은 이 계획 전후로 같다.
- 어휘 이행 자체를 하지 않는다 — 검사 장치만 놓는다(계획 3).
- 표면 pick·이름 관례를 넣지 않는다(계획 2).
- 기존 라운드의 후보를 옮기거나 지우지 않는다. 구 평면 경로의 파일은 그대로 두고, 새 라운드부터 분리 경로를 쓴다.
