Files
coorl-lost-cities/docs/plans/lost-cities-classic-3round.md
T
coolguyandClaude Opus 4.8 62b755c3f0 Retract the 120-ply round cap and record the missing wiring step
The cap item existed so a three-round match would fit inside a 400-step scan.
Phase 0a's truncation bootstrap removed that constraint -- scan length is a free
parameter now -- and the same phase showed the cap is the thing that turns
stalled games into losses: 20.6% of cap-hit games lost, 99.4% of all losses. A
120-ply cap would put *more* games into the wall and make "freeze the round
while ahead" easier, so the round cap stays at 400 and max_steps_rate stays a
watched metric.

Also records what the plan never listed: ppo.py, gates.py and league.py all take
a single-round State and still have to be wired to MatchState. That is the
largest remaining piece of Phase 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 02:09:49 +09:00

22 KiB
Raw Blame History

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 — 학습 루프 수정 (최우선, 단판 체제에서 검증 가능)

  • GAE 부트스트랩 수정: 잘린 에피소드를 V(s_final)로 부트스트랩 (ppo.py:691).

  • 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배로 줄지만 그때는 스캔 전체가 실제 매치로 채워진다.

  • 메트릭 파이프라인 재작업 (필수, 놓치면 조용히 오염됨): rollout_metricsfinal_env에서 통계를 읽고 episode_return = jnp.sum(transitions.reward, axis=0)env당 에피소드 1개를 가정한다 (ppo.py:1069). in-scan 리셋 후에는 final_env가 에피소드 중간이고 여러 에피소드의 보상이 합산된다 → metrics.jsonl 전체가 쓰레기가 된다. done 경계에서 누적하는 에피소드 단위 집계로 바꿀 것.

  • league assignments를 scan carry로: learner_seat/opponent_index/use_mirror가 업데이트 사이에서만 재샘플링된다 (ppo.py:568). in-scan 리셋을 켜면 리셋 시점에 스캔 내부에서 재샘플링해야 한다 — 안 하면 자리/상대가 에피소드 간 고정되어 편향.

  • shaping 어닐링 회계 수정: 패딩된 스캔 스텝이 아니라 learner 액션 수 기준 (ppo.py:222). shaping_coef는 호스트에서 계산해 jit 함수에 넘기므로 (ppo.py:221), 직전 업데이트의 액션 수를 호스트에서 누적하는 1-업데이트 지연 구조가 된다 (무해).

  • 회귀 게이트 — 통과 (단, 게이트 설계를 고쳐야 했다).

    **(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를 든다.

  • MatchState: round(단판 State), deck_orders(3×60), coin_flips(3,), round_idx, carry(2, 플레이어별 누적 점수), done. 별도 매치 스텝 카운터는 두지 않았다 — 매치는 3라운드가 끝나면 종료하므로 종료 판정에 불필요하고, 라운드 진행도는 내부 State.step_count(라운드마다 리셋)가 이미 준다.

  • match_step(): 덱 소진 시 라운드 < 3이면 carry 갱신 → 다음 덱 슬라이스 → round_idx += 1, done 유지. 라운드 == 3이면 done = True. 보상은 3라운드 끝에서만.

  • 선공 규칙: carry[0] > carry[1] → p0, < → p1, == → pre-sampled 코인플립. 1라운드는 특수 케이스가 필요 없다 — carry가 (0,0)이라 동점 분기가 자동으로 코인을 뽑고, 그게 룰북의 "가장 나이 많은 사람"(= 임의)과 정확히 같다.

  • PRNG: 딜 3벌 + 코인 3개를 match_reset에서 전부 미리 뽑아 상태에 저장. match_step은 결정론적이라 키를 rollout/eval/gates에 꿸 필요가 없고, 미러 매치가 자리 라벨 교체만으로 성립한다 (Phase 5 antithetic 페어링이 여기 의존).

  • ~라운드당 스텝 상한을 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가 확정되기 전에 풀을 다시 채우면 두 번 일한다).

  • 테스트 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:961MAX_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.050.2). 현재의 계수 1.0 원점수 shaping은 ±1 종료 보상보다 1030배 크다. 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를 학습 백본으로 유지한다.
  • 레거시 호환을 위한 타협.

성공 기준

  1. (Phase 0a 게이트) 루프 수정 후 단판 체제에서 동일 learner-액션 예산으로 기존 anchor 성적 재현 ≥ 동등. 없으면 auto-reset 버그가 3라운드 결과에 섞여 원인 분리가 불가능해진다.

  2. carry 프로브에서 행동이 단조롭게 변한다 — carry 6개 수준에 걸쳐 원정 개수/악수 비율의 단조 추세(CI 포함). ("행동이 변한다"는 노이즈로도 통과 가능하므로 단조성으로 정의.)

  3. 매치 승률이 구 정책(3라운드에 그대로 투입)을 이긴다 — 페어드 매치 ≥ 1만 쌍, 매치 승률 Wilson 하한 > 0.5 평균 총 마진 CI 하한 > 0.

  4. 착취자(exploiter) 승률이 악화되지 않는다.

  5. (anchor 비회귀) 휴리스틱 anchor 상대 매치 승률·라운드당 마진이 구 정책 대비 악화되지 않는다. 기준 2의 구멍을 막는 항목: carry-blind인 구 정책만 상대로 이기는 것은 카드 플레이가 퇴보해도 carry 착취만으로 달성 가능하다.

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