"""시험용 **합성 원고** — 샷까지 붙여 끝점을 무료로 태운다.

## 왜 짓나

C(c) 계약을 재려면 원고가 필요한데, 저장된 실제 원고는

- **크다**(66천자) — 한 번 돌리는 데 오래 걸리고 재현이 무겁다
- **무엇이 정답인지 모른다** — 「이 대상이 두 구간에 걸쳐 있다」를 사람이
  일일이 세야 한다
- 유료 판정을 태우면 **돈이 든다**

그래서 **여섯 반례가 원고 안에 실제로 들어 있는** 작은 원고를 짓는다.
정답을 코드가 알고 있으므로 끝점 판정이 결정적이다.

## ★지키는 것

- **작품 고유명사를 안 쓴다.** 등장하는 것은 전부 **역할어**(운전사·승객)와
  **일반명사**(가방·표·정류장)다. 특정 작품·시대·지역을 안 가리킨다.
- 이 파일은 **시험 데이터**다. 지문·코드 규칙에 이 낱말들을 넣지 않는다
  (사용자 계약 1·2).
- 산출 모양은 **저장 체크포인트와 같다** — `scene_save.segments` 와
  `shot_validator.scenes[].shots[]` 의 실제 칸을 그대로 쓴다. 안 그러면
  「내 도구가 프로덕션과 다른 입력을 보낸다」가 된다.

## 심어 둔 여섯 반례

| # | 무엇 | 어디 |
|---|---|---|
| 1 | **같은 실물이 두 구간에** | `가방` — 1장과 4장 |
| 2 | **이름이 겹치는 다른 실물** | `표` (승차권) vs `표` (게시판의 표) |
| 3 | **한 구간 안에 두 번** | `정류장 표지` — 2장에서 두 문장 |
| 4 | **부분 관계** | `대합실` 은 `정류장` 의 부분 |
| 5 | **다섯 갈래가 다 있다** | 인물·장소·소품·장소부분·아웃룩 |
| 6 | **한 번만 나오는 어려운 것** | `옛 요금표` — 3장에 한 번 |
"""
from __future__ import annotations

from typing import Any, Dict, List, Tuple

#: 장면 하나 = (제목, 본문 문단들, [(샷 설명, [인물])]).
#:
#: ★★본문을 길게 쓰는 이유 — 구간(`BUNDLE_TARGET=3000`)이 **여럿** 나와야
#:  「같은 실물이 두 구간에 걸친다」가 성립한다. 짧게 지었더니 원고 전체가
#:  한 구간이라 그 반례가 **원고 안에 없었다.** 아래 `assert_planted()` 가
#:  그것을 검사한다 — 「심었다」고 적어 놓고 안 심은 것을 막는다.
#: ★★★**실제로 읽히는 산문**이어야 한다 (Codex 2026-08-31).
#:
#:  두 번 틀렸다. ①처음엔 한 문장을 `* 22` 로 이어 붙였고 ②고친다고 조각을
#:  8×8×8 로 조합했는데, 문자열만 208가지로 달랐을 뿐 「기다리는 이들」 30회 ·
#:  「옷깃」 19회 · 「손이 주머니」 23회처럼 **같은 시각 대상을 수십 번**
#:  되풀이했다. 첫 유료 판을 망친 것은 문자열 중복이 아니라 **비현실적 반복이
#:  엔티티 출현과 merge 를 지배한 것**이라, 조각 순열은 원인 제거가 아니다.
#:
#:  ★그래서 손으로 쓴다. 작품 고유명사 없이 흔한 이름만으로.
_SCENES: List[Tuple[str, List[str], List[Tuple[str, List[str]]]]] = [
    (
        "S#1. 정류장 앞 / 낮",
        [
            "차가 멎고 문이 열린다. 운전사가 낡은 가방을 들고 내려선다. "
            "가방 손잡이는 한쪽이 떨어져 나가 끈으로 묶여 있다.",

            "운전사는 짐을 발치에 내려놓고 허리를 편다. 차체 옆면에 앉은 "
            "먼지가 손바닥 자국을 남긴다. 창문 줄이 햇빛을 한 칸씩 끊어 낸다.",

            "승객 하나가 뒤늦게 내린다. 주머니를 뒤져 표를 꺼내 들여다본다. "
            "종이 끝이 접혀 글자 한 자가 반쯤 가려져 있다.",

            "승객은 그것을 다시 접어 넣고 걸음을 옮긴다. 운전사는 뒷모습을 "
            "잠깐 보다가 짐을 도로 집어 든다.",

            "길 건너에서 자전거 한 대가 지나간다. 바퀴가 자갈을 밟는 소리가 "
            "짧게 끊긴다. 그 소리가 멀어지고 나서야 차 문이 닫힌다.",

            "운전사가 차 뒤로 돌아가 무언가를 확인한다. 손등으로 이마를 훔치고 "
            "다시 앞으로 걸어 나온다. 걸음이 느리다.",

            "빈 자리에 햇빛이 고인다. 아무도 서 있지 않은 동안 그늘이 조금 "
            "자리를 옮긴다. 길 끝에서 개 한 마리가 방향을 바꾼다.",

            "차 안에서는 아직 엔진이 식지 않아 미열이 올라온다. 좌석 등받이 "
            "천이 햇빛을 받은 쪽만 색이 바랬다. 통로 바닥에 흙이 얇게 깔려 "
            "있고, 발자국 몇 개가 겹쳐 찍혀 있다.",

            "운전사는 문틀을 짚고 한 번 더 몸을 낮춘다. 무릎을 펴는 동작이 "
            "매끄럽지 않다. 바지 무릎께가 접힌 자국을 그대로 갖고 있다.",

            "정류장 바닥은 흙과 자갈이 섞여 있다. 비 온 뒤 마른 자국이 "
            "군데군데 남아 얕은 웅덩이 모양을 그린다. 웅덩이 가장자리에 "
            "잔돌이 둥글게 몰려 있다.",

            "승객은 걸음을 옮기다 말고 한 번 멈춘다. 신발 끈이 풀린 것을 "
            "알아채고 몸을 숙인다. 끈을 다시 묶는 동안 어깨가 앞으로 굽는다.",

            "차 앞 유리에 벌레 자국이 몇 개 말라붙어 있다. 닦아 낸 자국이 "
            "부채꼴로 남아 있고, 그 밖은 그대로다.",

            "운전사가 손을 털고 옷자락을 정리한다. 목덜미에 땀이 마르며 "
            "허옇게 자국이 진다. 숨을 한 번 길게 내쉰다.",

            "길 저편 담벼락에 오래된 글씨가 희미하게 남아 있다. 무슨 말인지 "
            "알아보기 어렵다. 담 아래 풀이 벽 쪽으로 기울어 자랐다.",

            "차 지붕에 얹힌 짐 몇 개가 끈으로 묶여 있다. 끈 매듭이 단단하지 "
            "않아 한쪽이 늘어졌다. 바람이 불 때마다 그 끝이 흔들린다.",
        ],
        [
            ("낡은 가방을 든 운전사가 차에서 내리는 모습", ["운전사"]),
            ("표를 들여다보는 승객의 손", ["승객"]),
        ],
    ),
    (
        "S#2. 정류장 승강장 / 낮",
        [
            "승강장 한쪽에 기둥이 서 있고 그 위에 정류장 표지가 달려 있다. "
            "표지는 한쪽으로 기울어 아래 모서리가 기둥에 닿아 있다.",

            "거기 적힌 글자는 위에서 아래로 내려간다. 글자 왼편에 그어진 "
            "짧은 가로줄은 절반쯤 지워졌다. 칠이 벗겨진 자리에 녹이 앉았다.",

            "승객이 걸음을 멈추고 고개를 든다. 정류장 표지 아래에 서서 "
            "글자를 눈으로 따라 읽는다. 그림자가 신발코에 걸린다.",

            "바닥에 금이 하나 길게 나 있다. 그 금을 따라 물기가 말라붙은 "
            "자국이 남아 있다. 지나간 자리마다 자국이 끊긴다.",

            "멀리서 차가 들어오는 기척이 난다. 승객은 한 걸음 물러서서 "
            "기둥 쪽으로 붙는다. 아직 차는 보이지 않는다.",

            "기다리는 동안 빛이 한 뼘 옮겨 간다. 기둥 밑동에 붙은 종이 조각이 "
            "바람에 떨린다. 반쯤 뜯겨 나가 남은 글자가 몇 자 없다.",

            "기둥은 아래쪽이 흙에 조금 묻혀 있다. 묻힌 자리 위로 물이 오른 "
            "자국이 띠처럼 둘러 있다. 손이 자주 닿은 높이만 반들반들하다.",

            "승강장 지붕은 한쪽이 짧아 비가 오면 절반만 가린다. 지붕 끝에서 "
            "물이 떨어진 자리에 홈이 파여 있다. 홈은 길게 이어지다 끊긴다.",

            "승객은 손을 뒤로 모으고 선다. 발끝으로 바닥의 금을 한 번 "
            "짚어 본다. 금은 생각보다 깊다.",

            "맞은편 길에 나무 한 그루가 서 있다. 가지가 한쪽으로만 뻗어 "
            "그늘이 치우쳐 있다. 잎이 성글어 빛이 사이로 샌다.",

            "어디선가 종이 넘기는 소리가 짧게 들린다. 소리가 난 쪽을 보면 "
            "아무도 없다. 바람이 방향을 바꾼 것뿐이다.",

            "기둥 옆에 낡은 통 하나가 놓여 있다. 뚜껑이 없고 안은 비어 "
            "있다. 테두리가 안쪽으로 우그러졌다.",

            "승객이 한 번 더 위를 본다. 글자를 다 읽고 나서도 시선을 바로 "
            "거두지 않는다. 목을 뒤로 젖힌 자세가 잠깐 이어진다.",

            "발밑으로 개미 몇 마리가 줄지어 지나간다. 줄은 금을 따라가다 "
            "갈라진다. 곧 다시 하나로 모인다.",

            "차가 들어오는 소리가 조금 더 가까워진다. 아직 모습은 보이지 "
            "않는다. 승객이 어깨를 편다.",
        ],
        [
            ("기울어진 정류장 표지의 클로즈업", []),
            ("표지 아래 선 승객의 전신", ["승객"]),
        ],
    ),
    (
        "S#3. 대합실 안 / 낮",
        [
            "대합실은 승강장 안쪽에 있다. 문 하나로 승강장과 이어지고, "
            "한쪽 면은 통째로 열려 있어 바깥 빛이 그대로 들어온다.",

            "긴 걸상이 대합실 벽에 붙어 있다. 앉는 자리마다 나뭇결이 닳아 "
            "색이 옅어졌다. 다리 하나 아래에 얇은 나무 조각이 끼워져 있다.",

            "안쪽 벽에 옛 요금표가 걸려 있다. 가로로 길게 켠 나무판에 "
            "가는 곳과 값이 두 줄로 나란히 적혀 있고, 값은 오른쪽에 글자로만 "
            "쓰여 있다. 판 아래 끝에 나무 고리가 하나 달려 있다.",

            "칠은 여러 번 덧칠되었다. 값이 바뀔 때마다 앞의 글자를 지우고 "
            "그 위에 다시 쓴 흔적이 겹쳐 있다.",

            "운전사가 들어와 제복 상의를 벗는다. 왼쪽 가슴에 덧댄 천 조각이 "
            "실밥 몇 개로 겨우 붙어 있다. 소매 끝은 닳아 실이 풀렸다.",

            "벗은 옷을 등받이에 걸치고 앉는다. 등을 벽에 대고 잠깐 눈을 "
            "감는다. 바깥에서 문 여닫는 소리가 두 번 난다.",

            "천장 가까이에 작은 창이 하나 있다. 거기로 든 빛이 바닥에 "
            "네모를 만든다. 네모가 조금씩 벽 쪽으로 밀려간다.",

            "바닥은 시멘트를 그대로 두어 결이 드러난다. 걸을 때마다 소리가 "
            "낮게 울린다. 구석에 마른 잎 몇 장이 몰려 있다.",

            "열린 면 쪽으로 바람이 들어와 안쪽까지 닿는다. 들어온 바람이 "
            "구석에서 한 번 돌아 나간다. 잎이 자리를 조금 옮긴다.",

            "벽 아래쪽에는 사람 어깨 높이까지 다른 색으로 칠해져 있다. "
            "경계선이 고르지 않아 붓 자국이 남았다. 아래쪽 칠이 더 두껍다.",

            "걸상 끝에 누군가 놓고 간 종이가 한 장 있다. 접힌 자국이 여러 "
            "번 나 있고 모서리가 눌렸다. 글자는 보이지 않는 쪽이 위로 향해 "
            "있다.",

            "천장 모서리에 거미줄이 걸려 있다. 오래되어 먼지가 붙었다. "
            "바람이 들어와도 거의 흔들리지 않는다.",

            "운전사가 앉은 자세로 목을 좌우로 돌린다. 뼈 소리가 짧게 난다. "
            "손을 무릎 위에 올려 둔 채 움직이지 않는다.",

            "문틀에 손자국이 여러 개 겹쳐 있다. 높이가 대체로 비슷하다. "
            "가장 진한 자국은 문 여는 쪽에 몰려 있다.",

            "바깥에서 누군가 지나가는 그림자가 열린 면을 스친다. 그림자는 "
            "금방 사라진다. 안쪽은 다시 조용해진다.",

            "빛이 옮겨 가면서 벽의 색 경계가 더 또렷해진다. 아래위 색이 "
            "갈라진 자리에 얇게 그늘이 진다.",
        ],
        [
            ("벽에 걸린 옛 요금표", []),
            ("제복 상의를 벗는 운전사", ["운전사"]),
        ],
    ),
    (
        "S#4. 정류장 앞 / 저녁",
        [
            "해가 낮아지고 그림자가 길게 눕는다. 운전사가 가방을 다시 들고 "
            "차 쪽으로 걸어간다. 묶어 둔 끈이 아까보다 더 늘어졌다.",

            "운전사가 주머니에서 접힌 표를 꺼낸다. 모서리가 둥글게 닳아 "
            "네 귀가 모두 부드럽다. 접힌 자리를 손톱으로 눌러 편다.",

            "그것을 들여다보다가 다시 접어 주머니에 넣는다. 차 문을 열고 "
            "짐을 먼저 안으로 올린다.",

            "길가에 사람 그림자가 하나 지나간다. 운전사는 뒤를 돌아보지 "
            "않는다. 차에 올라 문을 당겨 닫는다.",

            "차가 천천히 움직이기 시작한다. 뒤에 남은 자리에 바퀴 자국이 "
            "두 줄로 찍힌다. 자국은 곧 흐려진다.",

            "가로등이 하나 켜진다. 불빛이 아직 약해 바닥까지 닿지 않는다. "
            "길 끝이 먼저 어두워진다.",

            "차체가 낮에 받은 열을 아직 갖고 있다. 손을 대면 미지근하다. "
            "옆면 먼지 위에 손가락으로 그은 자국이 하나 남아 있다.",

            "정류장 바닥의 웅덩이 자국은 낮보다 흐려졌다. 밟고 지나간 자리가 "
            "더 넓어졌다. 잔돌이 흩어져 원래 모양이 무너졌다.",

            "운전사는 차 문을 잡은 채 한 번 뒤를 살핀다. 아무도 오지 "
            "않는다. 손을 놓고 계단에 발을 올린다.",

            "운전석 창은 절반쯤 내려가 있다. 그 틈으로 바깥 공기가 들어온다. "
            "손잡이 부분에 칠이 벗겨져 쇠가 드러났다.",

            "거울에 뒤쪽 길이 좁게 비친다. 비친 길은 금방 어두워진다. "
            "거울 테두리에 금이 하나 가 있다.",

            "엔진이 다시 돌기 시작하면서 차체가 잠깐 떤다. 떨림이 멎고 "
            "소리가 고르게 잡힌다. 바퀴가 자갈을 눌러 가라앉힌다.",

            "길가 담 위에 앉아 있던 새가 날아오른다. 날갯짓 소리가 짧게 "
            "난다. 담 위가 다시 비어 있다.",

            "멀어지는 차 뒤로 흙먼지가 얕게 인다. 먼지는 오래 떠 있지 "
            "않는다. 곧 바닥으로 내려앉는다.",
        ],
        [
            ("가방을 다시 드는 운전사", ["운전사"]),
            ("접힌 표를 꺼내는 운전사의 손", ["운전사"]),
        ],
    ),
]


#: ★원고 안에 심어 둔 것 — **코드가 아는 정답**. 시험이 이것으로 판정한다.
#:  이름이 아니라 **자리(장면 번호)** 로 적는다.
PLANTED: Dict[str, Dict[str, Any]] = {
    "cross_chunk_same_thing": {"what": "가방", "scenes": [1, 4]},
    "same_name_different_thing": {"what": "표", "scenes": [1, 4],
                                  "note": "1장은 승객이 쥔 표 · 4장은 "
                                          "운전사가 꺼낸 접힌 표. ★갈래는 "
                                          "둘 다 prop 이다 — 갈래가 다르면 "
                                          "merge 가 「합치지 마라」를 이미 "
                                          "알고 있어 반례가 쉬워진다"},
    "twice_in_one_chunk": {"what": "정류장 표지", "scenes": [2],
                           "times": 2},
    "part_of": {"part": "긴 걸상", "whole": "대합실", "scenes": [3]},
    "once_only_hard": {"what": "옛 요금표", "scenes": [3], "times": 1},
    "five_owners": {"character": "운전사", "location": "대합실",
                    "prop": "가방", "location_part": "긴 걸상",
                    "outlook": "제복 상의"},
}


def _bodies() -> List[str]:
    """장면 본문. ★원고와 구간을 **한 곳에서** 만든다.

    앞 판은 원고를 따로 잇고 구간을 따로 지어서 좌표가 안 맞았다 — 같은
    규칙을 두 곳에 적은 그 부류다.
    """
    return ["\n".join([f"**{heading}**", "", *paras])
            for heading, paras, _shots in _SCENES]


#: 장면 사이 구분자. 원고와 좌표가 **이 값 하나**를 같이 본다.
_SEP = "\n\n"


def manuscript() -> str:
    """원고 전문. ★자르지 않는다 — 시험도 프로덕션과 같은 것을 본다."""
    return _SEP.join(_bodies()) + "\n"


def segments() -> List[Dict[str, Any]]:
    """`scene_save.segments` 와 **같은 모양**. 좌표는 원고 조립과 같은 값에서."""
    out: List[Dict[str, Any]] = []
    cursor = 0
    for i, (body, (heading, _p, _s)) in enumerate(zip(_bodies(), _SCENES), 1):
        out.append({"scene_index": i, "heading": f"**{heading}**",
                    "start_char": cursor, "end_char": cursor + len(body),
                    "length": len(body), "text": body})
        cursor += len(body) + len(_SEP)
    return out


def shot_scenes() -> List[Dict[str, Any]]:
    """`shot_validator.scenes` 와 **같은 모양**."""
    out: List[Dict[str, Any]] = []
    for i, (heading, _paras, shots) in enumerate(_SCENES, start=1):
        out.append({
            "scene_index": i, "scene_heading": f"**{heading}**",
            "shots": [{"shot_index": j, "description": d,
                       "based_on_beat": j, "characters": list(chars),
                       "character_ids": []}
                      for j, (d, chars) in enumerate(shots, start=1)],
        })
    return out


def span_of(scene_index: int, needle: str, occurrence: int = 1
            ) -> Dict[str, Any]:
    """원고에서 그 낱말의 **정본 span** 을 잰다 — 코드가 검증할 좌표다.

    ★시험이 span 을 손으로 적으면 원고를 고칠 때 조용히 어긋난다.
    여기서 **실제로 찾아서** 낸다.

    Raises:
        LookupError: 그 자리에 그 낱말이 없으면 — 「없는 것을 있다고」 안 한다.
    """
    seg = next(s for s in segments() if s["scene_index"] == scene_index)
    body = seg["text"]
    pos = -1
    for _ in range(occurrence):
        pos = body.find(needle, pos + 1)
        if pos < 0:
            raise LookupError(
                f"{scene_index}장에 {needle!r} 의 {occurrence}번째가 없다")
    return {"segment_id": f"scene-{scene_index}", "start": pos,
            "end": pos + len(needle)}


def bundles(target: int | None = None) -> List[List[int]]:
    """생산 관례(`entity_lister.BUNDLE_TARGET`)로 묶은 **구간**. 장면 번호 목록.

    ★새 수를 지어내지 않는다 — 프로덕션이 쓰는 값을 그대로 받는다.
    """
    from app.modules.pipeline.entity_lister import BUNDLE_TARGET

    limit = BUNDLE_TARGET if target is None else target
    out: List[List[int]] = []
    cur: List[int] = []
    cur_len = 0
    for seg in segments():
        if cur and cur_len + seg["length"] > limit:
            out.append(cur)
            cur, cur_len = [], 0
        cur.append(seg["scene_index"])
        cur_len += seg["length"]
    if cur:
        out.append(cur)
    return out


def assert_planted() -> None:
    """★★**심었다고 적은 것이 원고 안에 실제로 있는지** 본다.

    이 파일이 「여섯 반례를 심었다」고 적어 놓고 원고가 그렇지 않으면, 그것을
    쓰는 시험은 **아무것도 안 재면서 초록**이 된다. 그래서 검사한다.

    Raises:
        AssertionError: 하나라도 안 맞으면.
    """
    b = bundles()
    where = {i: n for n, part in enumerate(b) for i in part}

    # ① 같은 실물이 **두 구간에**
    a, z = PLANTED["cross_chunk_same_thing"]["scenes"]
    span_of(a, PLANTED["cross_chunk_same_thing"]["what"])
    span_of(z, PLANTED["cross_chunk_same_thing"]["what"])
    if where[a] == where[z]:
        raise AssertionError(
            f"「같은 실물이 두 구간에」가 안 심겼다 — {a}장과 {z}장이 같은 "
            f"구간({where[a]})이다. 구간 {len(b)}개 · 본문을 늘려야 한다")

    # ③ 한 구간 **안에** 두 번
    t = PLANTED["twice_in_one_chunk"]
    span_of(t["scenes"][0], t["what"], t["times"])

    # ⑥ 한 번만 — **두 번째가 없어야** 한다
    o = PLANTED["once_only_hard"]
    span_of(o["scenes"][0], o["what"], 1)
    try:
        span_of(o["scenes"][0], o["what"], 2)
    except LookupError:
        pass
    else:
        raise AssertionError(f"{o['what']!r} 이 한 번만이 아니다")

    # ② 이름이 겹치는 **다른** 실물 · ④ 부분 · ⑤ 다섯 갈래
    for sc in PLANTED["same_name_different_thing"]["scenes"]:
        span_of(sc, PLANTED["same_name_different_thing"]["what"])
    span_of(PLANTED["part_of"]["scenes"][0], PLANTED["part_of"]["part"])
    for owner, word in PLANTED["five_owners"].items():
        if not any(word in s["text"] for s in segments()):
            raise AssertionError(f"{owner} 자리의 {word!r} 가 원고에 없다")

    # ⑦ **다섯 갈래가 모두 참조 획득을 지나나** (Codex 조건 ⑥, 2026-08-31)
    #
    #  ★고증 의무를 여는 것은 **같은 행에서 hard AND notice** 다. 어느 갈래에
    #   양성이 하나도 없으면 그 갈래는 참조 획득·참조 소비를 **통째로 안
    #   지나고**, 그런데도 「다섯 갈래를 다 봤다」로 읽힌다.
    #
    #  ★실제로 그랬다 — 앞 판 fixture 는 양성이 `location_part` 둘과 `outlook`
    #   하나뿐이라 `prop`·`character`·`location` 이 한 번도 안 지났다.
    #  ★★뜻을 옮겼다 (유료 주행 실측, 2026-08-31).
    #
    #   앞 판은 「**모든** 갈래에 hard&notice 양성이 있어야 한다」로 두고,
    #   그러려고 사람에게 없는 축을 붙였다. 실제 모델은 `운전사` 를 두 축 다
    #   False 로 보고 그 근거를 **입은 것**에 놓았는데 **그쪽이 맞다** —
    #   세계 사실이 규정한 것은 옷의 모양이지 사람의 형태가 아니다.
    #
    #   진짜 불변식은 「어느 갈래든 **자격이 되면 지나갈 수 있다**」이지
    #   「모든 갈래가 늘 지난다」가 아니다. 데이터가 정할 일을 fixture 가
    #   못박으면 그것이 지어낸 정답이다.
    if not acquisition_targets():
        raise AssertionError("고증 예외 양성이 하나도 없다 — 참조 획득 축이 "
                             "통째로 안 돈다")
    covered = acquisition_owners()
    if len(covered) < 3:
        raise AssertionError(
            f"참조 획득을 지나는 갈래가 {sorted(covered)} 뿐이다 — 여러 갈래가 "
            "실제로 지나는지 못 본다")
    no_basis = [t["key"] for t in EXPECTED_TARGETS if t["key"] not in AXIS_BASIS]
    if no_basis:
        raise AssertionError(f"두 축 근거가 없는 표적: {no_basis}")


def segment_texts() -> Dict[str, str]:
    """`segment_id` → 그 구간 원문. `assert_rows(segments=…)` 가 쓴다."""
    return {f"scene-{s['scene_index']}": s["text"] for s in segments()}


#: ★★★**채점 가능한 표적 장부.** `PLANTED` 는 산문이라 채점을 못 한다
#:  (Codex 2026-08-31). 여기는 **정본 span** 과 기대값이다.
#:
#:  ★이것은 **원고의 모든 합법 엔티티 목록이 아니다.** 표적 회귀 장부다 —
#:   「빠졌나」는 이것으로 채점하고, 「근거 없이 더했나」는 **이 목록 밖인가**가
#:   아니라 **원문에 근거가 있나**(정본 span 대조)로 채점한다. 목록 밖이라고
#:   틀린 것으로 세면, 내가 안 적은 정당한 엔티티가 전부 결함이 된다.
#:
#:  ★`surface` 는 **어디를 가리키는지 찾기 위한 좌표**지 채점 근거가 아니다.
#:   글자 세기로 채점하면 「표」가 「정류장 표지」·「옛 요금표」 안에서도
#:   세어진다 — 그래서 겹침은 **span 으로** 본다.

EXPECTED_TARGETS: List[Dict[str, Any]] = [
    # ★`prop` 갈래는 이 표적이 대표한다 — 「낡은 가방」을 따로 표적으로 두면
    #  그 span 이 「가방」을 **품어서** 한 언급이 두 표적을 주장하게 된다.
    # ★`anchors`: `all` = 적힌 자리를 **전부** 부른 언급이 있어야 한다.
    #  `any` = 그 중 **하나만** 불러도 된다. 구간을 넘는 실물은 `all` 이어야
    #  「한쪽 구간을 통째로 잃었다」를 잡는다.
    {"key": "cross_chunk_same_thing", "owner": "prop", "anchors": "all",
     # ★자리는 **원문의 완전한 이름 덩어리**로 잠근다. 앞 판은 「가방 손잡이」
     #  안의 두 번째 「가방」까지 정답 자리로 넣어서, 손잡이를 한 실물로
     #  합쳐도 통과했다 (Codex 재현).
     "at": [(1, "낡은 가방", 1), (4, "가방", 1)],
     "rows_after_merge": 1, "min_occurrences": 2,
     "why": "두 구간에 걸친 같은 실물 — 합쳐져 한 행이어야 한다"},
    {"key": "same_name_different_thing", "owner": "prop",
     # ★두 자리 **모두** 필수다. `any` 로 두면 한쪽을 빼고 다른 쪽을 두 행으로
     #  복제해도 통과한다 (Codex 재현).
     # ★★**post-hoc fixture oracle correction** (2026-08-31, Codex 승인).
     #  유료 산출을 보고 채점 규칙을 새로 만든 것이 **아니다** — 기존 팩 계약
     #  (「그 대상 **전체**를 가리키는 **완전한 최소** 이름 덩어리. 짧게 만드는
     #  것보다 대상 하나만 집는 것이 먼저」)과 어긋나 있던 **oracle 한 칸**을
     #  바로잡은 것이다. 4장 원문은 「운전사가 주머니에서 접힌 표를 꺼낸다」이고
     #  완전한 최소 명사구는 「접힌 표」다. 벌거벗은 「표」로 잠갔던 것이 틀렸다.
     #  ★반례는 그대로다 — 1장 「표」와 4장 「접힌 표」는 **서로 다른 실물**이고,
     #   정본 주소가 달라야 둘을 정확히 가른다.
     "anchors": "all", "at": [(1, "표", 1), (4, "접힌 표", 1)],
     "rows_after_merge": 2, "min_occurrences": 1,
     "why": "같은 말이 서로 다른 것 — 합치면 안 된다"},
    {"key": "twice_in_one_chunk", "owner": "location_part",
     # ★두 출현 **모두** 필수 — 그게 이 반례의 목적이다.
     "anchors": "all", "at": [(2, "정류장 표지", 1), (2, "정류장 표지", 2)],
     "rows_after_merge": 1, "min_occurrences": 2,
     "why": "한 구간 안 2회 — 한 행이고 출현이 2 여야 한다"},
    # ★★갈래 기대를 **계약과 맞췄다** (Codex 2026-08-31). 앞 판은 방(대합실)을
    #  `location_part` 로, 벽에 붙은 것을 `prop` 으로 기대했는데 팩의 관계
    #  규칙은 반대다 — 「장면을 담을 수 있는 공간 전체」가 `location`,
    #  「공간에 고정된 구성 요소」가 `location_part`. **모델이 맞았다.**
    {"key": "part_whole_part", "owner": "location_part",
     "anchors": "any", "at": [(3, "긴 걸상", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "부분 — 벽에 붙은 구성 요소. 전체에 합쳐 없애면 안 된다"},
    {"key": "part_whole_whole", "owner": "location",
     "anchors": "any", "at": [(3, "대합실", 1), (3, "대합실", 2), (3, "대합실", 3)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "전체 — 장면을 담는 공간. 부분과 관계로 이어져야 한다"},
    {"key": "once_only_hard", "owner": "location_part",
     "anchors": "any", "at": [(3, "옛 요금표", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     # ★★**이 실험의 핵심**이다 — 「한 번만 나와도 만들기 어려우면 등록」.
     #  채점기가 두 축과 **등록 효과**까지 봐야 한다 (Codex 2026-08-31).
     "exception_axis": True,
     "why": "한 번만 — 두 축이 같은 행에서 둘 다 참일 때만 등록"},
    # ★★**이웃한 다른 실물.** 가방과 그 손잡이는 서로 다른 대상이다.
    #  이것을 표적으로 세워야 「손잡이까지 한 실물로 합쳤다」가 잡힌다 —
    #  표적 밖이면 합쳐도 안 보인다 (Codex 재현).
    {"key": "neighbour_prop", "owner": "prop", "anchors": "any",
     "at": [(1, "가방 손잡이", 1)], "rows_after_merge": 1,
     "min_occurrences": 1,
     "why": "가방과 손잡이는 서로 다른 대상 — 한 행으로 합치면 안 된다"},
    # ★★`prop` 갈래의 **고증 예외 양성**. 세계 사실이 「앞뒤로 긴 상자 모양 ·
    #  옆면 위쪽에 창이 줄지어 · 한 색 · 무늬 없음」으로 형태를 규정하므로
    #  근거 없이 그리면 틀린다. 이 갈래에 양성이 없으면 **prop 이 참조 획득을
    #  통째로 안 지난다** (Codex 조건 ⑥, 2026-08-31).
    {"key": "owner_prop_hard", "owner": "prop", "anchors": "any",
     "at": [(1, "차", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "다섯 갈래 — 물건(고증 예외 양성)"},
    {"key": "owner_character", "owner": "character",
     "anchors": "any", "at": [(1, "운전사", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "다섯 갈래 — 사람"},
    {"key": "owner_outlook", "owner": "outlook",
     "anchors": "any", "at": [(3, "제복 상의", 1)], "rows_after_merge": 1, "min_occurrences": 1,
     "why": "다섯 갈래 — 입은 것"},
]

#: 기대하는 관계. ★`part_of` 는 부분이 **살아남고** 관계만 적힌다.
EXPECTED_RELATIONS: List[Dict[str, str]] = [
    {"relation": "part_of", "part": "part_whole_part",
     "whole": "part_whole_whole"},
    {"relation": "same_referent", "keys": "cross_chunk_same_thing"},
]


def expected_spans(target: Dict[str, Any]) -> List[Dict[str, Any]]:
    """표적의 **정본 span** 들. ★원고가 바뀌면 여기서 선다."""
    return [span_of(sc, w, n) for sc, w, n in target["at"]]


def target_spans() -> Dict[str, List[Dict[str, Any]]]:
    return {t["key"]: expected_spans(t) for t in EXPECTED_TARGETS}


#: ★★★**실험 입력**인 권위 있는 세계 사실. production 프롬프트가 아니다.
#:
#:  Codex 가 걸었다 (2026-08-31): 세계관이 「가상의 근대 이후 어느 시기」
#:  하나뿐이면 「이미지 모델이 형태를 틀릴 만한가」와 「그곳 사람이 알아채는가」
#:  의 정답을 **사람도 못 정한다.** 그러면 실패가 구조 탓인지 입력 결손인지
#:  안 갈린다.
#:
#:  ★작품 고유명사가 없다. 지어낸 일반 세계의 **시각 관례**만 적는다 —
#:   실제 지역·시대를 안 가리키므로 어떤 원고에도 안 묻는다.
WORLD_FACTS = """\
이 이야기는 지어낸 한 지역의 어느 시기를 배경으로 한다. 아래는 그 세계의
시각 관례이며, 이 원고 안에서는 이것이 정본이다.

거리와 탈것
- 사람을 태우는 큰 탈것은 앞뒤로 긴 상자 모양이고, 옆면 위쪽에 창이 줄지어
  나 있다. 색은 한 가지로 칠하고 무늬를 넣지 않는다.
- 사람이 타고 내리는 자리에는 기둥을 세우고 그 위에 넓적한 판을 붙인다.
  판에는 가는 곳 이름을 **세로로** 적고, 이름 왼쪽에 짧은 가로줄을 하나 긋는다.
  이 세로쓰기와 가로줄이 이 지역 표지의 표식이며, 다르게 그리면 이곳 사람은
  곧바로 알아본다.

기다리는 곳
- 사람이 타는 자리 옆에는 지붕과 벽이 있는 작은 방을 붙여 둔다. 벽 한 면은
  통째로 열려 있고, 나머지 세 면에는 긴 걸상을 벽에 붙여 놓는다.
- 그 방 안쪽 벽에는 값을 적은 판을 건다. 이 판은 나무를 가로로 길게 켜서
  만들고, 가는 곳과 값을 **두 줄로 나란히** 적되 값을 오른쪽에 둔다. 값은
  글자로만 적고 숫자를 쓰지 않는다. 판 아래쪽 끝에는 손잡이처럼 튀어나온
  나무 고리가 하나 달려 있다.
- 이 값판은 이 지역 밖에서는 쓰지 않는 모양이라, 근거 없이 그리면 형태가
  거의 틀린다. 그리고 이곳 사람은 이 판을 날마다 보므로 **틀리면 바로 안다.**

사람과 입는 것
- 탈것을 모는 사람은 위아래가 같은 색인 옷을 입고, 윗옷 왼쪽 가슴에 손바닥
  절반쯤 되는 넓적한 천 조각을 덧대어 꿰맨다. 그 천에는 아무 무늬도 없다.
- 그 천 조각의 자리와 크기가 이 일을 하는 사람을 가리키는 표식이며, 자리가
  다르면 이곳 사람은 알아본다.

흔한 물건
- 손에 들고 다니는 자루나 가방, 종이로 된 표는 이 지역만의 모양이 따로 없다.
  어느 시기 어느 곳의 것으로 그려도 이곳 사람은 이상하게 여기지 않는다.
"""

#: 두 축의 **정답 근거**. ★기계는 모양·AND·등록 효과만 보고, 이 뜻이 옳은지는
#:  사람이 본다. 여기 적는 것은 사람이 볼 때 **무엇을 근거로 삼을지**다.
AXIS_BASIS: Dict[str, Dict[str, Any]] = {
    "once_only_hard": {
        "hard": True, "notice": True,
        "basis": ("값판은 이 지역 밖에서 안 쓰는 모양이라(가로로 켠 나무·"
                  "두 줄 나란히·값은 글자로·아래 나무 고리) 근거 없이 그리면 "
                  "형태가 틀린다. 그리고 날마다 보는 것이라 틀리면 안다"),
    },
    "twice_in_one_chunk": {
        "hard": True, "notice": True,
        "basis": "세로쓰기와 왼쪽 가로줄이 이 지역 표지의 표식이다",
    },
    "part_whole_part": {
        "hard": False, "notice": False,
        "basis": ("벽에 붙인 긴 걸상은 이 지역만의 모양이 따로 없다 — "
                  "세계 사실에 형태 규정이 없다"),
    },
    "owner_outlook": {
        "hard": True, "notice": True,
        "basis": "왼쪽 가슴의 천 조각 자리와 크기가 이 일의 표식이다",
    },
    "cross_chunk_same_thing": {
        "hard": False, "notice": False,
        "basis": "들고 다니는 가방은 이 지역만의 모양이 따로 없다",
    },
    "same_name_different_thing": {
        "hard": False, "notice": False,
        "basis": "종이로 된 표는 이 지역만의 모양이 따로 없다",
    },
    # ★★아래 셋이 없으면 **prop · character · location 이 참조 획득을 통째로
    #  안 지난다.** 조건은 「다섯 갈래가 모두 등록·보호·고증 의무·참조 획득·
    #  하류 소비까지 지나가야 한다」인데, 고증 의무를 여는 것은 **같은 행에서
    #  hard AND notice** 다 (Codex 조건 ⑥, 2026-08-31).
    #
    #  ★근거는 **이미 세계 사실에 적혀 있던 것**이다 — 새로 지어내지 않았다.
    "owner_prop_hard": {
        "hard": True, "notice": True,
        "basis": ("사람을 태우는 큰 탈것은 앞뒤로 긴 상자 모양이고 옆면 "
                  "위쪽에 창이 줄지어 나며 한 색으로 칠하고 무늬를 넣지 "
                  "않는다 — 근거 없이 그리면 형태가 틀리고, 날마다 타는 "
                  "것이라 틀리면 안다"),
    },
    # ★★**내가 지어낸 축을 되돌린다** (유료 주행 실측, 2026-08-31).
    #
    #  조건 ⑥(「다섯 갈래가 모두 참조 획득을 지나야」)을 맞추려고 사람에게
    #  hard&notice 를 붙였는데, 실제 모델은 `운전사` 를 **hard=False ·
    #  notice=False** 로 봤다. 그리고 그 근거를 **입은 것**에 놓았다 —
    #  `제복 상의` 와 `왼쪽 가슴에 덧댄 천 조각` 둘 다 outlook 으로
    #  hard&notice 다.
    #
    #  ★그쪽이 맞다. 세계 사실이 규정한 것은 **옷의 모양**이지 사람의 형태가
    #   아니다. 「사람은 지역 특유의 형태가 없고 입은 것이 있다」는 판단은
    #   계약과 어긋나지 않는다.
    #
    #  ★★내 기대값을 모델에 맞춰 고친 것이 **아니다** — 내가 근거 없이 붙인
    #   것을 뺀 것이다. 세계 사실 어디에도 「사람의 생김새」 규정은 없다.
    "owner_character": {
        "hard": False, "notice": False,
        "basis": ("세계 사실은 **입는 것**의 모양만 규정한다 — 사람 자체의 "
                  "생김새 규정은 없다. 고증 의무는 outlook 이 진다"),
    },
    "part_whole_whole": {
        "hard": True, "notice": True,
        "basis": ("기다리는 방은 벽 한 면이 통째로 열려 있고 나머지 세 면에 "
                  "긴 걸상을 벽에 붙인 모양이다 — 이 배치를 모르면 방의 꼴이 "
                  "틀리고, 날마다 드나드는 곳이라 틀리면 안다"),
    },
    "neighbour_prop": {
        "hard": False, "notice": False,
        "basis": "가방 손잡이는 이 지역만의 모양이 따로 없다",
    },
}


def acquisition_targets() -> List[str]:
    """참조 획득을 지나야 하는 표적. ★**같은 행에서 hard AND notice**.

    ★이 목록이 다섯 갈래를 **다 덮는지**를 `assert_planted()` 가 본다 —
    한 갈래라도 빠지면 그 갈래는 고증 의무를 통째로 안 지난다.
    """
    return sorted(k for k, v in AXIS_BASIS.items()
                  if v.get("hard") and v.get("notice"))


def acquisition_owners() -> set:
    by_key = {t["key"]: t["owner"] for t in EXPECTED_TARGETS}
    return {by_key[k] for k in acquisition_targets() if k in by_key}
