# 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는 아직 재지 않았다.** "덜 착취당한다"는 증명되지 않았다.