diff --git a/docs/plans/lost-cities-classic-3round.md b/docs/plans/lost-cities-classic-3round.md index 1881219..5f5cbe4 100644 --- a/docs/plans/lost-cities-classic-3round.md +++ b/docs/plans/lost-cities-classic-3round.md @@ -172,20 +172,47 @@ scale을 낮춰서(예: 50 → 25) 제거한다. ### Phase 1 — 3라운드 env -- [ ] `State`에 `carry`(누적 점수차), `round_idx`, **라운드/매치 스텝 카운터**, - **pre-sampled 덱 3세트 + 코인플립 비트 + 1라운드 선공 비트** 추가. -- [ ] `step()`: 덱 소진 시 라운드 < 3이면 carry 갱신 → 다음 덱 슬라이스로 전환 → - `round_idx += 1`, `done` 유지. 라운드 == 3이면 `done = True`. -- [ ] **선공 규칙**: 1라운드 pre-sampled 비트. 2·3라운드는 `carry > 0` → 나, `< 0` → 상대, - `== 0` → **pre-sampled 코인플립 비트**. -- [ ] 라운드당 스텝 상한을 400 → **~120**으로 분리. **전역 `MAX_STEPS` 상수를 직접 수정하지 - 말 것** — engine forced-done(`engine.py:224`), obs 정규화(`obs.py:75`), 모든 eval 스캔 - 길이(`ppo.py:773`, `ppo.py:1049`, `gates.py`)에 물려 있다. 매치 레벨 스캔 길이를 따로 둔다. -- [ ] **league 풀 재구축**: 기존 스냅샷 멤버는 obs 변경으로 전부 무효화된다 — Phase 1 산출물로 명시. -- [ ] **테스트** (엔진 재작성에 테스트 없이 들어가면 3라운드 버그를 학습 곡선으로만 발견하게 된다): - 라운드당 총 턴 수 = 44 + 버림패 드로우 (property), `carry` = 라운드 점수 합 불변식, - 선공 규칙 3분기(`>`, `<`, `==`) 각각, 코인플립 통계(≈50/50), PBRS Φ가 라운드 경계에서 - 연속(보드차 → carry 흡수), 미러 매치 대칭성. +**구현: `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라운드 학습 **전에** 착지)