작은 루프를 끝까지 닫는 것 — 흑백게임 AI 파일럿
Chess AI 스택은 왜 모델을 못 만들었는가
chess-ai 스택에는 94개 소스 파일, 104개 테스트, 24M 파라미터 Transformer, 서비스·워커·대시보드·k8s 매니페스트까지 갖춰져 있었습니다. RAM 한계에 부딪혔던 글에서도 언급했지만, training/chess-ai/weights/에는 .gitkeep 하나뿐이었고, 신경망 추론 테스트는 it.if(modelExists)로 조건부로 건너뛰었으며, 프로모션 로직은 한 번도 실행된 적이 없었습니다. 문제는 모델 품질이나 인프라 부족이 아니었습니다 — 루프가 한 번도 닫힌 적이 없었습니다.
결론은 하나였습니다. 더 큰 체스를 위한 개선 이전에, 가장 작은 게임으로 루프를 한 번 끝까지 닫는다.
왜 흑백게임인가
후보 게임 9개를 검토했습니다. 흑백게임(Black and White)이 모든 “루프를 빠르게 닫는” 기준에서 이겼습니다.
- 2인 제로섬, 순차 행동 — AlphaZero 교과서 구조와 1:1 매핑됩니다.
- 행동 공간 9 (타일 1~9 중 아직 안 쓴 것) — Augmented Chess의 4,708개와 비교할 수 없는 디버깅 속도입니다.
- 게임 당 최대 18수, 100판 생성에 7ms — 이터레이션 시간이 데이터 생성이 아닌 학습에 지배됩니다.
- 숨겨진 정보(상대의 타일 번호)가 존재하지만, 엔진이 이미
ViewerState프로젝션을 제공합니다. - 종료 가치가 자명합니다: 승 +1, 무 0, 패 -1.
Fruit Shop은 Pilot B로 남겨두었고, YINSH는 루프가 닫히고 난 뒤 MCTS 첫 타깃으로 지정했습니다.
슬라이스 0~4: 루프의 골격
슬라이스 6개로 나누어 진행했습니다 (서비스·MongoDB·k8s는 슬라이스 6+, 지금은 제외).
Slice 0 — packages/black-and-white-ai-core: GameAIAdapter (init/actor/terminal/legal/apply), seeded RandomEngine (mulberry32), playGame + JSONL 직렬화기. 100판이 7ms에 완료되고 seed 고정 재현 테스트 포함, 20개 테스트 통과.
Slice 1 — 41-float 상태 인코더: 내 남은/사용 타일(9+9), 완료된 라운드별 상대 제출 색(9, ±1), 라운드 결과(9, ±1), 스코어 차이/목표점(1), 라운드/최대라운드(1), 내가 두 번째인지(1), 대기 중인 상대의 첫 제출 색 one-hot(2). 숨겨진 정보 보장 테스트를 속성 테스트로 검증했습니다 — 타일 번호가 달라도 색·결과가 같으면 인코딩 동일.
Slice 2 — Python 미러(encoding.py), MLP 41→64→ReLU→64→ReLU→policy(9)+value(1, tanh), ONNX opset 17 단일 파일 export. Python 픽스처 패리티 테스트 4개 green, Bun 쪽 ONNX 추론 래퍼 + 마스킹 소프트맥스 구현.
Slice 3 — 아레나 스크립트. 검증 중 첫 번째 공정성 버그를 잡았습니다: 좌석 교대 로직(index % 2)이 시드 홀짝수 선-플레이어 배정(seed = baseSeed + index)과 완벽하게 상관되어, challenger가 200판 전부 두 번째 플레이어였습니다. 수정 전 0.560이었던 승률이 수정 후 0.502로 내려갔습니다. 수정된 값이 정직한 값입니다.
Slice 4 — scripts/iterate.ts: 자가 대전 → Python 학습 → ONNX → 이중 아레나 → 프로모션 게이트 → 메트릭 한 줄. registry.json이 현재 챔피언을 추적합니다. 검증 중 두 번째 설계 결함을 잡았습니다: greedy-vs-greedy 아레나가 이 게임에서 플레이어당 하나씩, 두 가지 결정론적 게임으로 붕괴합니다 — 승률이 0 또는 1 뿐이었고 0.55 게이트는 무의미했습니다. --eval-temperature 0.25로 양쪽 모두 샘플링하도록 수정했습니다.
Slice 5: 52세대 자동화 세션
자동화 드라이버에 iterate 커맨드를 넘겨 48번 무인 이터레이션을 진행했습니다 (gen 005~052, 세대당 약 3~12초). 각 배치는 변수 하나씩만 바꿨습니다.
배치 1 — 부호 있는 REINFORCE 발산. 결과 ±1로 가중된 크로스 엔트로피(REINFORCE)를 적용했습니다. scripts/probe.ts로 정책을 들여다봤더니 모든 상태에서 타일 8에 100% 확률이 몰려 있었습니다. 원인: ±1 가중치는 아래로 무한히 발산합니다 — 패배한 행동의 확률을 0으로 밀어넣는 항이 음의 방향으로 무한히 커지며 trainPolicyLoss가 -1412까지 발산했습니다. 해결: 비음수 가중치(승리 조건부 모방)로 교체.
게임 레벨 승리 조건부 모방 → 여전히 균일. 9라운드에 걸친 크레딧이 너무 희석되었습니다. 라운드 ∧ 게임 승리 조건부(round-outcome)로 전환, 데이터셋에서 라운드별 승자를 재구성했습니다. probe.ts가 처음으로 실제 구조를 보였습니다: BLACK→9 (76%) 그리고 “1이 9를 저격하는” 희생 플레이(WHITE→1). 라운드와 게임 둘 다 이겼을 때만 학습하니 진짜 신호가 나온 것입니다.
자가 대전 사이클링. 프로모션이 발화했지만 vsRandom 승률은 제자리였습니다 — 챌린저가 전임자를 착취하는 법(vsChampion 0.7+)을 배운 것이지, 실제로 더 강해진 게 아니었습니다. 자가 대전(self-play란: 모델이 자기 자신과 대전하며 데이터를 생성하는 것)의 대표적인 함정입니다. 해결: --mix-random으로 랜덤 게임을 혼합하고, 레지스트리에 vsRandom 앵커 게이트를 추가했습니다. 앵커는 프로모션 시 챔피언의 vsRandom 수치를 기록하고, 후임 모델이 그 수치를 초과하지 못하면 프로모션을 거부합니다. 초기 0.02 허용치가 앵커를 0.563→0.535로 끌어내리는 현상이 발견되어 제거했습니다.
8세대 프로모션 없음 → 알람 조건. 1-스텝 모방의 고정점을 진단했습니다: 챔피언의 행동을 모방하는 것만으로는 챔피언보다 나아질 수 없습니다. 해결: ValueGreedyEngine 구현 — 1-ply 루크어헤드 전문가(9개 합법 행동 각각에 applyAction → encodeState(next, me) → value를 실행해 가치가 가장 높은 행동 선택)를 학습 교사로 사용했습니다. 전문가가 생성한 포지션에서 학습하는 이 방식을 “전문가 이터레이션”이라 부릅니다.
최종 결과 — 챔피언: gen-037. 정책 헤드 단독 승률: greedy 0.543 / T=0.25 샘플링 0.583 (1000/400판). 배포 가능한 엔진 ValueGreedy(gen-037): vsRandom 0.923, vsMaxTile 1.000 (각 1000판). runs/metrics.jsonl에 52줄의 메트릭 흔적과, 앵커 0.5825로 gen-037이 기록된 레지스트리.
증류 격차: 루프 너머
이번 파일럿에서 가장 중요한 발견 하나를 꼽자면 **증류 격차(distillation gap)**입니다.
ValueGreedy 엔진(1-ply 탐색 포함)이 vsRandom 0.923을 기록하는 동안, 같은 학습 데이터로 훈련된 정책 헤드 단독은 0.55~0.58에서 정체했습니다. 24에포크로 늘려도 마찬가지였습니다. 정책 softmax가 루크어헤드를 흡수하지 못하는 것입니다. AlphaZero 프레임으로 보면 당연한 결론입니다 — 배포 가능한 엔진은 탐색을 포함한 네트워크이지, 정책 헤드 단독이 아닙니다.
다음 개선 방향:
- 소프트 타깃 / 더 큰 정책 헤드. 전문가 행동 인덱스 대신 ValueGreedy의 방문 분포를 소프트 타깃으로 증류하면 격차가 줄 수 있습니다. 또는 64→64 MLP 정책 헤드를 확장하는 것.
- 2-ply 또는 MCTS-lite 전문가. 현재 1-ply 전문가는 단일 수준의 탐색입니다. 2-ply 또는 소규모 트리 탐색으로 교사 품질을 높이면 학습 신호가 개선됩니다.
- 웜 스타트 학습. 현재는 세대마다 처음부터 학습합니다. 이전 챔피언의 가중치에서 시작(웜 스타트)하면 학습 속도를 높이고 망각을 줄일 수 있습니다.
- Pilot B: Fruit Shop. 동일한 슬라이스 구조를 3인 멀티플레이어 + 숨겨진 정보 게임에 적용합니다. 2인 제로섬을 넘어서는 일반화 테스트입니다.
- 체스 비교 레인. augmentation-disabled
augmented-chess-engine과 Stockfish 앵커를 사용해, 이 루프가 기존에 발표된 결과와 얼마나 일치하는지 보정합니다. 루프 자체를 검증하는 레인입니다. - board-game-client 연결. 학습된 AI를 실제 솔로 플레이에 사용하는 것이 최종 목표입니다.
TinyNeuralEngine+ValueGreedyEngine을board-game-client의 컴퓨터 플레이어로 연결하는 작업이 남아 있습니다.
작은 루프를 닫는 것이 먼저입니다
Chess AI 스택은 94개 파일과 104개 테스트를 가졌지만 단 한 번도 학습된 모델을 만들지 못했습니다. 이번 파일럿은 다른 방향으로 시작했습니다 — 가장 작은 게임, 파일 기반 레지스트리, 서비스도 MongoDB도 k8s도 없이. 루프는 하루 만에 닫혔습니다.
52세대 자동화 세션이 알려준 것은 코드보다 더 귀한 것입니다: 부호 있는 REINFORCE의 발산, greedy-vs-greedy 아레나의 이진성, 자가 대전 사이클링, 1-스텝 모방의 고정점, 증류 격차 — 이 결함들은 대형 모델에서 발견했다면 디버깅 비용이 몇 배 들었을 것입니다. 루프를 작은 게임으로 먼저 닫은 덕분에 6개 이상의 설계 결함을 자체 메트릭 흔적으로 진단하고 수정했습니다.
다음 단계는 이 AI를 실제 게임에 넣는 것입니다. 학습된 모델이 board-game-client의 방에서 사람과 마주 앉는 순간, 이 루프는 비로소 제품이 됩니다.
정정 — 0.923은 반칙이 포함된 수치였습니다 (2026-06-11)
발행 다음 날, 배포 계획 단계에서 이 글의 ValueGreedy 0.923이 오라클 치팅을 포함한 수치로 판명되었습니다. 1-ply 루크어헤드의 applyAction이 후공 차례에 상대의 숨겨진 타일로 라운드를 해소했던 것입니다. 인코더의 숨김정보 규칙과 속성 테스트는 끝까지 지켜졌지만, 누수는 인코더가 아니라 게임 동역학 경로로 들어왔습니다 — probe도, 아레나도 잡지 못했고, “실제 방에서 이 엔진이 받을 입력은 무엇인가”라는 배포 질문이 잡았습니다.
정직한 수치(1000판, vs random): 정책 헤드 0.596, 공정한 belief expectimax 0.592. 격차 약 0.33이 곧 치팅의 크기입니다. 이 사고로 엔진 계약에 정보집합 선언(fair/oracle), 정보집합 불변성 테스트(같은 가시 정보 + 다른 숨김 타일 → 동일 행동), 공정 expectimax 엔진, 그리고 가이드의 Hidden-Information Fairness Checklist가 추가되었습니다. 위 “증류 격차” 절의 교훈은 “넷+탐색을 배포하라”가 아니라 **“교사의 정보집합부터 검증하라”**로 읽혀야 합니다. 상세 분석: docs/task-log/20260610-bw-solo-ai/02-oracle-leak-rootcause.md.
