# Plan: 클래식 3라운드 로스트시티 에이전트

**Status:** Ready to execute (Fable 2차 검토 반영 완료)
**Priority:** High — 현재 스택은 **단판(1라운드)** 게임을 학습하는데, 원작 로스트시티는
**3라운드 합산**이 승패를 가른다. 즉 지금 에이전트는 애초에 다른 게임을 배우고 있다.
**Background:** Fable 설계 검토 2회 (2026-07-15), 인간 대 AI 기보
(`data/human-play/game-records.jsonl`, 계속 증가 중)

## 목표

**원작 규칙 그대로의 로스트시티에서 가장 강한 에이전트.** 레거시 코드 호환은 고려하지 않는다.
env/obs/보상/학습 루프 재작성 모두 허용.

## 확정된 규칙 (룰북 원문 검증 완료)

코스모스 공식 영문 룰북(691820-02) 및 Board Game Arena 구현체로 교차 확인:

- 3라운드를 두고 **누적 총점**이 높은 쪽이 매치 승리.
- 1라운드 선공: "가장 나이 많은 사람" → 게임 내적으로는 **임의**.
- **2·3라운드 선공: 누적 점수가 더 많은 쪽** ("The player who has more points begins.")
  — 번갈아 가는 것이 아니다.
- **정확한 동점일 때의 선공은 룰북에 없다.** 공식 룰북·BGA 모두 침묵.
  → **무작위(동전 던지기)로 정한다.** 결정론적 규칙은 대칭 제로섬 셀프플레이에
  자리(seat) 비대칭을 주입해 착취 가능한 구멍이 된다.

점수 계산(`(랭크합 − 20) × (1 + 악수) + (8장 이상 +20)`)과 "방금 버린 카드는 즉시
회수 불가"(`just_discarded`)는 **현재 엔진이 이미 정확하다**
(`engine.py:244`, `engine.py:124`).

### 선공이 중요한 이유: 덱 시계

라운드는 **덱의 마지막 카드를 뽑는 순간** 끝난다. 카드는 놓은 뒤에 뽑으므로 마지막
카드를 뽑은 쪽은 그 카드를 쓰지 못한다. 총 턴 수가 홀수면 **선공이 한 장 더 놓는다**.
총 턴 수 = 44 + (버림패 드로우 횟수)이므로 **플레이어가 홀짝을 조작할 수 있다**
(실측 게임당 버림패 드로우 ≈6.6회). 선공권이 누적 점수에 달려 있으므로, 2라운드
마진에는 **점수를 넘어 3라운드 선공권이라는 추가 가치**가 붙는다.

### 측정된 사실: stalling은 정적 상대 착취이지 균형이 아니다 (Phase 1 사이징의 근거)

버림패 드로우는 덱을 줄이지 않으므로, **양쪽이 계속 버림패만 뒤지면 라운드가 끝나지
않는다.** 기존 학습 로그를 보면 이 착취가 실제로 학습된다:

| 학습 체제 | 라운드 길이 추이 | 400수 상한 도달 |
|---|---|---|
| 정적 휴리스틱 상대 (`balanced.yaml`) | 82 → **116수** (eval greedy **168수**) | 0.4~0.7% |
| **셀프플레이 (league)** | 71 → **53.6수로 수렴** | **0%** |

정적 약체 상대에게는 턴을 늘릴수록 원정 깊이·8장 보너스에서 강자가 이득이라 질질 끄는
것이 합리적이다. 그러나 **셀프플레이에서는 상대도 똑같이 끌 수 있어 상쇄되고, 라운드가
자연 길이(≈54수 = 44 덱 드로우 + ~10 버림패 드로우)로 수렴한다.**

**결론:** 우리의 목표 체제는 셀프플레이/league이므로 **3라운드 매치 ≈ 160수**로 잡으면
되고, 기존 `rollout_steps=400` 안에 여유롭게 들어간다. 단 두 가지를 유의한다:

1. **정적 상대로 3라운드를 학습시키면 매치가 350~500수까지 늘어날 수 있다** — 정적 상대
   워밍업 단계를 쓴다면 스캔 길이를 따로 잡아야 한다.
2. **라운드 상한(~120수)은 그 자체가 착취 가능한 구멍이다.** 앞선 쪽이 상한까지 끌면
   라운드가 유리한 상태로 얼어붙는다. `max_steps_rate`를 게이트 지표로 계속 감시할 것
   (셀프플레이에서는 0이어야 정상).

## 현재 코드의 결함 (Fable 2회 교차 검증 — 8건 전부 사실 확인)

1. **env가 1라운드짜리** — `State`에 라운드/누적 점수 없음 (`types.py:46`),
   `to_move=0` 하드코딩 (`engine.py:63`).
2. **롤아웃의 87%가 죽은 스텝** — 한 라운드는 실측 평균 50.6수(최대 56)인데
   `rollout_steps = MAX_STEPS = 400` (`ppo.py:66`)이고, 스캔 **안에는 리셋이 없다**
   (`reset_done_envs`는 업데이트 사이에서만, `ppo.py:275`).
3. **GAE 부트스트랩 초기값이 0** (`ppo.py:691`). 지금은 모든 에피소드가 스캔 안에서 끝나
   무해하나, **in-scan 리셋을 켜는 순간 편향**이 된다.
4. **shaping 어닐링 회계 버그** — `update * batch_games * rollout_steps` (`ppo.py:222`)로
   **죽은 스텝까지 세어서**, 5M 어닐링이 250 업데이트 중 2번째에 끝난다. league는 shaping을
   아예 끈다 (`league.py:592`).
   → **"shaping은 불필요하다"는 과거 결론이 있다면 무효다. 켜진 적이 없다.**
5. **obs에 `to_move`가 없다.** 그런데 value loss는 `active` 마스크로 학습되어 (`ppo.py:624`)
   **상대 차례 상태에서도** 가치를 맞추라고 요구한다. critic이 차례를 구분할 단서가
   `step_count / 400`뿐 — MLP에게 부동소수점에서 홀짝을 뽑으라는 요구다.
6. **점수차 정규화가 ÷780** (`MAX_ABS_SCORE`, `obs.py:68`). ±50점 차가 ±0.06 → 사실상
   안 보인다.
7. **셀프플레이에서 상대 자리 결정을 전부 버린다** (`stop_gradient`, `ppo.py:425`) —
   의사결정의 절반을 낭비.
8. **평가가 단판 승률** (`gates.py`) — 3라운드 개편의 성패를 측정할 자가 없다.

## 핵심 설계 결정

### PRNG: 모든 무작위성을 `reset()`에서 미리 뽑는다 (필수)

라운드 전환 리셔플과 동점 코인플립을 `step()` 안에서 샘플링하면 `step`이 확률적이 되어
서명이 바뀌고, 모든 rollout/eval/gates 바디에 키를 꿰어야 하며 duplicate 미러링이 꼬인다.

**`reset()`에서 3라운드 덱 순서 전부(3×60), 동점 코인플립 비트 2개, 1라운드 선공 비트
1개를 미리 샘플링해 `State`에 저장한다.** `step()`은 결정론을 유지하고 라운드 전환은
다음 덱 슬라이스로 스위치만 한다. 덤: **미러 매치(같은 3딜 + 자리 교대 + 같은 코인플립
비트)가 공짜로 따라온다** — Phase 5의 antithetic 페어링이 그대로 성립.

### 라운드 전환 vs 매치 리셋의 분리

- **라운드 전환은 엔진 `step()` 내부**에서 (`done=False` 유지, carry 갱신, 덱 슬라이스 전환)
- **매치 done 리셋은 rollout 바디**에서 (in-scan auto-reset)

이러면 두 메커니즘이 깨끗이 분리된다. 단 위의 pre-sampled 덱 설계가 전제다.

- `State`에 **라운드 스텝 카운터와 매치 스텝 카운터를 둘 다** 둔다 (forced-done은 라운드
  단위, obs 정규화는 라운드 상대 진행도 + round 원-핫).
- 중간 라운드 forced-done(라운드 상한 초과) 시 **자연 종료와 동일하게 라운드 종료 → 전환**.

### 보상 스케일에 대한 해소 (혼동 방지)

`sign()`에 가까운 작은 scale은 **틀린 선택**이다 — 150수에 걸쳐 모든 수가 동일한 ±1을
받아 크레딧 할당이 전부 critic에 떠넘겨지고, 5점 차 패배와 80점 차 패배의 그래디언트가
같아진다. **scale 30~50의 tanh가 절충점**이다: |마진| ≳ 60에서는 `sign`을 근사하면서
크레딧 그래디언트를 보존한다. 대신 5점 승과 80점 승을 여전히 구분하므로 순수 승률
최적화 대비 **마진 쪽으로 약간 왜곡**된다 — 이 잔여 왜곡은 Phase 5의 후기 fine-tune으로
scale을 낮춰서(예: 50 → 25) 제거한다.

명시적 risk 항은 **넣지 않는다.** carry + round를 obs에 넣은 종료 보상이 올바른 리스크
태도(뒤지면 도박, 앞서면 잠금)를 자동으로 유도한다.

## 체크리스트

### Phase 0a — 학습 루프 수정 (최우선, 단판 체제에서 검증 가능)

- [x] **GAE 부트스트랩 수정**: 잘린 에피소드를 `V(s_final)`로 부트스트랩 (`ppo.py:691`).
- [x] **in-scan auto-reset**: 롤아웃 스캔 안에서 done env 리셋 → 죽은 스텝 제거.
      **실측(rollout_steps=400, batch_games=64, 3 업데이트):** main은 활성 17.3%
      (learner 액션 2,221/업데이트), 수정 후 **활성 100% (12,842/업데이트) — 동일 연산량에
      샘플 5.8배.** 활성 비율은 게임 길이에 비례하므로, 학습 초기 정책(~69수)에서 5.8배,
      학습된 정책(~50수)이면 8배에 가까워진다. 3라운드 매치(~150수)가 되면 약 2.7배로
      줄지만 그때는 스캔 전체가 실제 매치로 채워진다.
- [x] **메트릭 파이프라인 재작업 (필수, 놓치면 조용히 오염됨)**: `rollout_metrics`는
      `final_env`에서 통계를 읽고 `episode_return = jnp.sum(transitions.reward, axis=0)`은
      **env당 에피소드 1개를 가정**한다 (`ppo.py:1069`). in-scan 리셋 후에는 final_env가
      에피소드 중간이고 여러 에피소드의 보상이 합산된다 → **metrics.jsonl 전체가 쓰레기가
      된다.** done 경계에서 누적하는 에피소드 단위 집계로 바꿀 것.
- [x] **league assignments를 scan carry로**: `learner_seat`/`opponent_index`/`use_mirror`가
      업데이트 사이에서만 재샘플링된다 (`ppo.py:568`). in-scan 리셋을 켜면 리셋 시점에
      스캔 내부에서 재샘플링해야 한다 — 안 하면 자리/상대가 에피소드 간 고정되어 편향.
- [x] **shaping 어닐링 회계 수정**: 패딩된 스캔 스텝이 아니라 **learner 액션 수** 기준
      (`ppo.py:222`). `shaping_coef`는 호스트에서 계산해 jit 함수에 넘기므로
      (`ppo.py:221`), 직전 업데이트의 액션 수를 호스트에서 누적하는 **1-업데이트 지연**
      구조가 된다 (무해).
- [x] **회귀 게이트 — 통과 (단, 게이트 설계를 고쳐야 했다).**

      **(1) "동일 learner-액션 예산"은 잘못된 게이트였다.** PPO 진척도는 샘플 수와
      **옵티마이저 스텝 수** 둘 다에 달려 있다. 샘플을 맞추면(64 vs 250 업데이트) 새 코드는
      그래디언트 스텝이 1/4이 되어 덜 수렴한다. 업데이트당 연산량은 동일하므로
      **올바른 게이트는 250 대 250**(같은 컴퓨트·같은 그래디언트 스텝, 샘플만 5.8배)이다.

      **(2) 동일 컴퓨트 게이트 결과 (2만판 duplicate eval):**

      | 앵커 | 지표 | 베이스라인 | Phase 0a |
      |---|---|---|---|
      | **expert** (강함) | 승률 | 0.4525 | **0.5071** |
      | **expert** | 평균 점수차 | −4.28 | **−0.56** |
      | balanced (약함) | 승률 | 0.9866 | 0.9292 |
      | balanced | 평균 점수차 | 116.8 | **128.1** |

      학습 곡선은 **모든 업데이트 지점에서** 새 코드가 위(최종 return 0.963 vs 0.935).

      **(3) balanced 승률 하락은 전부 `MAX_STEPS=400` 아티팩트다.** 새 정책 2,048판 분해:

      | | 판수 | 패배율 |
      |---|---|---|
      | 자연 종료 | 1,214 | **0.08%** |
      | 400수 상한 도달 | 834 (40.7%) | **20.62%** |

      **전체 패배의 99.4%가 상한에 부딪힌 판에서 발생.** 새 정책은 자연 종료 게임에서
      **99.92% 승률**이다. 샘플이 5.8배라 stalling 착취를 더 깊이 배웠고(greedy 225수),
      그 결과 40%가 인위적 벽에 박아 원정 미완성인 채 얼어붙는다.

      **결론:** Phase 0a는 성공. expert(=stalling을 허용하지 않는 앵커)에서 45%→51%로
      실제 실력이 올랐다.

### Phase 1 — 3라운드 env

**구현: `src/lost_cities_jax/match.py` (커밋 `7cd299d`).** 단판 엔진을 고치지 않고 **감쌌다**
— `engine.py`는 TS 클라이언트와의 차분 테스트가 지키는 규칙 오라클이고, 라운드 자체는
매치가 생겨도 변하지 않는다. `MatchState`가 내부에 단판 `State`를 든다.

- [x] `MatchState`: `round`(단판 State), `deck_orders`(3×60), `coin_flips`(3,),
      `round_idx`, `carry`(2, 플레이어별 누적 점수), `done`.
      별도 매치 스텝 카운터는 두지 않았다 — 매치는 3라운드가 끝나면 종료하므로 종료 판정에
      불필요하고, 라운드 진행도는 내부 `State.step_count`(라운드마다 리셋)가 이미 준다.
- [x] `match_step()`: 덱 소진 시 라운드 < 3이면 carry 갱신 → 다음 덱 슬라이스 → `round_idx += 1`,
      `done` 유지. 라운드 == 3이면 `done = True`. **보상은 3라운드 끝에서만.**
- [x] **선공 규칙**: `carry[0] > carry[1]` → p0, `<` → p1, `==` → pre-sampled 코인플립.
      1라운드는 특수 케이스가 **필요 없다** — carry가 (0,0)이라 동점 분기가 자동으로 코인을
      뽑고, 그게 룰북의 "가장 나이 많은 사람"(= 임의)과 정확히 같다.
- [x] **PRNG**: 딜 3벌 + 코인 3개를 `match_reset`에서 전부 미리 뽑아 상태에 저장.
      `match_step`은 결정론적이라 키를 rollout/eval/gates에 꿸 필요가 없고, **미러 매치가
      자리 라벨 교체만으로 성립**한다 (Phase 5 antithetic 페어링이 여기 의존).
- [x] ~~라운드당 스텝 상한을 400 → ~120으로 분리~~ — **철회한다. 라운드 상한은 400을 유지.**

      **이 항목의 원래 명분이 사라졌다.** 근거는 "3라운드(~360수)가 400스텝 스캔에 들어가게"
      였는데, **Phase 0a의 GAE 절단 부트스트랩이 스캔 길이 제약을 없앴다.** 잘린 매치는
      올바르게 부트스트랩되므로 스캔 길이는 이제 자유 파라미터다.

      **그리고 상한을 낮추면 아티팩트가 악화된다.** Phase 0a 실측: 400수 벽에 부딪힌 판의
      20.6%가 패배로 뒤집혔고 **전체 패배의 99.4%가 거기서 발생**했다. 상한을 120으로 내리면
      **더 많은 판이 벽에 박고**, "앞선 쪽이 상한까지 끌어 라운드를 얼린다"는 착취가 훨씬
      쉬워진다. 셀프플레이에서는 라운드가 54수로 수렴해 120이든 400이든 안 걸리므로,
      **덜 걸리는 쪽을 두고 `max_steps_rate`를 감시**하는 것이 맞다.

- [ ] **학습 파이프라인 배선** (원래 계획서에서 누락된 항목): `ppo.py`의 rollout/eval,
      `gates.py`, `league.py`가 전부 단판 `State`를 받는다. `MatchState`를 받도록 배선한다.
      - 매치용 obs 래퍼: 우선 `observation(state.round, player)`로 carry-blind하게 연결
        (carry/round 피처는 Phase 3에서 추가).
      - `match_legal_action_mask`, `match_step`, `match_score`로 교체.
      - 스캔 길이는 자유 파라미터가 되었으므로 셀프플레이 매치 길이(~160수) 기준으로 잡되,
        잘려도 부트스트랩이 처리한다.
- [ ] **league 풀 재구축**: 기존 스냅샷 멤버는 obs 변경으로 전부 무효화된다 — Phase 3 이후로
      미룬다 (obs가 확정되기 전에 풀을 다시 채우면 두 번 일한다).
- [x] **테스트 12개** (`tests/lost_cities_jax/test_match.py`): 라운드당 덱 드로우 = 44 +
      버림패 드로우만큼 연장, `carry`가 각 라운드를 정확히 한 번씩 적립, 선공 규칙 3분기,
      1라운드 코인 공정성(≈50/50), 라운드 경계에서 누적 점수 불연속 없음(PBRS 전제),
      미러 매치 대칭성, 보상은 3라운드 끝에서만 지급.

### Phase 0b — 측정 자 (첫 장기 3라운드 학습 **전에** 착지)

- [ ] **매치 단위 평가**: 3딜 전부 미러링 + 자리 교대 + **동일 코인플립 비트**,
      매치 승률 + Wilson CI, 평균 총 마진. `gates.py`/`league.py`의 단판 기준을 대체.
      **`gates.py:884`, `gates.py:961`의 `MAX_STEPS` 길이 단판 스캔 2곳 포팅 포함** —
      exploiter 학습·평가 경로 전체가 3라운드로 가야 성공 기준 3을 잴 수 있다.
- [ ] **carry 조건부 프로브**: carry ∈ {−60, −25, −1, +1, +25, +60}을 주입한 3라운드 시작
      위치에서 승률·행동 변화(원정 개수, 악수 비율, 덱 레이스 비율) 측정.
- [ ] **shuffle bank 포맷 확장**: 덱과 함께 코인플립·선공 비트도 뽑도록. 안 그러면
      `jax.random.PRNGKey(0)` 고정 eval(`ppo.py:1025`)의 재현성이 매치 정의와 얽힌다.
- [ ] **legacy obs 버전 보존**: 구 정책을 3라운드 매치에 투입해 베이스라인을 재려면 구
      obs(454차원)로 추론해야 하는데, `checkpoint_policy_from_params`는 전역 `observation`을
      호출한다 (`ppo.py:802`) → obs 개편 후 구 체크포인트는 **로드 자체가 실패**한다.
      policy 로더가 체크포인트별 obs 버전을 받도록 할 것. **성공 기준 2의 분모가 여기 달렸다.**

### Phase 2 — 보상

- [ ] 종료 보상: 3라운드 끝에서만 `tanh(총_점수차 / scale)`, **scale = 30~50**
      (근거는 위 "보상 스케일에 대한 해소" 절).
- [ ] 마진 shaping 부활: `Φ(s) = carry + 현재 보드 점수차`, **작은 계수**(종료 보상 스케일의
      0.05~0.2). 현재의 계수 1.0 원점수 shaping은 ±1 종료 보상보다 10~30배 크다.
      learner 스텝 기준으로 어닐링하되 **후반까지 정확히 0으로 내리지 않는다.**
- [ ] γ=1.0에서 `Φ(s') − Φ(s)`는 **올바른 PBRS 형태다** (γ 누락 아님 — 검토에서 확인).

### Phase 3 — 관측

- [ ] `carry`: **÷75 스칼라 + 구간 원-핫**(약 9구간). 3라운드 정책은 "1점만 더" 임계값
      근처에서 급격히 꺾여야 한다. 기존 `score_diff`의 ÷780 정규화도 같이 고친다.
- [ ] `round_idx` 원-핫 + 남은 라운드 수.
- [ ] **`to_move` 비트** + "내가 이번 라운드 선공인가" + "현재 홀짝에서 마지막 덱 카드를
      누가 뽑는가"(덱 시계).
- [ ] 색깔별 **살아있는 점수 3분할**: 내 `col_top` 위로 아직 나올 수 있는 점수를
      (a) **내 손패**, (b) **버림패 더미**(공개돼 있고 회수 가능 — 빠뜨리기 쉬움),
      (c) **미공개**(덱 ∪ 상대 은닉 손패)로 나눠 넣는다. 상대에 대해서도 동일
      (상대 `col_top`은 공개). MLP가 7×60 채널에서 뽑아내기 어려운 비선형 집계이고,
      모든 개시/연장/차단 판단을 좌우한다.

### Phase 4 — 학습 효율

- [ ] **전지적 critic (CTDE)**: critic에만 상대 손패 + 덱 구성을 준다. 행동과 무관한
      정보이므로 정책 그래디언트를 편향시키지 않는다. 딜 운 분산을 정면으로 깎는다.
      **가치 경로를 정책 트렁크에서 분리해야 한다** (현재 공유 트렁크, `ppo.py:104`) —
      특권 정보가 정책 로짓으로 새면 안 된다.
      → 이 critic은 나중에 **PIMC 탐색의 리프 평가기로 그대로 재활용**된다.
- [ ] **양쪽 자리 학습**: `stop_gradient`된 상대 자리 전이(`ppo.py:425`)도 학습에 쓴다
      (샘플 효율 2배). 같은 게임의 두 자리는 반상관이므로 같은 배치에 두고 advantage
      정규화에 맡긴다.
- [ ] `gae_lambda` 재검토: 0.95는 150수 지평에서 너무 짧다(중반 수 직접 가중치 0.02).
      전지적 critic이 있으면 유지, 없으면 0.97~0.99. **λ=1은 금지**(딜 분산).
- [ ] `entropy_coef` **스윕으로 재결정**. (주의: "0.01이 원점수 shaping 기준으로 잡혔다"는
      추론은 **틀렸다** — league는 shaping을 끄고 학습했으므로 0.01은 이미 ±1 tanh 체제에서
      동작해온 값이다. 다만 3라운드에서 보상 빈도가 1/150로 희석되고 작은 shaping이
      추가되므로 재튜닝 자체는 타당하다. **근거 없이 10배 낮추면 과소탐색으로 직행한다.**)

### Phase 5 (나중) — 최종 강함

- [ ] **후기 fine-tune**: 학습 말미에 종료 보상 scale을 낮춰(50 → 25) 마진 왜곡을 제거하고
      순수 승률 쪽으로 당긴다.
- [ ] 페어드 antithetic 딜(같은 3딜 + 자리 교대 + 같은 코인플립 비트)을 **학습에** 도입.
      단순 포함이 아니라 **쌍으로 묶어** control variate로 써야 효과가 있다.
      (Phase 1의 pre-sampled PRNG 설계 덕에 사실상 공짜.)
- [ ] MMD식 정규화 셀프플레이 (loss에 ~20줄) — 2인 제로섬 근사 내시 보험.
- [ ] **추론 시점 탐색 (PIMC / ISMCTS)** — 최종 강함의 가장 큰 이득. 로스트시티는 블러핑
      경제가 없는 저기만성 불완전정보 게임이라 결정화 탐색이 잘 맞는다. raw net 대
      net+search 맞대결로 측정.
- [ ] 네트워크 용량 A/B (512×3 → 1024×3 또는 residual) — **파이프라인 변경이 끝난 뒤에.**

## 범위 밖 (명시적 동결)

- **웹 클라이언트/ONNX는 별도 계획 전까지 레거시 단판 모델로 동결한다.** obs 개편 즉시
  export 파이프라인(`scripts/export_jax_ppo_onnx.py:70`, manifest `observation_size: 454`),
  TS obs 빌더, TS 단판 엔진이 전부 비호환이 된다. 이 선언이 없으면 실행 중 스코프가
  웹 재작성으로 샌다.
- **Deep CFR 복귀** — 이미 BC 천장을 쳤고, 3라운드는 트리만 키운다. PPO+league를 학습
  백본으로 유지한다.
- 레거시 호환을 위한 타협.

## 성공 기준

0. **(Phase 0a 게이트)** 루프 수정 후 단판 체제에서 동일 learner-액션 예산으로 기존 anchor
   성적 재현 ≥ 동등. **없으면 auto-reset 버그가 3라운드 결과에 섞여 원인 분리가 불가능해진다.**
1. **carry 프로브에서 행동이 단조롭게 변한다** — carry 6개 수준에 걸쳐 원정 개수/악수 비율의
   **단조 추세**(CI 포함). ("행동이 변한다"는 노이즈로도 통과 가능하므로 단조성으로 정의.)
2. **매치 승률이 구 정책(3라운드에 그대로 투입)을 이긴다** — 페어드 매치 **≥ 1만 쌍**,
   매치 승률 **Wilson 하한 > 0.5** *및* 평균 총 마진 **CI 하한 > 0**.
3. 착취자(exploiter) 승률이 악화되지 않는다.
4. **(anchor 비회귀)** 휴리스틱 anchor 상대 매치 승률·라운드당 마진이 구 정책 대비 악화되지
   않는다. **기준 2의 구멍을 막는 항목**: carry-blind인 구 정책만 상대로 이기는 것은
   **카드 플레이가 퇴보해도 carry 착취만으로 달성 가능**하다.

   **단, anchor 선택에 주의:** Phase 0a에서 실증됐듯 **약한 anchor(`heuristic_balanced`)는
   무한 stalling을 허용해서 신호를 오염시킨다** — 승률이 실력이 아니라 상한 도달률을 잰다.
   **`heuristic_expert`처럼 stalling을 허용하지 않는 anchor**(게임이 자연 길이로 끝남)만
   실력 지표로 쓰고, 약한 anchor는 **자연 종료 게임만 필터링해서** 보거나 `max_steps_rate`를
   함께 보고할 것.

---

## 실행 결과 (2026-07-15, 커밋 `9ba5a07`)

### 동작하는 것

- **셀프플레이가 stalling을 스스로 제거한다.** 매치가 146.8수(라운드 ≈49수)로 수렴하고
  덱 레이스 비율 91%. Phase 0a에서 정적 상대가 225수까지 끌던 것과 대조된다.
- **미러 매치 평가가 정확하다.** 셀프플레이 duplicate 승률 0.4968, **평균 lead 정확히 0.0** —
  같은 3딜 + 같은 코인 + 자리 교대가 딜 운을 완전히 상쇄한다. 미완료 0%.
- **전지적 critic이 격리돼 있다.** 특권 입력을 흔들어도 정책 로짓은 비트 단위로 동일하고,
  가치만 움직인다 (테스트로 고정).

### 성공 기준 1 — **약하게만 충족. 사실상 미달.**

carry 프로브(3라운드 시작 시 carry 주입)에서:

| | scale 50 | scale 25 | scale 12 |
|---|---|---|---|
| 악수 스프레드 (carry −60 → +60) | 0.19 | 0.08 | **0.31** |
| 원정 개수 | 5.00 고정 | 4.99 고정 | **5.00 고정** |

- **악수 사용은 6개 carry 수준 전체에서 단조**로 움직인다 (뒤지면 배수 베팅 ↑). 방향은 맞다.
- 그러나 **원정 개수는 carry와 무관하게 5.00에 붙어 있다.** 굳은 습관이지 조건부 플레이가 아니다.
- 효과 크기가 작다.

### 확정된 진단

1. **shaping 가설은 틀렸다.** `potential_shaping_final=0.0`으로 완전히 꺼도 프로브는 평평했다.
2. **원인은 `terminal_scale`이다.** carry = −60에서 `tanh((margin − 60)/50)`의 인자는 현실적
   마진 범위에서 **거의 선형**이고, 선형 구간에서 `E[tanh]` 최대화는 `E[margin]` 최대화와 같다
   → 도박할 이유가 없다. 위험 추구는 tanh가 **강하게 볼록한** 구간에서만 나오며 scale을 낮춰야
   그 구간에 들어간다. scale 50 → 12에서 스프레드가 0.19 → 0.31로 커진 것이 이를 확인한다.
3. **프로브 설계 결함:** scale 12에서 `tanh(60/12) ≈ 1.0`이라 **±60은 포화 = 그래디언트 0**.
   거기서 정책은 학습된 바가 없다. **의미 있는 측정 구간은 `|carry| ≲ 2 × terminal_scale`.**
   프로브 수준을 scale에 맞춰 재설계해야 한다.

### 다음에 할 일

- [ ] **프로브 재설계**: carry 수준을 `terminal_scale`에 맞춰 잡는다 (포화 구간 측정 금지).
- [ ] **학습 중 carry 분포 확인**: 셀프플레이에서 3라운드 진입 시 carry가 실제로 얼마나
      퍼지는가. 좁으면 정책이 큰 deficit을 본 적이 없다는 뜻이고, 그게 진짜 원인일 수 있다.
- [ ] **"항상 5색 개시"가 정상인지 검증**: 인간 기보에서 AI는 4.81, 인간은 4.19를 열었다.
      5.00 고정은 의심스럽다. 셀프플레이 균형인지, 탐색 붕괴인지 (엔트로피 3.5 → 1.03).
- [ ] scale 스케줄(50 → 12 후기 fine-tune)이 처음부터 12로 학습하는 것보다 나은지 A/B.

---

## 판정: 보상은 **선형 총점**이다 (2026-07-15, Fable 3차 검토 + 실측)

사용자 제안("그냥 3판 총점이 크기만 하면 되는 것 아니냐")이 **맞았다. tanh 종료 보상은
오버엔지니어링이었다.**

### 근거 1 — 분해 논증 (실측)

보상이 총점에 선형이면 라운드가 독립이므로 3라운드 게임이 3개의 독립 단판으로 분해된다.
셀프플레이 2,048매치 실측: `corr(m1, m2) = 0.004`, `corr(m1+m2, m3) = 0.05`.
라운드를 잇는 유일한 고리인 **선공 어드밴티지는 +0.73 ± 0.84점** — 0과 구분 불가.

### 근거 2 — 리스크 태도는 값어치가 없다 (실측)

Fable이 리스크 태도를 직접 구현해 greedy 클론과 duplicate 6,144판 맞대결:

| 도박 정책 | 매치 승률 |
|---|---|
| 3R에서 20점 이상 뒤지면 온도 샘플링 | **0.482** (진다) |
| 40점 이상 뒤질 때만 | 0.498 (본전) |

**일부러 도박을 시켜도 지거나 본전.** 로스트시티의 분산 레버(한계 악수 ≈ Δσ +1.7에 마진
−2~3점)가 근본적으로 약해서, 분산을 사는 비용이 볼록성 이득을 먹는다.
carry 조건부 플레이의 가치 상한: **1승점 미만.**

### 근거 3 — 맞대결에서 단순한 쪽이 **이겼다**

duplicate 10,000판 (같은 3딜 + 같은 코인 + 자리 교대), 동일 컴퓨트 300 업데이트:

| A | B | A 승률 | A 평균 총점차 |
|---|---|---|---|
| **선형 총점** | tanh(총점/12) | **0.5859** (CI 0.576–0.596) | **+20.3점** |

버려도 손해가 없는 게 아니라 **버리니 더 강해졌다.** 이유는 리스크가 아니라 **신호 밀도**다:
tanh는 ~150수 매치에 포화된 ±1 하나를 주고, 선형은 매 수마다 그 수가 총점차를 움직인 만큼을
준다. γ=1에서 후자의 합이 정확히 최종 총점차로 telescoping되므로 **목적함수는 동일한데 크레딧
할당만 150배 조밀**하다.

### 근거 4 — 탐색 붕괴가 아니었다

프로브가 평평했던 이유는 탐색 붕괴가 아니다. 샘플링 프로브에서도 원정 5.00 ± 0.05,
엔트로피 1.36나트(유효 행동 3.9개)로 정상 수렴. 그리고 **critic은 carry를 완벽히 읽고 있었다**
(3R 시작 가치 −0.87 → +0.86 단조). 신호는 있었고, **정책이 그걸로 살 수 있는 물건이
없었을 뿐**이다.

### 최종 설계

```python
# match_ppo.py 롤아웃 바디
reward0 = jnp.where(active, (after - before) / cfg.reward.terminal_scale, 0.0)
```

- **성공 기준 1(carry 프로브 단조성)은 게이트에서 제거한다.** 최적 반응 자체가 이 게임에서
  거의 존재하지 않는다는 것이 측정 결과다. 기준 2·4(맞대결 + anchor 비회귀)가 옳은 자다.
- `carry`는 obs에 **남긴다** (비용 0, 선공 규칙이 키로 쓰는 상태, critic이 잘 읽음).

### 목적함수와 무관하게 살아남은 것 (전부 순이득)

in-scan auto-reset + GAE 절단 부트스트랩(샘플 5.8배), 메트릭 재작업, **비대칭 CTDE critic**,
**양쪽 좌석 학습**, **duplicate 매치 평가**, pre-sampled PRNG/미러 설계, 그리고 match_obs의
단판 결함 수정분(`to_move`, 덱 시계, ÷780 → ÷75, 살아있는 점수 3분할).

**죽은 것은 tanh 종료 보상과 carry 구간 원-핫뿐이다.**

---

## 신구 대결: 진짜 3라운드 클래식에서 (2026-07-15)

**질문: "기존 학습 방법 대비 실제로 뭐가 나아졌나?"**

duplicate 매치 8,192판(같은 3딜 + 같은 코인 + 자리 교대), 진짜 3라운드 게임:

| 신(매치 스택) | 상대 | 신 승률 | 신 평균 총점차 | 상대 learner 액션 |
|---|---|---|---|---|
| 39.3M | 기존 baseline (정적 상대 학습) | 0.6077 | +19.5 | 104M |
| 39.3M | Phase 0a 게이트 (루프 수정만) | **0.5842** | +16.0 | **411M (10.5배)** |
| 39.3M | league (셀프플레이, 웹 배포판) | 0.3142 | −37.0 | 122.6M (3.1배) |
| **131M** | **league (셀프플레이, 웹 배포판)** | **0.6094** (CI 0.599–0.620) | **+22.0** | 122.6M |

**결론:**

1. **샘플 효율이 크게 올랐다.** 39.3M짜리가 **411M(10.5배)짜리를 이긴다.**
2. **동일 예산에서 기존 최강(league)을 이긴다** — 131M vs 122.6M에서 0.6094.
3. 39.3M에서 league에 졌던 것(0.3142)은 **약해서가 아니라 예산이 1/3이어서**였다.

**남은 단서:** league는 **착취자(exploiter) 구조**를 포함한 런이다. 평균 강함은 우리가 이기지만
**exploitability는 아직 재지 않았다.** "덜 착취당한다"는 증명되지 않았다.

---

## 기여도 A/B (2026-07-15)

각 조각을 하나씩 끄고 동일 컴퓨트(300 업데이트, batch 512)로 학습시킨 뒤,
full 스택과 duplicate 매치 8,192판 맞대결. **0.5 미만 = 그 조각이 기여하고 있었다.**

| 제거한 것 | full 대비 승률 | 95% CI | 평균 총점차 | 판정 |
|---|---|---|---|---|
| **양쪽 좌석 학습** | **0.3317** | [0.322, 0.342] | −28.6 | **압도적 기여** |
| 매치 관측 (carry/to_move/덱시계/살아있는점수) | 0.4751 | [0.464, 0.486] | −4.0 | 유의하게 기여 |
| **전지적 critic (CTDE)** | **0.5160** | [0.505, 0.527] | +2.6 | **오히려 해가 된다** |

### 1. 양쪽 좌석 학습 — 가장 큰 기여

끄면 승률이 0.33으로 무너진다. 같은 업데이트 수에서 learner 액션이 **정확히 절반**이 되므로
상당 부분은 샘플 수 효과다 — 그러나 **그게 요점이다.** 셀프플레이에서 상대 좌석의 수는 같은
네트워크가 둔 것인데 기존 트레이너는 `stop_gradient`로 버렸다. **공짜로 데이터가 2배**가 된다.

### 2. 매치 관측 — 작지만 실재

0.4751 (CI 상한 0.486 < 0.5). `to_move`, 덱 시계, ÷780 → ÷75 스케일 수정, 살아있는 점수
3분할이 합쳐서 약 2.5%p 값어치. carry 자체는 (분해 논증대로) 거의 기여하지 않으므로,
이 이득의 대부분은 **단판 obs의 결함 수정분**으로 보인다.

### 3. 전지적 critic — **Fable의 최우선 권고가 틀렸다**

Fable은 이것을 "가장 큰 누락 아이디어"로 꼽았다. 실측은 **반대**다: 끄면 오히려
**0.5160 (CI 하한 0.505 > 0.5)** 으로 유의하게 **더 강해진다.**

그럴듯한 이유: 특권 critic은 덱을 알기 때문에 가치를 아주 잘 맞추지만, 그 결과
`V(전체상태) ≠ E[리턴 | 마스킹된 관측]`이 되어, **어드밴티지에 정책이 통제할 수 없는 성분**이
섞인다. 액터 입장에서 그건 분산 감소가 아니라 노이즈다. 부분관측 환경에서 비대칭 critic이
해가 되는 것은 알려진 현상이다.

**조치: 전지적 critic을 기본에서 끈다.** (PIMC 탐색의 리프 평가기로 재활용한다는 계획은
별개로 유효할 수 있으나, 정책 학습용으로는 손해다.)

### 종합: 실제로 값어치 있었던 것

1. **in-scan auto-reset + GAE 절단 부트스트랩** — 샘플 5.8배, expert 0.4525 → 0.5071
2. **양쪽 좌석 학습** — 데이터 2배, 단독으로 승률 0.33 → 0.5
3. **선형 총점 보상** — tanh 대비 0.5859 (사용자 제안)
4. **매치 관측 결함 수정** — 약 2.5%p
5. ~~전지적 critic~~ — **해가 된다. 끈다.**

### 정정: 전지적 critic의 가치는 **스케일에 따라 뒤집힌다**

위 A/B는 39.3M learner 액션 규모에서 돌렸다. 실전 규모(131M)에서 동일 설정으로 둘을 직접
맞붙이면 **결론이 반대로 나온다.**

| 규모 | critic OFF의 승률 (critic ON 상대) | 판정 |
|---|---|---|
| 39.3M (A/B 규모) | **0.5160** [0.505, 0.527] | OFF가 낫다 |
| **131M (실전 규모)** | **0.4633** [0.453, 0.474] | **ON이 낫다** |

**전지적 critic은 그것을 학습시킬 데이터가 충분해질 때 값어치를 한다.** 39.3M에서는 특권
정보를 쓰는 큰 가치 트렁크를 제대로 못 맞춰서 어드밴티지에 노이즈만 얹었고, 131M에서는
제대로 맞춰서 분산 감소가 실현된다.

**교훈: 배포할 예산이 아닌 규모에서 ablation을 돌리면 결론이 뒤집힐 수 있다.**
`privileged_critic` 기본값은 **ON으로 되돌린다.**

### 최종 순위 (진짜 3라운드 클래식, duplicate 8,192판)

| 모델 | learner 액션 | vs league |
|---|---|---|
| **매치 스택 (critic ON)** | 131M | **0.6094** (+22.0점) |
| 매치 스택 (critic OFF) | 131M | 0.5526 (+10.9점) |
| league (기존 최강, 웹 배포판) | 122.6M | — |

### 기여도 재측정: 동일 규모(131M)에서

39.3M A/B는 critic에게 가장 불리한 지점이었다. 두 조각을 **같은 131M 예산**에서 다시 쟀다:

| 조각 | full의 승률 (그 조각 뺀 버전 상대) | 순이득 |
|---|---|---|
| **양쪽 좌석 학습** | 0.6707 | **+17.1 %p** |
| **전지적 critic** | 0.5367 | **+3.7 %p** |

**양쪽 좌석이 약 4.6배 크다.** 그리고 성격이 다르다:

| 규모 | 양쪽 좌석 | 전지적 critic |
|---|---|---|
| 39.3M | +16.8 %p | **−1.6 %p** |
| 131M | **+17.1 %p** | +3.7 %p |

**양쪽 좌석은 규모와 무관하게 안정적**(+17%p). **critic은 규모를 탄다.**

이유: 양쪽 좌석은 **데이터를 2배로** 만든다(65.5M → 131M, 같은 컴퓨트에서). critic은 데이터를
안 늘리고 **채점 정확도만** 올리는데, 코치가 배울 게 많아져서 데이터가 부족하면 오히려
엉터리 채점을 한다.

**Fable은 critic을 "가장 큰 누락 아이디어" 1순위로, 양쪽 좌석을 4순위로 꼽았다. 정확히 거꾸로였다.**

---

## Exploitability: 우리가 league보다 **덜 착취당한다** (2026-07-15)

**남겨뒀던 단서를 해소했다.** 평균 실력에서 이기는 것과 "파먹을 약점이 없는 것"은 다른
속성이다. 가위바위보에서 바위를 60% 내는 놈은 아무한테나 이길 수도 있지만, 그 습관을
알아챈 놈에게는 매번 진다.

**측정 방법:** 정책을 얼려놓고, **오직 그놈만 이기도록 특화된 새 정책을 처음부터 학습**시킨다
(`src/lost_cities_jax/exploit.py`). 착취자가 도달한 승률이 곧 그 정책이 못 막아낸 습관의 크기다.

**동일 착취자 예산 (250 업데이트 × batch 1024 = 32.5M learner 액션):**

| 얼려놓은 정책 | 착취자 승률 | 95% CI |
|---|---|---|
| **우리 매치 스택 (131M)** | **0.2278** | [0.219, 0.237] |
| league (웹 배포판, 122.6M) | **0.3213** | [0.311, 0.331] |

**league가 9.4%p 더 털린다.** 그리고 둘 다 0.5에서 한참 멀다 — 어느 쪽도 만만한 상대는 아니다.

### 최종 정리: 두 축 모두에서 이긴다

| 축 | 결과 |
|---|---|
| **평균 실력** (정면 대결) | 0.6094, +22.0점 ✅ |
| **약점 적음** (exploitability) | 0.2278 vs 0.3213 ✅ |

### 단서

1. **하한선이다.** 더 세거나 더 오래 학습한 착취자는 더 찾아낼 수 있다. **절대값이 아니라
   동일 예산에서의 비교로만 의미가 있다.**
2. **착취자 예산이 목표의 약 1/4에 불과하다 (32.5M vs 131M).** 약한 공격자다.
   따라서 **"우리는 22.8%밖에 안 털린다"고 말할 수 없다** — 그건 이 약한 공격자 기준일 뿐이다.
   살아남는 주장은 **"동일 예산에서 league가 더 털린다"** 하나뿐이다.
   착취자를 목표와 같은 예산(131M)으로 키워서 **순서가 유지되는지** 확인해야 한다.
3. league는 단판 정책이라 3라운드 게임에선 다소 제 물이 아니다 — 다만 carry가 무의미하다는
   것이 이미 증명됐으므로 큰 불리함은 아니다.

**흥미로운 점:** 우리는 **순수 셀프플레이**이고 league는 **착취자 구조를 학습에 넣은** 런인데,
그런데도 우리가 덜 착취당한다. 착취자 구조가 exploitability를 낮춰줄 것이라는 기대가
이 게임에서는 확인되지 않았다.

### 착취자에 제대로 자금을 대고 재측정 (131M, 목표와 동일 예산)

앞선 32.5M 측정은 **공격자가 목표보다 4배 부족**했다. 예산을 목표와 맞춰 다시 쟀다.
실측 확인: 착취자 learner 액션 **130.4M** (우리 상대) / **131.1M** (league 상대).

| 얼려놓은 정책 | 착취자 승률 | 95% CI | 착취자 평균 마진 | 이전(32.5M) |
|---|---|---|---|---|
| **우리 매치 스택** (131.1M 학습) | **0.4657** | [0.455, 0.477] | **−5.5점** | 0.2278 |
| league (122.6M 학습) | **0.6295** | [0.619, 0.640] | **+28.8점** | 0.3213 |

**CI가 전혀 겹치지 않고, 격차가 오히려 벌어졌다 (0.094 → 0.164).**

**질적으로 선을 넘었다:** 제대로 자금 댄 전담 공격자는 **league를 아예 이긴다**(0.63, +28.8점).
반면 **우리는 여전히 못 뚫는다**(0.47, −5.5점).

#### 우리에게 불리한 단서 (반드시 함께 읽을 것)

- **둘 다 1000 업데이트 끝까지 정체 없이 오르고 있었다. 이 수치도 여전히 하한선이다.**
- **우리 쪽 착취자의 말단 기울기가 더 가파르다** (+0.031 vs +0.016 / 100 업데이트).
  league 착취자 곡선은 꺾이기 시작했는데 우리 쪽은 아직 갈 길이 남았다.
  **공격자 예산을 크게 더 키우면 격차가 좁혀질 수 있고, 뒤집힐 가능성도 배제 못 한다.**

#### 구조적 교란 (순수한 "학습법 A vs B"가 아니다)

1. **league는 단판 정책이다.** carry도 매치 점수도 모른다. 더 털리는 것의 일부는
   **학습 방법이 아니라 구조적 맹점** 때문일 수 있다.
2. **둘 다 greedy(argmax)로 뒀다.** 결정론적 정책은 **정의상 최대로 착취당한다.**
   양쪽을 똑같이 대우했으니 비교는 공정하나, **절대값은 부풀려져 있다.**

#### 살아남는 주장 / 못 하는 주장

- ✅ **"동일 예산의 전담 공격자 앞에서 league는 뚫리고 우리는 안 뚫린다."**
- ❌ "우리 정책은 착취 불가능하다." — 하한선일 뿐이고, 우리 쪽 곡선은 아직 오르는 중이다.
