# Background Topology Planner Experiment — plan (W0, rev W0b)

> 상태: 작성자 = claude / 검토 = codex (W0 NEEDS_REVISION_BEFORE_W1 → W0b patch 후 재검토 대기). 코드/이미지/API/DB write 0. production 미수정.
>
> W0 → W0b 변경 (Codex 2026-05-24 NEEDS_REVISION):
> - BLOCKING 1 — evidence_id deterministic hash + `source_span` 필드 추가 + `input_manifest` sha256 강제 (§2-C, §2-B, §4-C, §6-A).
> - BLOCKING 2 — `EvidenceExtractionRuleSet` (§1-G) 7번째 contract 신설 + generic collector 가 lexicon/ruleset 만 받음 (§3, §4-B).
> - BLOCKING 3 — `experiment_rooftop_spatial_bg.load_l05_shots` / `ShotMeta` 의존 제거 + 본 script 안 generic `load_selected_shots` 신설 (§2-A, §3, §4-A).
> - IMPORTANT 1 — `location_catalog` source_kind 를 acceptance 5종에 포함 (§2-A, §6-A).
> - IMPORTANT 2 — `match_terms` + `inference_basis` 필드 추가, `confidence_band != observed` 시 필수 (§2-C, §4-C, §6-A).
> - IMPORTANT 3 — `--acceptance-mode sample_fixture|override` CLI flag + override 시 count threshold 는 advisory (§5, §6-B).
> - IMPORTANT 4 — `--include-diagnostic-runs` additive-only test (§2-A, §4-E).
> - MINOR — §1-A continuity_scope `same_property` 추가, §2-C inferred_candidate 예시 generic.
>
> 직전 wave: `background_place_grouping_experiment` (W2e) + `background_spatial_decision_experiment` (W3d) APPROVED_FOR_USER_REVIEW. 두 산출 모두 **diagnostic/reference input** 일 뿐 **본 wave 의 SOT 가 아니다**. 본 wave 는 방향 전환이다.
>
> 본 plan 의 목적: **topology-first generic background topology planner** 의 SOT 흐름 설계 + W1 dry-run evidence pack 산출. 배경 이미지 생성/기존 chain_bg 끼워맞추기/sample-specific keyword 보강은 본 wave 범위 아님.

## ★★★ 범위 (2026-05-24 사용자 standing rule)

This is a **generic background topology planner experiment**. 특정 시나리오/에피소드/location/캐릭터/소품 전용 코드·프롬프트·테스트 금지. L05 / 옥탑방 / 수리영 / 민숙 등은 **sample fixture / evidence 로만 허용** — generic rule body / 함수명 / data contract / test invariant 에 sample-specific label 박지 않는다. 모든 sample 값은 `SampleFixtureSpec` + input manifest + output artifact 안에만 격리한다. production pipeline code 수정 0. 신규 실험 script + tests + `scripts_output/background_topology_planner_experiment/` 산출만 허용. 사용자 standing rule [[feedback-no-scenario-specific-coding]] 참조.

---

## 0. 핵심 원칙 (topology-first SOT)

1. **배경 이미지부터 만들지 않는다.** chain_bg / master plate / derived unit 생성은 본 wave 출력 아님.
2. **기존 chain_bg variant 를 뒤에서 끼워 맞추지 않는다.** W2e 의 `chain_bg_decomposition.*` 는 diagnostic-only.
3. **새 SOT 흐름:**

   ```
   source evidence pack
       → topology candidate (set/space/state/structural_version, 후보만)
       → shot spatial intent (per-shot 의도 + camera intent)
       → background unit need (어떤 unit 이 실제로 필요한지)
       → spatial decision ledger (resolved / needs_user_decision / impossible)
   ```

4. **이번 W1 = evidence 수집만.** topology 확정 X, unit 결정 X, sample rule auto-promotion X. 모든 evidence 는 quote + source_kind + source_ref 를 가져야 한다.
5. **재사용 가능한 generic engine.** 다른 fixture 로 교체했을 때 같은 SOT 흐름이 동작해야 한다 — 모든 sample literal 은 `SampleFixtureSpec` 격리.
6. **read-only, dry-run.** DB read 만 (PostgreSQL `theroad`). DB write 0, 이미지 0, openai/gemini/fal/PIL/requests/httpx 0. production code 0.

---

## 1. Data contracts (7) — required fields + sample-specific 금지 rule

> 본 §1 은 **본 실험 전체 (W1→W6) 의 SOT contract** 다. W1 dry-run 은 evidence pack + EvidenceExtractionRuleSet 만 emit. topology / shot intent / unit need / ledger 는 후속 wave 에서 채워짐. W1 산출은 `candidate_contract_targets` 필드로 미래 contract 가 어디로 흘러갈지만 표시.
>
> 본래 6 contract (place / set / structural / shot intent / unit need / ledger) 에 W1 이 만드는 **EvidenceExtractionRuleSet** 을 더해 7 contract — BLOCKING 2 fix.

### 1-A. `PlaceContinuityGroup`

세계관/지리적 의미의 거대 연속성 단위. 같은 건물/같은 옥상 단지/같은 부지 같은 "장소 동일성" 묶음.

```json
{
  "place_group_id": "<slug>",
  "label": "<human label>",
  "member_location_short_ids": ["<L##>"],
  "physical_place_key": "<stable key>",
  "continuity_scope": "single_building | same_property | adjacent_buildings | shared_outdoor | independent",
  "evidence_refs": ["<evidence_id>"]
}
```

- 필수: `place_group_id`, `member_location_short_ids` (≥1), `continuity_scope`, `evidence_refs` (≥1).
- 모든 필드는 sample-specific label 을 직접 코드에 박지 않는다 — `SampleFixtureSpec.place_group_seeds` 에서만 생성.
- `evidence_refs` 의 각 entry 는 `source_evidence_pack.json` 의 evidence_id 와 1:1 매칭.

### 1-B. `SetTopologyGraph`

PlaceContinuityGroup 안의 촬영 세트 정합성 graph. node + edge 양쪽 모두 evidence-backed.

```json
{
  "set_topology_id": "<slug>",
  "parent_place_group_id": "<slug>",
  "continuity_policy": "geometry_locked | loose_continuity | independent",
  "nodes": [
    {
      "node_id": "<slug>",
      "node_type": "room | zone | boundary | exterior | aperture",
      "contained_in": null | "<node_id>",
      "active_status": "active | needs_decision | inactive",
      "evidence_refs": ["<evidence_id>"]
    }
  ],
  "edges": [
    {
      "edge_id": "<slug>",
      "from": "<node_id>",
      "to": "<node_id>",
      "edge_type": "connected | visible_through | contains | aperture_between",
      "evidence_refs": ["<evidence_id>"]
    }
  ]
}
```

- 필수: 각 node 의 `node_id`, `node_type`, `active_status`, `evidence_refs` (≥1). 각 edge 의 `from`, `to`, `edge_type`, `evidence_refs` (≥1).
- `node_type=zone` 은 `contained_in` 필수.
- evidence 가 0 인 node/edge 는 **생성 금지** — `unknowns` 로 분리 emit.

### 1-C. `StructuralStateModel`

geometry 변경 (StructuralVersion) vs surface/state 변경 (StateLayer) 의 분리 모델.

```json
{
  "structural_versions": [
    {
      "version_id": "<slug>",
      "scope_node_ids": ["<node_id>"],
      "geometry_change_kind": "initial | remodel | wall_added | wall_removed | demolition | extension | none_documented",
      "starts_at_scene": null | "<scene_ref>",
      "evidence_refs": ["<evidence_id>"]
    }
  ],
  "state_layers": [
    {
      "layer_id": "<slug>",
      "scope_node_ids": ["<node_id>"],
      "affects": ["surface" | "furniture" | "lighting" | "damage" | "blood" | "emptiness" | "weather"],
      "geometry_preserving": true,
      "evidence_refs": ["<evidence_id>"]
    }
  ]
}
```

- 필수: structural_versions ≥1 (default = `version_id=sv_initial`, `geometry_change_kind=initial`).
- state_layers 의 `geometry_preserving=true` 강제. false 가 필요하면 StructuralVersion 으로 reclassify.
- 모든 entry `evidence_refs` ≥1. 단 `sv_initial` 은 evidence 0 허용 (default seed) — 그 외 version 은 evidence 강제.

### 1-D. `ShotSpatialIntent`

per-shot 의 공간/카메라/상태 의도 (확정 X — 후보 + ambiguity).

```json
{
  "shot_id": "<scene_index>_<shot_index>",
  "loc_short_ids": ["<L##>"],
  "primary_space_candidates": ["<node_id>"],
  "visible_secondary_candidates": ["<node_id>"],
  "state_layer_candidates": ["<layer_id>"],
  "structural_version_hint": "<version_id>",
  "camera_intent": {
    "framing": "wide | doorway_wide | medium | close | macro | establishing",
    "anchor_subject": "<node_id> | <entity_short_id> | unknown",
    "height_hint": "eye_level | floor_level | low | high | unknown",
    "movement_hint": "static | pan | track | dolly | unknown"
  },
  "ambiguity_flags": ["<flag>"],
  "evidence_refs": ["<evidence_id>"]
}
```

- 필수: `shot_id`, `loc_short_ids` (≥1 또는 명시적 `[]` + ambiguity flag), `evidence_refs` (≥1).
- `primary_space_candidates` 가 ≥2 면 `ambiguity_flags` 에 `multi_primary_candidate` 강제.
- `camera_intent.framing` enum 외 값 금지.

### 1-E. `BackgroundUnitNeed`

실제로 필요한 background generation unit 의 *필요성* 선언 (생성 X — 필요성만).

```json
{
  "unit_need_id": "<slug>",
  "unit_role": "master | derived_state | derived_camera | derived_combo",
  "anchored_node_id": "<node_id>",
  "anchored_camera_intent_key": "<framing>@<height_hint>",
  "anchored_state_layer_id": null | "<layer_id>",
  "based_on_unit_need_id": null | "<unit_need_id>",
  "required_for_shot_ids": ["<shot_id>"],
  "evidence_refs": ["<evidence_id>"]
}
```

- 필수: `unit_need_id`, `unit_role`, `anchored_node_id`, `required_for_shot_ids` (≥1), `evidence_refs` (≥1).
- cross-product 폭증 invariant: 총 unit_need 수 ≤ master + Σ(derived_state) + Σ(derived_camera) + Σ(derived_combo). cross-product (`space × state × camera`) 금지.
- W1 wave 에서는 emit 0. 후속 wave 가 채움 — 본 contract 는 future placeholder.

### 1-F. `SpatialDecisionLedger`

resolved / needs_user_decision / impossible 의 최종 결정 ledger. 사용자에게 묻는 rollup 도 여기에.

```json
{
  "shot_decisions": [
    {
      "shot_id": "<shot_id>",
      "decision_status": "resolved_to_master | resolved_to_derived_state | resolved_to_derived_camera | requires_derived_combo | needs_user_decision | impossible_without_topology_update",
      "bound_unit_need_ids": ["<unit_need_id>"],
      "rule_trace_ids": ["<rule_id>"],
      "evidence_refs": ["<evidence_id>"]
    }
  ],
  "rollup_requests": [
    {
      "rollup_id": "<slug>",
      "decision_type": "choose_space_node | choose_multi_space_policy | activate_space_node | topology_update | choose_camera_variant",
      "affected_shot_ids": ["<shot_id>"],
      "options": ["<option_label>"],
      "evidence_refs": ["<evidence_id>"]
    }
  ]
}
```

- 필수: 각 shot_decision `shot_id`, `decision_status`, `rule_trace_ids` (≥1), `evidence_refs` (≥1).
- W1 wave 에서는 emit 0. 후속 wave 가 채움.

### 1-G. `EvidenceExtractionRuleSet` (BLOCKING 2 fix)

generic collector body 가 sample literal 을 보지 않고도 evidence 를 뽑을 수 있도록 분리하는 rule data set. W1 이 직접 emit.

```json
{
  "ruleset_id": "<slug>",
  "ruleset_version": "<semver-like>",
  "lexicon_hash": "<sha256 hex>",
  "rules": [
    {
      "rule_id": "<slug>",
      "evidence_type": "<enum value from §2-C>",
      "match_kind": "literal_term | regex | structural_field | row_field_value | json_pointer",
      "match_terms": ["<term1>", "<term2>"],
      "match_pattern": null | "<regex or pointer>",
      "candidate_contract_targets": ["<contract.field>"],
      "confidence_band_default": "observed | inferred_candidate | ambiguous",
      "negation_terms": ["<term>"],
      "applies_to_source_kinds": ["planning_doc", "episode_fulltext", ...]
    }
  ]
}
```

- 필수: 모든 rule 의 `rule_id`, `evidence_type`, `match_kind`, `candidate_contract_targets` (≥1), `confidence_band_default`, `applies_to_source_kinds` (≥1).
- `match_terms` 또는 `match_pattern` 중 ≥1 필수 (match_kind 에 맞춰).
- **lexicon source 분리**:
  - `SampleFixtureSpec.evidence_lexicon` 가 sample 별 term list. 다른 fixture 추가 시 별도 lexicon 만 갈아끼움.
  - generic builtin lexicon 은 sample-agnostic term 만 (e.g. `["rooftop", "옥상", "balcony", "stairs", "window", "door"]` 처럼 도메인 일반어).
  - **collector function body 에 term literal 박지 않는다** — rule_set 을 받아서 순회.
- `applies_to_source_kinds` 는 evidence-row source_kind 와 같은 enum.
- `lexicon_hash` 는 input_manifest 에도 mirror — evidence_id 의 결정성에 영향.
- 본 ruleset 자체도 W1 산출. 파일명 `evidence_extraction_ruleset.json`. Codex/사용자가 산출만 보고도 어떤 rule 로 어떤 evidence 가 뽑혔는지 trace 가능.

---

## 2. W1 dry-run — evidence pack only

본 wave 는 **evidence 수집 + coverage report** 만. topology / shot intent / unit need / ledger 는 contract field 만 정의 (§1) + W1 산출에서 emit 0.

### 2-A. 입력 (모두 read-only)

- `episode.fulltext` (DB `episode` table where id = `SampleFixtureSpec.episode_id`).
- `project_registry.planning_doc_text` (DB `project_registry` where id = `SampleFixtureSpec.project_id`).
- selected `scene_still` rows — `shot_description`, `scene_summary`, `visible_entities_json`, `scene_index`, `shot_index`. **generic loader (BLOCKING 3 fix)**: 본 script 안의 `load_selected_shots(session, project_id, episode_id, loc_short_ids=None)`. 기준 = `episode_id == fixture.episode_id` AND `is_selected=True` AND `still_index >= 0` AND `(status IS NULL OR status != 'stale')`. `loc_short_ids` 가 주어지면 visible_entities_json 안에 substring 매칭 (단순 필터, sample fixture 한정 — generic 호출자는 None 으로 전체 로드 가능). `experiment_rooftop_spatial_bg` 의 `load_l05_shots` / `ShotMeta` 의존 **제거**.
- `entity_canon` (DB) — entity_type = `character` / `prop` / `outlook`: name + description + metadata_json (entity_catalog source_kind).
- `entity_canon` (DB) — entity_type = `location`: name + description + metadata_json (location_catalog source_kind). 별도 분리해야 `location_catalog` evidence 가 source_kind 로 카운트됨 — IMPORTANT 1 fix.
- (optional) 기존 source grounding bible JSON — `SampleFixtureSpec.source_run_path / source_bible_filename` 이 존재하면 read. evidence_kind = `existing_artifact`.
- (optional, `--include-diagnostic-runs`) `background_place_grouping_experiment` 와 `background_spatial_decision_experiment` 의 최신 run dir — diagnostic input only. evidence_kind = `existing_artifact` 만, 다른 source 의 evidence count / confidence_band / candidate_contract_targets 에 영향 0 — IMPORTANT 4 fix.

### 2-B. 산출 (run dir: `scripts_output/background_topology_planner_experiment/<run_id>/`)

| 파일 | 내용 |
| --- | --- |
| `input_manifest.json` | 입력 source 식별자, **각 source 의 `sha256` hash + length** (text body 와 row dump 모두), missing_inputs, fixture id, plan version, generic_evidence_lexicon hash |
| `source_evidence_pack.json` | EvidenceRow list (아래 §2-C) |
| `source_evidence.tsv` | evidence pack 의 사람이 읽기 좋은 평탄화 |
| `evidence_extraction_ruleset.json` | §1-G `EvidenceExtractionRuleSet` JSON (W1 emit) |
| `source_coverage_summary.json` | source 별/evidence_type 별/confidence 별 count + ambiguity / leakage guard 결과 + acceptance_metrics |
| `index.html` | 사람이 읽는 첫 화면 (§2-D) |
| `run_meta.json` | run_id, plan_version, generated_at, fixture_id, args, file checksums, lexicon hash |

### 2-C. `EvidenceRow` 스키마

```json
{
  "evidence_id": "ev_<deterministic_hash>",
  "source_kind": "planning_doc | episode_fulltext | shot_description | scene_summary | entity_catalog | location_catalog | existing_artifact",
  "source_ref": "<stable pointer, e.g. shot:<still_id>, episode:<id>, entity:<canon_id>, artifact:<path>>",
  "source_span": {
    "kind": "text_offset | row_pointer | artifact_pointer",
    "char_start": null | <int>,
    "char_end": null | <int>,
    "row_id": null | "<stable id>",
    "row_field": null | "<column or json path>",
    "artifact_path": null | "<path>",
    "artifact_pointer": null | "<json pointer or jq path>",
    "source_hash": "<sha256 hex of source body>"
  },
  "quote": "<원문 인용 — sentence-window, ≤ 280 chars>",
  "normalized_quote": "<공백/escape 정리한 quote>",
  "evidence_type": "place_hint | set_hint | space_hint | zone_hint | boundary_hint | door_window_hint | furniture_hint | state_hint | camera_hint | movement_hint | entity_layout_hint | ambiguity_hint",
  "candidate_contract_targets": ["PlaceContinuityGroup.member_location_short_ids", "SetTopologyGraph.nodes", "ShotSpatialIntent.primary_space_candidates", ...],
  "confidence_band": "observed | inferred_candidate | ambiguous",
  "match_terms": ["<term>"],
  "inference_basis": "<짧은 generic 설명, confidence_band != observed 시 필수>",
  "sample_fixture_tags": ["<optional, data-only tag>"],
  "extracted_by": "<rule_id, e.g. shot_visible_entities, generic_location_type_keyword>"
}
```

규칙:
- 모든 evidence row 는 `evidence_id`, `source_kind`, `source_ref`, `source_span`, `quote`, `evidence_type`, `confidence_band`, `extracted_by` 8 필드 필수.
- `evidence_id` 는 **deterministic hash** (BLOCKING 1 fix). 산출 공식:
  - `evidence_id = "ev_" + sha256(source_kind | source_ref | source_span_canonical_json | evidence_type | normalized_quote)[:16]`
  - `source_span_canonical_json` = `source_span` dict 의 deterministic JSON (key sort + null-stable). text_offset 은 `(char_start, char_end)`, row_pointer 는 `(row_id, row_field)`, artifact_pointer 는 `(artifact_path, artifact_pointer)` 가 hash 의 anchor.
  - run 마다 / extract 순서 변동 시 / 같은 quote 가 다른 위치에 있어도 동일 source span 이면 같은 id. quote 내용이 다르면 id 변경.
- `source_span` 필수 분기:
  - text source (planning_doc / episode_fulltext / shot_description / scene_summary): `kind="text_offset"`, `char_start`, `char_end`, `source_hash` 필수.
  - DB row (entity_catalog / location_catalog): `kind="row_pointer"`, `row_id` (entity canon_id 등), `row_field` (e.g. `description`, `metadata_json.space_profile`), `source_hash` 필수.
  - existing_artifact: `kind="artifact_pointer"`, `artifact_path`, `artifact_pointer` (e.g. `$.sub_spaces[0]`), `source_hash` 필수.
- `quote` 는 sample-specific 원문 인용 OK (data). 단 코드/rule body 에 quote 를 복제하지 않는다.
- `confidence_band`:
  - `observed`: 원문에서 직접 인용된 사실 (e.g. shot description 의 visible_entity, episode 의 명시 sub_space).
  - `inferred_candidate`: 직접 인용은 아니나 명백한 추론 (e.g. generic location-type keyword 매칭 — "rooftop / 옥상" 등 lexicon 등록 term).
  - `ambiguous`: 두 가지 이상의 해석 가능 / negation / 약한 매칭.
- `match_terms`: confidence_band != `observed` 일 때 필수, generic lexicon 의 term ID list. observed 시 optional.
- `inference_basis`: confidence_band != `observed` 일 때 필수, 추론 근거를 generic 표현으로 (sample literal 금지). observed 시 생략 가능.
- `candidate_contract_targets` 는 §1 의 contract 필드 경로 (string). 미래 wave 가 어디로 흘러야 하는지 표시만.
- `sample_fixture_tags` 는 fixture-level 분류 (예: `["fixture:<fixture_id>", "loc_short_id:<L##>"]`) — data only, code rule 에 사용 X.

### 2-D. `index.html` 요구

첫 화면 순서:

1. **헤더**: "Background Topology Planner — generic experiment (sample fixture: `<fixture_id>`)". diagnostic-only 입력 표시.
2. **흐름 요약**: source evidence → topology candidate → shot spatial intent → background unit need → spatial decision ledger. 현재 wave = **W1 = evidence pack only**.
3. **source 별 evidence count** (planning_doc / episode_fulltext / shot_description / scene_summary / entity_catalog / location_catalog / existing_artifact).
4. **evidence_type 별 count**.
5. **confidence_band 분포** (observed / inferred_candidate / ambiguous).
6. **unresolved/ambiguous evidence table** (ambiguity_hint 또는 confidence_band=ambiguous, ≤ 50행, 더 있으면 truncated 표시).
7. **sample fixture leakage guard 결과** (test 가 통과했는지 / scope leakage phrases 0건).
8. **다음 단계**: "W2 = topology candidate generation (set/space/state, structural_version)" 명시.

shot 별 question 18개 표시 금지 (rollup 은 후속 wave).

---

## 3. 신규 script 구조

- `backend/scripts/experiment_background_topology_planner.py`
  - import 허용 범위:
    - `app.core.database.SessionLocal`
    - `app.models.project`: `Episode`, `EntityCanon`, `SceneStill` (read-only)
    - `app.models.catalog`: `ProjectRegistry` (read-only)
    - 표준 라이브러리만 (re, json, csv, html, dataclasses, datetime, pathlib, uuid, argparse, hashlib, sys)
  - import 금지:
    - `openai`, `google.genai`, `google.generativeai`, `fal_client`, `PIL`, `requests`, `httpx`.
    - `experiment_rooftop_spatial_bg`, `experiment_background_place_grouping`, `experiment_background_spatial_decision`, `experiment_rooftop_source_grounding` 등 **모든 sibling experiment 모듈** — BLOCKING 3 fix. 새 wave 는 기존 실험을 코드 의존성으로 끌고 오지 않는다 (필요하면 diagnostic input 으로 산출 JSON 만 read).
  - module 구성:
    - 상단 `SAMPLE_FIXTURE_*` 상수 + `SampleFixtureSpec` dataclass + `build_sample_fixture_l05_spec()` + `EvidenceLexicon` (sample-specific term 분리).
    - `EvidenceRow` dataclass + helper (`_make_evidence_id`, `_canonical_source_span_json`, `_normalize_quote`, `_extract_sentence_window`, `_sha256_hex`).
    - generic read-only DB loader (본 script 안에 신규 정의, sibling experiment 의존 0):
      - `load_episode_fulltext(session, episode_id)`
      - `load_planning_doc(session, project_id)`
      - `load_selected_shots(session, project_id, episode_id, loc_short_ids=None)` — `SceneStill` filter (§2-A).
      - `load_entity_catalog(session, project_id)` — entity_type ∈ {character, prop, outlook}.
      - `load_location_catalog(session, project_id)` — entity_type = `location` (IMPORTANT 1 fix).
    - generic collectors (모두 rule_set + lexicon input, sample-agnostic; collector body 에 sample literal 0):
      - `collect_evidence_from_planning_doc(text, ruleset, lexicon, spec)`
      - `collect_evidence_from_episode_fulltext(text, ruleset, lexicon, spec)`
      - `collect_evidence_from_shots(shots, ruleset, lexicon, spec)`
      - `collect_evidence_from_entity_catalog(entities, ruleset, lexicon, spec)`
      - `collect_evidence_from_location_catalog(entities, ruleset, lexicon, spec)`
      - `collect_evidence_from_existing_artifact(path, ruleset, lexicon, spec)` (optional bible JSON)
      - `collect_evidence_from_diagnostic_run(run_dir, ruleset, lexicon, spec)` (`--include-diagnostic-runs` 시 — additive only)
    - `build_evidence_extraction_ruleset(spec)` — fixture 의 EvidenceLexicon + generic builtin lexicon 합쳐 `EvidenceExtractionRuleSet` 생성.
    - `build_source_coverage_summary(evidence_rows, spec, acceptance_mode)`
    - `write_outputs(...)`, `render_html(...)`, `parse_args()`, `main()`.
  - DB query 는 fixture 의 `project_id` / `episode_id` 또는 CLI override 사용. fixture 추가 시 spec + lexicon 만 추가하면 동일 흐름.
- `backend/tests/scripts/test_experiment_background_topology_planner.py`
  - guard tests + evidence shape tests + coverage tests + scope leakage guard + dry-run smoke.

---

## 4. TDD invariants (≥ 24 tests)

### 4-A. Import / DB-write / Sibling-experiment guards
1. `test_script_does_not_import_production_pipeline` — `from app.` 허용 범위는 `app.core.database`, `app.models.project`, `app.models.catalog` 만 (production pipeline / services / image / network 0).
2. `test_script_does_not_import_image_or_network` — openai/google.genai/google.generativeai/fal_client/fal/PIL/requests/httpx import 0.
3. `test_script_does_not_import_sibling_experiments` — `experiment_rooftop_spatial_bg`, `experiment_background_place_grouping`, `experiment_background_spatial_decision`, `experiment_rooftop_source_grounding` import 0 (BLOCKING 3 fix).
4. `test_script_does_not_call_db_write` — script body 에 `session.add(`, `session.commit(`, `session.flush(`, `session.delete(`, `INSERT `, `UPDATE `, `DELETE FROM` 패턴 0 (string literal 포함).

### 4-B. Sample fixture isolation / generic rule body guard
5. `test_sample_fixture_constants_namespaced` — `SAMPLE_FIXTURE_PROJECT_ID`, `SAMPLE_FIXTURE_EPISODE_ID`, `SAMPLE_FIXTURE_SHORT_ID`, `SampleFixtureSpec`, `EvidenceLexicon` 정의 존재 + 같은 literal 이 generic 함수 body 에 중복 등장 0.
6. `test_scope_leakage_phrases_not_in_generic_rule_body` — generic collector 함수 body (AST 로 함수 이름 prefix `collect_`, `build_evidence_extraction_ruleset`, `load_*` 추출) 안에 `FORBIDDEN_LEAKAGE_LITERALS` (예: `L05`, `옥탑방`, `수리영`, `민숙`, `안방`, `pg_rooftop_villa`, `sg_rooftop_interior`) 0 — sample fixture spec/lexicon/output data 는 허용 (BLOCKING 2 fix, 단순 phrase 만 보지 않고 literal 도 봄).
7. `test_collector_body_takes_ruleset_argument` — 모든 generic collector function signature 에 `ruleset` (또는 `rule_set`) 와 `lexicon` parameter 가 있고, body 에서 직접 `match_terms` literal 정의 0 — BLOCKING 2 fix.
8. `test_fixture_swap_does_not_require_collector_edit` — synthetic `EvidenceLexicon` 로 fixture 갈아끼우면 collector code 수정 없이 다른 term 으로 evidence 추출 — BLOCKING 2 fix.

### 4-C. Evidence row schema / determinism
9. `test_evidence_row_required_fields` — 모든 emit evidence 가 `evidence_id`, `source_kind`, `source_ref`, `source_span`, `quote`, `evidence_type`, `confidence_band`, `extracted_by` 8 필드 100% 채움.
10. `test_evidence_source_kind_enum` — `source_kind` 가 enum 만.
11. `test_evidence_type_enum` — `evidence_type` 가 enum 만.
12. `test_evidence_confidence_band_enum` — `confidence_band` 가 enum 만.
13. `test_inference_basis_required_when_not_observed` — `confidence_band != "observed"` 인 row 는 `inference_basis` 와 `match_terms` 필수 (IMPORTANT 2 fix).
14. `test_evidence_id_is_deterministic_sha256_prefix` — `evidence_id` 가 `ev_` + 16-hex prefix, 입력 동일 시 두 run 결과 동일, normalized_quote 만 바뀌어도 id 변경 (BLOCKING 1 fix).
15. `test_evidence_id_stable_under_extraction_order_shuffle` — collector 출력 list 를 shuffle 해도 evidence_id 집합 동일 (BLOCKING 1 fix).
16. `test_evidence_id_unique_within_run` — 한 run 안에서 evidence_id 충돌 0.
17. `test_source_span_kind_branch_fields_required` — text_offset 은 char_start/char_end/source_hash, row_pointer 는 row_id/row_field/source_hash, artifact_pointer 는 artifact_path/artifact_pointer/source_hash 필수.
18. `test_input_manifest_includes_per_source_hash_and_length` — input_manifest.json 의 각 source entry 에 `sha256` + `length_chars` (text) 또는 `row_count` (catalog) 가 있고, `evidence_extraction_ruleset.lexicon_hash` 가 manifest mirror 와 일치 (BLOCKING 1 fix).

### 4-D. W1 scope (no topology / units / decisions)
19. `test_w1_does_not_emit_topology_or_units` — 산출 디렉토리에 `set_topology.json` / `background_unit_need.json` / `spatial_decision_ledger.json` / `shot_spatial_intent.json` / `structural_state_model.json` 등 future contract artifact 존재 0.
20. `test_w1_emits_evidence_extraction_ruleset` — 산출 디렉토리에 `evidence_extraction_ruleset.json` 존재 (BLOCKING 2 fix).

### 4-E. Diagnostic / CLI / dry-run guards
21. `test_diagnostic_input_only_adds_existing_artifact_rows` — `--include-diagnostic-runs` on vs off 비교 시 (a) `source_kind != existing_artifact` row count 동일, (b) confidence_band/candidate_contract_targets 분포 동일, (c) 차이 = `existing_artifact` row 만 추가 (IMPORTANT 4 fix).
22. `test_no_serve_flag_short_circuits_http_server` — `--no-serve` 시 http.server import / bind / spawn 0.
23. `test_dry_run_smoke_no_network` — sample fixture 로 collector 1회 실행 후 evidence pack > 0, urlopen / socket / requests / httpx 호출 0 (monkeypatch 가드).
24. `test_run_meta_contains_required_keys` — `run_meta.json` 에 `run_id`, `plan_version`, `generated_at`, `fixture_id`, `args`, `outputs`, `lexicon_hash` 키 존재.

### 4-F. Acceptance metric
25. `test_acceptance_strict_only_for_sample_fixture_run` — `acceptance_mode == "sample_fixture"` 면 §6 strict (count threshold hard fail), `acceptance_mode == "override"` 면 advisory metric 만 emit, hard fail 0 (IMPORTANT 3 fix).
26. `test_location_catalog_source_kind_present_for_sample_fixture` — sample fixture run 에서 `source_kind = "location_catalog"` row ≥ 1 (IMPORTANT 1 fix).

### 4-G. HTML / plan.md
27. `test_index_html_declares_generic_scope_and_w1_only` — `index.html` 본문에 `generic`, `sample fixture`, `W1 = evidence pack only`, `next: W2 = topology candidate generation` 명시.
28. `test_plan_md_declares_generic_scope_and_sample_fixture` — `scripts_output/background_topology_planner_experiment/plan.md` 에 `generic`, `sample fixture`, `topology-first`, 7 contract 이름 모두 명시.

backend pytest 전체 회귀 0 강제. tests/scripts 전 회귀 0 강제.

---

## 5. CLI

```bash
PYTHONPATH=backend python backend/scripts/experiment_background_topology_planner.py \
  --output-root scripts_output/background_topology_planner_experiment \
  --run-id <optional, default=YYYYMMDD_HHMM_<short_uuid>> \
  --project-id <optional, default=SampleFixtureSpec.project_id> \
  --episode-id <optional, default=SampleFixtureSpec.episode_id> \
  --include-diagnostic-runs \
  --acceptance-mode sample_fixture|override \
  --no-serve
```

- `--no-serve` 는 dry-run 확실히 dry — http server bind 0.
- `--project-id` / `--episode-id` 가 fixture 와 다르면 `--acceptance-mode override` 가 자동 설정되어 acceptance metric 은 advisory 만 emit (IMPORTANT 3 fix).
- `--acceptance-mode sample_fixture` (기본, fixture id/episode 와 일치 시) 는 strict — §6 hard threshold 가 fail-closed. `--acceptance-mode override` 는 advisory metric (count / hash 만 emit, threshold 검사 X).

---

## 6. W1 acceptance (Codex 검토 시 점검 항목)

### 6-A. Sample fixture run (acceptance_mode = `sample_fixture`, 기본, hard fail-closed)

1. `source_evidence_pack.json` 의 evidence row 개수 ≥ 50.
2. source_kind 분포 hard: planning_doc / episode_fulltext / shot_description / entity_catalog / **location_catalog** 5종 모두 ≥ 1 row (IMPORTANT 1 fix).
3. evidence_type 분포 hard: place_hint / space_hint / state_hint / camera_hint 최소 4종 ≥ 1 row.
4. confidence_band 분포 명시 (observed / inferred_candidate / ambiguous 모두 ≥ 1 row 이면 best, 0 이면 그 이유가 산출 표시됨).
5. `confidence_band != observed` 인 모든 row 가 `inference_basis` + `match_terms` 채움 (IMPORTANT 2 fix).
6. `source_coverage_summary.json` 의 sample fixture leakage guard = clean (`FORBIDDEN_LEAKAGE_LITERALS` 0건 in generic collector body).
7. `evidence_extraction_ruleset.json` 의 rule_id ↔ evidence rows.extracted_by 1:N 매칭 (모든 extracted_by 가 ruleset 안에 존재) — BLOCKING 2 fix.
8. `input_manifest.json` 의 모든 source entry 가 sha256 hash + length 채움. `lexicon_hash` 와 mirror 일치 — BLOCKING 1 fix.
9. evidence_id 결정성: 같은 입력으로 두 번 실행 시 산출 evidence_id 집합 100% 동일 — BLOCKING 1 fix.
10. `index.html` 첫 화면 §2-D 7가지 섹션 모두 존재.
11. W1 산출에 topology / shot intent / unit need / ledger artifact 0. `evidence_extraction_ruleset.json` 은 존재.
12. TDD ≥ 24 tests + backend pytest 회귀 0 + tests/scripts 회귀 0.
13. production code 미수정 (git diff backend/app 0 lines).
14. DB write 0, image 0, network 0 — Codex 가 source grep + import guard test 로 정적 검증 가능.

### 6-B. Override run (acceptance_mode = `override`, advisory only)

- 위 1~3 의 count threshold 는 advisory metric 으로 표기만 (hard fail X).
- 5~9 의 schema invariant (deterministic id, source_span branch, inference_basis 강제, ruleset cross-ref, manifest hash) 는 **여전히 hard** — 이는 evidence pack 의 SOT 무결성이라 fixture 와 무관.
- 10~14 도 hard.

---

## 7. Out of scope (이번 W1)

- topology candidate 자동 생성 (PlaceContinuityGroup / SetTopologyGraph / StructuralStateModel emit).
- shot spatial intent 확정 (`ShotSpatialIntent` JSON 출력).
- background unit need 결정 (`BackgroundUnitNeed` 산출).
- spatial decision ledger / rollup 생성 (`SpatialDecisionLedger` 산출).
- 새 이미지 / chain_bg / floor plan 생성.
- 기존 chain_bg variant decomposition 또는 reverse-fitting.
- LLM 호출 (Gemini / OpenAI / fal).
- production pipeline 모듈 수정 (backend/app/*).

후속 wave (참고):
- W2: topology candidate generation (`SetTopologyGraph` + `StructuralStateModel`).
- W3: shot spatial intent + ambiguity rollup.
- W4: background unit need (master / derived_state / derived_camera / derived_combo).
- W5: spatial decision ledger (resolve / needs_user_decision / impossible).
- W6+: 실제 image generation (별도 wave).

---

## 8. Codex 리뷰 기록 + W1 구현 시 주의 사항

### 8-A. W0 → W0b 리뷰 결과 (2026-05-24)

Codex 1차 리뷰 (W0): NEEDS_REVISION_BEFORE_W1 — BLOCKING 3개 + IMPORTANT 4개 + MINOR 2개. 헤더 참조.
Codex 2차 리뷰 (W0b, 본 SOT): **APPROVED_FOR_W1**. 이전 BLOCKING 3건 모두 닫힘 — evidence_id deterministic / EvidenceExtractionRuleSet / sibling loader 제거 / location_catalog / inference_basis / acceptance_mode / diagnostic additive 모두 §1~§6 SOT 에 반영.

### 8-B. W1 구현 시 non-blocking note (Codex W0b)

1. **evidence_id dedup** — 같은 `(source_kind, source_ref, source_span, evidence_type, normalized_quote)` 5-tuple 에 여러 rule 이 매칭되면 중복 row 생성 금지. 대신 candidate_contract_targets + extracted_by 를 merge 해 단일 row 로 유지. `test_evidence_id_unique_within_run` (§4-C 16) 이 hard guard.
2. **HTML/log 에서 acceptance mode 가시화** — `index.html` 와 `source_coverage_summary.json` 에 `acceptance_mode = sample_fixture | override` 명시. 사용자/Codex 가 한 눈에 strict run 인지 advisory run 인지 알 수 있어야 함.
3. **§8 stale doc cleanup** (본 섹션) — 완료.

### 8-C. 향후 wave entry condition

- W2 entry: W1 evidence pack + ruleset 산출 + Codex W1 APPROVED 후. W2 SOT = §1-B `SetTopologyGraph` + §1-C `StructuralStateModel` 생성. evidence_refs 는 본 W1 evidence_id (deterministic hash) 로 참조.
- W3+ entry: 각 wave 의 plan 별도 작성, 본 plan.md §1 의 contract 만 SOT.

### 8-D. W1 → W1b narrow patch (2026-05-24, Codex W1 NEEDS_REVISION 후)

W1b closes 3 IMPORTANT items raised by Codex W1 review: (a) inference_basis literal leak — generic 표현으로 좁히고 sample term 은 match_terms/quote/source_span 에만 남김, (b) deterministic loader ordering — `load_selected_shots / load_entity_catalog / load_location_catalog` 에 SQL `order_by` + Python fallback sort, (c) `_dedup_merge` basis preservation — distinct sorted "; " join. 추가로 `write_outputs` 가 evidence_rows 를 `evidence_id` 기준 정렬해 cross-run byte stability 안정화. plan §1~§6 SOT 변경 없음. 신규 TDD 5 test (4-H 그룹).

### 8-E. W1b → W1c HTML/report enhancement (2026-05-24, Codex W1b HTML diagnose 후)

W1c is review/report enhancement over an approved W1b evidence snapshot; it does not re-extract evidence or change raw evidence artifacts. CLI 새 mode `--from-run-dir <path>` 가 W1b run dir 의 `source_evidence_pack.json / source_evidence.tsv / evidence_extraction_ruleset.json / input_manifest.json / source_coverage_summary.json` 5 파일을 byte-identical copy 하고, 같은 run dir 내 `index.html` + 새 `readiness_report.json` + 새 `run_meta.json` 만 W1c metadata 로 emit. 새 W1c run dir 만들고 W1b run dir 은 APPROVED snapshot 으로 고정.

HTML 첫 화면 순서: §0 Header + W2 readiness summary (최상단, ambiguous=0 옆에 "W1 = literal-term only, ambiguity classification 미실행" 경고) → §1 흐름 요약 → §2 counts 축약 → §3 Place/Set/Space evidence (candidate 별 quote N=5) → §4 State evidence (match_term group + noisy flag) → §5 Camera evidence (shot 별) → §6 Noisy evidence candidates (negation marker 표시) → §7 Top source quotes (source_kind 별 대표 N) → §8 detailed W2 readiness (dimension 별).

Noisy heuristic = generic negation lexicon (Korean phrase: `없이`, `없`, `않`, `아니`, `금지`, `제외`, `배제`, `보이지`, `나타나지`, `흔적을 찾지`, `감쪽같이`, `감쪽 같이`, `사라졌`, `사라진`; English regex word boundary: `\bno\b`, `\bnot\b`, `\bwithout\b`, `\black\b`, `\blacks\b`, `\babsent\b`, `\babsence\b`, `\bexcluded\b`, `\bexclude\b`) 와 row.match_terms 중 ≥1 이 quote 안에 동시 등장 시 `noisy_candidate=true`. bare `안 ` 금지 (`안방` 오탐). structural rows (match_terms 비어있음) 는 noisy 대상 제외. raw evidence pack 의 `confidence_band` / 필드 변경 0 — HTML render 시 동적 계산.

W2 readiness = dimension report (단일 yes/no 아님): `topology_candidate_ready` (place/space/boundary/door_window coverage) / `state_model_ready` (state distinct + noisy ratio) / `camera_intent_ready` (camera + movement distinct) / `overall_w2_readiness` (worst dimension). Thresholds 는 `SampleFixtureSpec.readiness_thresholds` 에 dimension 별 key 로 정의 (sample literal 격리). 결과 = `readiness_report.json` + HTML §0/§8.

State category labeling (normal/cleaned/vandalized/corpse 등) 은 W1c 범위 밖 (W2 이후). `SampleFixtureSpec` 추가 필드 = `readiness_thresholds`, `negation_terms` 두 개만 — `state_category_seeds` 미리 추가 금지.

신규 TDD 13 test (4-I 그룹): HTML 섹션 존재 / noisy heuristic + bare 안방 오탐 방지 + English word boundary + structural row 제외 / state group / camera group / top quotes / readiness thresholds spec-loaded / readiness camera_intent weak when sparse / W1b raw vs W1c raw byte-identical copy / W1c does not modify source-run raw files / readiness_report.json emit / run_meta marks W1c.

### 8-F. W1c → W1d narrow patch (2026-05-24, Codex W1c NEEDS_REVISION_BEFORE_USER_REVIEW 후)

W1d closes 2 IMPORTANT items raised by Codex W1c review: (a) `state_model` readiness 가 noisy_rows > 0 일 때 자동 `conditional` 로 다운그레이드 — coverage threshold 만 충족하고 noisy=0 일 때만 `yes`, (b) `topology_candidate` 의 `boundary_terms` / `door_window_terms` metric key 가 distinct term 처럼 보이는 row count 였음. `boundary_rows` / `door_window_rows` 로 rename (place/space 는 진짜 distinct terms 유지). plan §8-E test count 표기 11 → 13 MINOR 함께. 신규 TDD 3 test (4-J 그룹). raw evidence pack/ruleset/manifest/coverage/tsv 변경 0 — W1b APPROVED snapshot 무결성 유지.

### 8-G. W1d → W1e collector patch + display dedup (2026-05-24, Codex W1d HTML 직접 검토 후) — **DEPRECATED, §9 pivot 으로 대체**

> 2026-05-24 사용자 두 차례 강력 질책 ("그 글자로 구분하려하고 있는것 같은데???? 그건 안된다 절대로" / "패턴 인지(조사나 글자 단위 구분 방식)로 절대 개발하면 안된다!!!!!!") 로 본 §8-G 의 literal boundary 접근 (Korean particle, `_is_valid_literal_match`, `_iter_term_positions` 정교화) 은 폐기. 새 SOT 는 §9. literal lexicon matcher 자체가 의미 추출 primary path 가 될 수 없으며 audit/helper/coverage hint 어떤 명목으로도 살리지 않음. 자세한 standing rule = [[feedback-no-literal-substring-meaning]].
>
> 진행 중이던 W1e 코드/test diff (uncommitted) 는 사용자 지시 "되돌리지 말고 보고" 에 따라 그대로 보존 — 다음 wave (§9-A) 에서 cleanup.



W1e is a **collector patch wave** (not a review-only overlay) producing a new raw run that supersedes W1b. W1b APPROVED snapshot 은 그대로 보존 — W1e 는 별도 새 run dir 에서 full collector rerun. closes 1 BLOCKING + 3 IMPORTANT items raised by Codex W1d HTML review:

- BLOCKING 0 — selected shot evidence 가 fixture location 으로 scope 되지 않음. main() 에서 `load_selected_shots(..., loc_short_ids=[spec.location_short_id])` 로 호출해 L05 14 shot 만 추출 (전체 61 shot 중). 숲/골목/마트/바다 등 다른 location 의 scene_summary/shot_description evidence 가 state/camera/source quote 에 섞이는 문제 해소.
- IMPORTANT A — `_iter_term_positions` 가 substring 매칭만 — 1-char Korean term (`문`, `방`) 이 `문화/질문/주문/문턱` 같은 다른 단어 안에서 trigger. 새 `_is_valid_literal_match(text, term, idx)` generic guard 추가: ASCII/alnum term 은 regex \b token boundary, Hangul 1-char term 은 좌/우 boundary 검사 (left=문자열 시작 or non-Hangul/non-alnum, right=문자열 끝/공백/문장부호 or 한국어 조사 은/는/이/가/을/를/에/에서/으로/로/와/과/도/만 등), Hangul 2+ char 은 기존 substring 허용 유지. `boundary_required` flag 안 도입 (W1e 범위 외).
- IMPORTANT B — §3/§4/§5/§6/§7 HTML 의 sample quote 가 normalized_quote 기준 중복 dedup 0. `_dedup_rows_for_display(rows, limit)` helper 신설 + 5 render 함수 모두 적용. 같은 quote 가 N row 면 1 row + `+N duplicate rows hidden` 표시. raw evidence pack 은 그대로 보존 — display-only dedup.
- IMPORTANT C — `시신` (37 rows) 가 `state_hint` 에 분류되었으나 'state' 가 아니라 body/object presence. `SAMPLE_FIXTURE_L05_STATE_TERMS` 에서 `("시신", "state_hint")` → `("시신", "entity_layout_hint")` 로 reclass. `핏자국` 은 `state_hint` 유지 (표면/혈흔/환경 상태). evidence_type enum 변경 없음 — lexicon 만.

비차단 (W1e 미포함): camera lexicon 보강 (D, 실제 데이터 부족 신호 유지), noisy heuristic 의미 분석 (E, HTML 문구만 "broad candidate; may include semantic false positives" 강화), entity_catalog source-kind applicability 정리 (W1f/W2-entry 별도).

신규 TDD 8 test (4-K 그룹). plan §1~§6 SOT 변경 없음 — collector 가 같은 contract 의 더 정확한 evidence 를 emit 할 뿐. raw evidence_id 가 변경되어 W1b run 과는 다른 pack — W1b snapshot 은 보존하고 W1e 가 새 APPROVED snapshot 후보.
