Self-play works: matches converge to 146.8 plies (~49 a round) with a 91% deck- race rate, so the stalling that static opponents induced is gone. Duplicate match eval scores 0.4968 with a mean lead of exactly 0.0 -- same deals, same coins, both seats, deal luck cancelling exactly. Success criterion 1 does not pass. The carry probe is close to flat: expeditions opened sit at 5.00 whether the policy is 60 points down or 60 points up. Wager use does move monotonically across all six carry levels, and in the right direction (behind -> more multipliers), but the spread is 0.31 wagers. Two diagnoses, one of which was mine and wrong: - Residual potential shaping was NOT the cause. Annealing it fully to zero left the probe just as flat. - terminal_scale is. At carry -60, tanh((margin - 60)/50) is close to linear over any realistic round margin, and maximising E[tanh] on a linear stretch is just maximising E[margin] -- there is no reason to gamble. Risk-seeking only appears where tanh is sharply convex, which needs a smaller scale. Dropping 50 -> 12 widens the wager spread 0.19 -> 0.31, which is the mechanism showing up. The probe itself is also mis-scaled: at scale 12, tanh(60/12) is 1.0, so +/-60 is a saturated dead zone with no gradient and the policy has learned nothing there. The measurable band is |carry| <~ 2 * terminal_scale, and the probe levels have to be set from the scale rather than fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
25 KiB
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 안에 여유롭게 들어간다. 단 두 가지를 유의한다:
- 정적 상대로 3라운드를 학습시키면 매치가 350~500수까지 늘어날 수 있다 — 정적 상대 워밍업 단계를 쓴다면 스캔 길이를 따로 잡아야 한다.
- 라운드 상한(~120수)은 그 자체가 착취 가능한 구멍이다. 앞선 쪽이 상한까지 끌면
라운드가 유리한 상태로 얼어붙는다.
max_steps_rate를 게이트 지표로 계속 감시할 것 (셀프플레이에서는 0이어야 정상).
현재 코드의 결함 (Fable 2회 교차 검증 — 8건 전부 사실 확인)
- env가 1라운드짜리 —
State에 라운드/누적 점수 없음 (types.py:46),to_move=0하드코딩 (engine.py:63). - 롤아웃의 87%가 죽은 스텝 — 한 라운드는 실측 평균 50.6수(최대 56)인데
rollout_steps = MAX_STEPS = 400(ppo.py:66)이고, 스캔 안에는 리셋이 없다 (reset_done_envs는 업데이트 사이에서만,ppo.py:275). - GAE 부트스트랩 초기값이 0 (
ppo.py:691). 지금은 모든 에피소드가 스캔 안에서 끝나 무해하나, in-scan 리셋을 켜는 순간 편향이 된다. - shaping 어닐링 회계 버그 —
update * batch_games * rollout_steps(ppo.py:222)로 죽은 스텝까지 세어서, 5M 어닐링이 250 업데이트 중 2번째에 끝난다. league는 shaping을 아예 끈다 (league.py:592). → "shaping은 불필요하다"는 과거 결론이 있다면 무효다. 켜진 적이 없다. - obs에
to_move가 없다. 그런데 value loss는active마스크로 학습되어 (ppo.py:624) 상대 차례 상태에서도 가치를 맞추라고 요구한다. critic이 차례를 구분할 단서가step_count / 400뿐 — MLP에게 부동소수점에서 홀짝을 뽑으라는 요구다. - 점수차 정규화가 ÷780 (
MAX_ABS_SCORE,obs.py:68). ±50점 차가 ±0.06 → 사실상 안 보인다. - 셀프플레이에서 상대 자리 결정을 전부 버린다 (
stop_gradient,ppo.py:425) — 의사결정의 절반을 낭비. - 평가가 단판 승률 (
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_metrics는final_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 →— 철회한다. 라운드 상한은 400을 유지.120으로 분리**이 항목의 원래 명분이 사라졌다.** 근거는 "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: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.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, manifestobservation_size: 454), TS obs 빌더, TS 단판 엔진이 전부 비호환이 된다. 이 선언이 없으면 실행 중 스코프가 웹 재작성으로 샌다. - Deep CFR 복귀 — 이미 BC 천장을 쳤고, 3라운드는 트리만 키운다. PPO+league를 학습 백본으로 유지한다.
- 레거시 호환을 위한 타협.
성공 기준
-
(Phase 0a 게이트) 루프 수정 후 단판 체제에서 동일 learner-액션 예산으로 기존 anchor 성적 재현 ≥ 동등. 없으면 auto-reset 버그가 3라운드 결과에 섞여 원인 분리가 불가능해진다.
-
carry 프로브에서 행동이 단조롭게 변한다 — carry 6개 수준에 걸쳐 원정 개수/악수 비율의 단조 추세(CI 포함). ("행동이 변한다"는 노이즈로도 통과 가능하므로 단조성으로 정의.)
-
매치 승률이 구 정책(3라운드에 그대로 투입)을 이긴다 — 페어드 매치 ≥ 1만 쌍, 매치 승률 Wilson 하한 > 0.5 및 평균 총 마진 CI 하한 > 0.
-
착취자(exploiter) 승률이 악화되지 않는다.
-
(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에 붙어 있다. 굳은 습관이지 조건부 플레이가 아니다.
- 효과 크기가 작다.
확정된 진단
- shaping 가설은 틀렸다.
potential_shaping_final=0.0으로 완전히 꺼도 프로브는 평평했다. - 원인은
terminal_scale이다. carry = −60에서tanh((margin − 60)/50)의 인자는 현실적 마진 범위에서 거의 선형이고, 선형 구간에서E[tanh]최대화는E[margin]최대화와 같다 → 도박할 이유가 없다. 위험 추구는 tanh가 강하게 볼록한 구간에서만 나오며 scale을 낮춰야 그 구간에 들어간다. scale 50 → 12에서 스프레드가 0.19 → 0.31로 커진 것이 이를 확인한다. - 프로브 설계 결함: 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.