# 표기 저작 v3 — 인용을 코드가 검사한다

> 감사 보고서 1-A 의 나머지. v2 승격은 [0단계 진행 기록](2026-08-26-stage0-progress.md)
> 에서 한 번 올렸다가 되돌렸다 — v3 와 **한 번에** 올린다(지문이 바뀌면
> 표기 저작이 걸린 샷이 다시 도는데, 두 번 나눠 올리면 두 번 돈다).

## 무엇이 남았나

v2 는 출처를 **열거값**으로 받고 `inferred` 를 코드가 버린다. 완주 판
7샷에서 86% → 0% 로 떨어졌다. 그런데 v2 를 낸 판이 스스로 반례를 적어
두었다:

> 읽는 행위만 지목한 입력에서 모델이 "공고"를 **지어내고** `scene_text` 라
> 신고했다.

**자가신고는 계약이 아니다.** 열거값 하나로는 "그렇게 분류했다"까지만
알 수 있고, 그 분류가 맞는지는 아무도 안 본다.

그리고 버려진 항목이 `logger.info` 에만 남는다 — records 에는 최종 목록만
남아서, 나중에 「몇 건이 왜 버려졌나」를 셀 수가 없다.

## 계약

### ① 인용을 별도 칸으로 받는다

v2 팩은 `reason_ko` **안에** 인용을 요구했다("그 대목을 원문 그대로
인용한다"). 모델은 풀어 쓰기만 했다. 이유 문장 안에 섞으면 코드가 무엇이
인용이고 무엇이 설명인지 가를 수 없다.

    source_quote: str
      scene_text / world_facts  → 그 원문에서 **그대로 복사한** 조각
      inferred                  → 빈 문자열

### ② 코드가 입력에 있는지 본다

```
quote_is_grounded(quote, haystack=shot_text + place_text + world_facts_block)
```

**이것은 의미 판단이 아니다.** 글자가 무슨 뜻인지 재는 것이 아니라
"이 문자열이 저 문자열 안에 있나"만 본다 — v2 의 `source in
GROUNDED_SOURCES` 와 같은 부류다. 의미는 여전히 모델이 정한다.

정규화는 **최소만** 한다:

- 앞뒤 공백 제거, 연속 공백 하나로
- 따옴표류 제거(`" ' “ ” ‘ ’ 「 」 『 』`) — 모델이 인용부호를 덧붙인다

그 이상(어미 자르기·유사도)은 **안 한다**. 느슨하게 만들면 검사가
무력해지고, 그때는 v2 와 같아진다.

### ③ 버린 항목을 records 에 남긴다

```python
{"fp": …, "inscriptions": [...], "dropped": [
    {"text_native": …, "source": …, "source_quote": …, "why": "ungrounded_quote"},
]}
```

`why` 는 코드가 정하는 열거값: `not_grounded_source`(=inferred 등) ·
`ungrounded_quote`(인용이 입력에 없다) · `empty_quote`.

이게 있어야 **다음 판에서 무엇을 고칠지**가 데이터로 남는다. 지금은
로그가 흘러가고 나면 세어 볼 방법이 없다.

## 갈래 유지

팩 버전이 계약을 정한다 — 옛 팩은 옛 계약 그대로 돈다.

| | source 칸 | source_quote 칸 | 코드 검사 |
|---|---|---|---|
| v1 | 없다 | 없다 | 없다 |
| v2 | 있다 | 없다 | 열거값만 |
| v3 | 있다 | 있다 | 열거값 + 인용 존재 |

`pack_has_source_contract` 옆에 `pack_has_quote_contract` 를 둔다.

## 실측 계획

**그림은 사지 않는다.** 보조 모델(flash) 호출만으로 잰다 — v2 를 낸 판이
쓴 `backend/tools/prompt_measure/ab_inscription_source.py` 와 같은 방식.

1. 완주 판 7샷 입력으로 v2 · v3 를 각각 태운다
2. **양성 대조**를 함께 태운다 — 대본이 실제로 글자를 인용하는 입력을
   지어 넣고 v3 가 그것을 `scene_text` 로 살리는지 본다.
   ★ 이게 없으면 「0건」이 「꺼진 장치」와 구분되지 않는다.
3. v2 가 통과시키던 거짓 신고(지어낸 "공고")를 v3 가 버리는지 본다 —
   이 판의 표적이다.

## 승격

실측이 끝나면 `SIGNAGE_PACK_VERSION = "3"` + `SIGNAGE_POLICY_VERSION` 을
한 커밋에서 올린다. 팩 문자열이 스텝 지문(`image_steps.py:848-850`)에
들어가므로 표기 저작이 걸린 샷이 다시 돈다 — 86% 를 지어내던 판이라
다시 도는 것이 맞다.

---

## 실측 (2026-08-27) — 그림은 한 장도 안 샀다

`backend/tools/prompt_measure/ab_inscription_source.py --arms v2,v3 --rounds 2`

### 입력 복원

도구가 **스스로 증명한다** — 복원한 (샷·장소·세계 사실)로 프로덕션 지문을
다시 계산해 records 의 `fp` 와 댄다. 6/7 일치, 어긋난 1건은 수치에서 뺐다.

> ★처음 돌렸을 때 **0/7** 이 나와 도구가 측정을 멈췄다. 원인은 입력이
> 아니라 **판 식별자**였다 — 도구가 `SIGNAGE_POLICY_VERSION` 을 코드의
> 현재 값(v3)으로 읽는데 기록은 v1 판에서 만들어졌다. 정책·팩을 기준선
> 상수로 분리했다. 도구가 안 맞는 것을 안 맞다고 말해 준 덕에 잘못된
> 수치를 보고하지 않았다.

### 결과

| | 본 표본 (6샷 × 2회) | 양성 대조 |
|---|---|---|
| v1 (기준선, 기록) | **6/7 (86%)** — 대본이 부른 글자는 0개 | — |
| v2 | 최종 0% · 모델이 낸 것 **4**/12 (전부 `inferred`) | 2/2 |
| **v3** | 최종 0% · 모델이 낸 것 **3**/12 (전부 `inferred`) | 2/2 |

**인용을 대라니까 모델이 내는 것 자체가 줄었다.** v3 가 낸 3건은 스스로
`inferred` 로 분류해 코드가 버렸다.

### 양성 대조 — 「0」이 「꺼진 장치」가 아님을 증명한다

    v3  PC-quoted : "금일 휴업"  [scene_text] ← "금일 휴업"
        PC-named  : "안내"       [scene_text] ← "notice"

인용이 **실제로 주어진 글에서 온 것**임이 보인다. `PC-named` 입력은
영어("reads the notice pinned to it")이고 저작 문안은 그 장소 언어(한글)다
— 인용은 근거가 어디 있나를 대는 것이고 문안은 그 자리 말로 쓴다.

같은 대조에서 **v2 는 "알림"을 `scene_text` 라 신고했다.** 그 말은 입력에
없다 — v2 가 못 막는 거짓 신고이고, 이 판이 겨눈 자리다.

> ★출력에 인용을 안 찍었을 때는 v2·v3 가 둘 다 "2/2 저작"으로만 보였다.
> **통과했다는 것만 보이면 무엇으로 통과했는지 모른다** — 도구에 인용을
> 함께 찍게 고치고서야 갈렸다.

### 이번 실측이 **못 태운** 갈래

`ungrounded_quote`(인용을 댔는데 입력에 없다)로 버려진 건이 **0건**이다.
모델이 근거를 못 대면 아예 `inferred` 로 신고하기 때문이다. 그 갈래는
단위 시험이 덮는다(`test_v3_drops_an_entry_whose_quote_is_not_in_the_given_text`).
실측에서 안 돈 것을 돌았다고 읽지 말 것.
