# 두 VLM 실행부 — 합의는 호출자가 한다 (#92 1단계)

2026-08-27

## 지시

> gpt sol vlm 은 성능이 안좋아 … **무조건 gemini 3.1 pro 와 grok 최신
> 모델 둘을 사용해야해** / 아홉 전부 바꿔

## 이 모듈이 하는 것과 **안 하는 것**

| | |
|---|---|
| 한다 | 두 alias 를 **각각 한 번씩** · fallback 봉인 · 모델별 raw·물리 모델·오류·토큰·비용을 같은 모양으로 기록 |
| **안 한다** | 합의 · 기각 · 승자 선정 · 재시도 · 한쪽 실패를 다른 쪽 두 번째 호출로 메우기 |

★**합의를 실행부에 넣으면 안 된다** (Codex 판정). 업무마다 실패의 뜻이
다르다:

    binary defect gate  둘 다 broken 일 때만 기각, 불일치는 split
    ranking·selection   모델별 점수 보존 → 호출자 규칙으로 합산
    관찰·critique       모델별 관찰 기록 → 기존 issue 계약으로 결합
    geometry·추출       구조화 ID 합의 — **좌표 평균 금지**

근거가 코드에 있다. `ref_image_pipeline` 의 두 호출자는 같은 운반층
(`_call_gpt_lvm`)을 쓰는데 **업무가 다르다**:

    validate_reference_image   severity=severe 면 **돈 내고 재생성**
    compare_two_images         이미 산 두 장 중 승자 선정

같은 합의로 묶으면 실패의 뜻이 섞인다.

## fallback 봉인이 왜 계약인가

`call_structured` 는 기본이 `enable_fallback=True` 다 — Gemini/Grok 이
실패하면 **GPT Sol 이 다시 들어온다.** 사용자 결정을 조용히 어기고 기록에도
Sol 이 안 남는다. 시험이 인자와 **소스 AST** 둘 다로 잠근다.

## `usage_sink` — 없던 것을 만들었다

`call_structured` 는 payload 만 돌려줘 **한 판정에 얼마를 썼는지 알 길이
없었다.** 선택적 `usage_sink` 를 달았다 — 안 주면 아무것도 안 하므로 기존
호출은 바이트 동일이다.

★**미보고를 0 으로 안 채운다.** provider 가 usage 를 안 주면 키를 비워
둔다. 0 으로 채우면 「안 썼다」로 읽힌다 — 내가 그 함정을 이미 밟았다
(`cost` 가 딕셔너리인데 스칼라로 읽어 0 이 나왔고, 그 0 을 「기록이 없다」로
사용자에게 보고했다).

★물리 모델은 **응답이 말한 것**을 쓴다. 못 읽으면 빈 문자열이다 — alias 로
대신 채우면 「무엇이 답했나」와 「무엇을 부르려 했나」가 섞인다.

## 끝점 실측

같은 그림 한 장, 같은 문안:

| | 물리 모델 | prompt | completion | 비용 | 나간 상한 |
|---|---|---|---|---|---|
| `gemini-pro` | `gemini-3.1-pro-preview` | 1,114 | 29 | **$0.000376** | 65,536 |
| `grok` | `x-ai/grok-4.6` | 1,331 | 492 | **$0.005422** | 8,000 |

답은 둘 다 `people_count=1` · 여자·자전거로 일치.

★**grok 이 같은 판정에 14배 비싸다.** 추론 모델이라 completion 이 29 대
492 다. 자리를 고를 때 이 값을 봐야 한다 — 「둘 다 부른다」의 실제 값이
gemini 기준 15배가 아니라 **약 15.4배**다.

★`max_tokens_sent` 을 남기는 이유: grok 만 8,000 으로 깎이는데
(`MODEL_MAX_OUTPUT_TOKENS`), 나중에 답이 잘렸을 때 「모델이 짧게 답했나」와
「상한에 걸렸나」를 가르려면 그 값이 기록에 있어야 한다.

## 다음

Codex 가 정한 순서:

    2. 업무 3종 reasoning probe (gate · ranking · geometry 따로)
    3. 기존 이중 ranking 의 GPT→Grok — 가장 좁은 첫 적용
    4. cine 동일-모델 2회 → **Gemini 1 + Grok 1 = 총 2콜**
    5. ref validation/comparison caller별 합의
    6. floor/lane/bbox geometry 합의
    7. 휴면은 켜는 판에서

---

# 2단계 실측 — 업무 3종 reasoning effort

Codex 요구: 「한 종류 probe 로 15자리 공통값을 정하면 안 된다. 최소 세
부류를 따로 재라.」

## 정답을 아는 입력을 **만들어** 썼다

    gate      한 장은 그대로, 한 장은 좌우 반씩 다른 그림을 붙여 이음매를
              만든다 → 「이어붙인 자국이 있나」의 답을 우리가 안다
    rank      서로 다른 세 장에 index 라벨을 붙인다
    geom      한 장 위 **정해진 자리**에 한 가지 색 사각형을 그린다

★코드로 그리는 것은 「시각 요소를 코드가 만든다」와 다르다 — 여기서 그리는
 것은 프로덕션 그림이 아니라 **자를 재는 자**다. 정답을 아는 입력이 없으면
 「0」과 「꺼진 장치」를 못 가른다.

## 결과 — **effort 를 낮출 근거가 이 표본에선 안 나온다**

| effort | 정답 | 비용(10콜) |
|---|---|---|
| 기본 | **8/8** | $0.1365 |
| low | **8/8** | $0.1064 |
| minimal | **8/8** | $0.1108 |

셋 다 만점이고 비용 차이는 **표본 잡음 안**이다(minimal 이 low 보다 비싸다).
2회씩이라 표본이 작고, `rank` 는 정답을 안 정해 놨다(`want=None`).

★**낮추자고 말하지 않는다.** 「같은 정확도에 더 싸다」를 주장하려면 회차를
 늘려 잡음을 걷어야 하는데, 지금 그 비용을 쓸 이유가 없다 — 아래 소득이
 더 크다.

## 진짜 소득 — `rank` 가 비용의 대부분이다

    gate   완성 토큰  417~ 764
    geom   완성 토큰  384~ 794
    rank   완성 토큰  4,841~7,451   ← **10배**

effort 를 minimal 로 낮춰도 안 줄어든다(7,011 이 나왔다). 여러 장을 한
번에 보는 물음 자체가 무거운 것이다.

★배선 순서에 쓸 값: **ranking 자리를 grok 으로 이중화하면 그 자리가 비용을
지배한다.** gate·geometry 는 콜당 $0.004~0.007 이라 부담이 작다.

---

# 6단계(geometry)를 **이 판에서 안 하는 이유**

Codex 계약: 「좌표 평균 금지 · 구조화 ID 합의 · `row/col` 은
`exact / adjacent / conflict` 로 기록 · 최초 cutover 는 `exact` 만 publish,
`adjacent`/`conflict` 는 fail-closed · **인접 셀 허용은 양성 표본으로 경계를
잰 뒤에만**」.

## 무료 실측 — 합의가 몇 개짜리 문제인가

`tools/prompt_measure/audit_floor_readback_shape.py` (체크포인트 94개).

| | |
|---|---|
| 도면 | **1,377장** |
| 마커 | 평균 **11개** · 최대 30 · 중앙 11 |
| 격자 | 전부 **10×10** |
| kind | `base_structural_unit` 5,995 · `base_opening` 4,046 · `base_persistent_fixture` 3,097 · `base_persistent_furniture` 2,903 |
| `missing` 있음 | **109/1,377 (8%)** |
| `extra` 있음 | 0/1,377 |
| confidence | 평균 0.98 · 최소 0.74 |
| 격자 점유율 | 평균 11.3% |

## 판단

★**한 도면마다 11개 마커의 `number`·`kind`·`row`·`col` 이 100칸 격자에서
 정확히 같아야** `exact` 가 된다. 두 VLM 이 11개를 **모두** 같은 칸으로
 읽을 확률은 낮다 — 그런데 그 확률을 **모른다.**

★`missing` 이 이미 8% 나온다. **한 모델도 다 못 읽는 경우가 있다**는 뜻이다.
 둘을 요구하면 그 비율은 올라간다.

★**실측 없이 `exact` 만 통과시키면 1,377 도면 중 상당수가 fail-closed 되어
 파이프라인이 선다.** 「재기 전에 무엇이 무엇에 닿는지 확인」 규칙이 정확히
 이 자리를 말한다.

★한 가지 좋은 신호: 격자 점유율이 11.3% 라 칸이 성기다 — 「인접 셀 허용」이
 서로 다른 마커를 섞을 위험이 낮다. 다만 그 완화의 **경계**도 표본으로
 정해야 한다.

## 그래서 다음 판의 순서

    1. 같은 도면 N장을 두 모델에 태워 **일치율을 잰다** (유료, 판정 콜)
       — exact / adjacent / conflict 비율을 marker 단위와 도면 단위로
    2. 그 값을 보고 문턱을 정한다 (`exact` 만 / 인접 허용 / 다른 축)
    3. 그 다음에 구현한다

★bbox(`zoom_continuity_render_service`)도 같다. 지금은 free-text target 만
 주고 `{found, bbox}` 를 받아 **그 좌표로 바로 crop 한다** — 안정된
 `target_id` 를 schema 로 왕복시켜 「둘이 같은 것을 가리키는가」부터
 확인해야 IoU 문턱을 말할 수 있다.
