오목 엔진 Rapfi 해부 Part 3: Rapfi 는 어떻게 수를 고르는가
이 글은 Claude Opus 5.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
Part 2 에서 Rapfi 는 “평가를 싸게 만들고 깊게 읽는” 엔진이라고 했습니다. 이번 편은 그 말을 소스 코드로 풀어 봅니다. 인용하는 코드와 줄 번호는 모두 dhbloo/rapfi master 커밋 3c94c2a (2026-07-23, 버전 0.43.02) 기준이고, 경로는 저장소 안의 Rapfi/ 디렉터리 기준입니다.
0. 전체 흐름#
TURN 7,8 같은 명령을 받은 뒤 좌표를 답하기까지의 흐름입니다.
flowchart TD
A["명령 수신<br/>TURN / BOARD / BEGIN"] --> B{"탐색 없이<br/>답할 수 있나?"}
B -->|"빈 판, 즉시 오목,<br/>DB 에 기록된 필승수"| Z["좌표 출력"]
B -->|"아니오"| C{"탐색기 선택"}
C -->|"기본값"| D["알파-베타 (PVS)<br/>반복 심화"]
C -->|"INFO SEARCH_TYPE mcts"| M["MCTS (PUCT)"]
D --> E["노드마다: 즉승 판정 → TT →<br/>평가 → 가지치기 → 수 정렬"]
E -->|"깊이 0 도달"| F["VCF 잎 탐색<br/>(사만 이어 두기)"]
F -.->|"값 반환"| E
E --> G["스레드 투표로<br/>최선 결과 선택"]
M --> N["방문 수 + LCB 로<br/>최종 수 선택"]
N --> Z
G --> H{"STRENGTH < 100?"}
H -->|"예"| I["상위 후보 중<br/>무작위 선택"]
H -->|"아니오"| Z
I --> Z
style D fill:#FFD700,color:#000000
style F fill:#FF9999,color:#000000
style M fill:#87CEEB,color:#000000
아래에서 판을 읽는 법(1~2절), 평가(3절), 탐색(4~5절), 그 밖의 장치(6~7절) 순으로 봅니다.
1. 판을 읽는 법: 선 패턴#
1.1 판과 후보 수#
- 내부 판은 가장자리 5칸씩을 패딩으로 둔 32x32 배열입니다. 그래서 지원하는 최대 판 크기는 32 − 2×5 = 22 이고, 프로토콜의
START n은 5 이상 22 이하만 받습니다. 직사각형 판(RECTSTART)은 지원하지 않습니다 (core/pos.h:34-38,command/gomocup.cpp:456-479). - 모든 빈칸을 후보로 보지 않고 이미 놓인 돌 근처만 봅니다. 기본 설정은
square3_line4로, 각 돌을 중심으로 한 7x7 정사각형에, 여덟 방향 직선으로 4칸까지를 더한 범위를 후보로 둡니다 (core/pos.h:285-294, 설정 파일default_candidate_range).
1.2 한 방향의 모양: Pattern 16종#
오목에서 한 칸의 가치는 그 칸을 지나는 가로·세로·두 대각선 네 줄의 모양으로 거의 결정됩니다. Rapfi 는 먼저 한 줄의 모양을 16가지로 분류합니다 (core/types.h:81-99). 약한 것부터 강한 것 순입니다.
| 패턴 | 뜻 | 한국식 이름 |
|---|---|---|
DEAD | 다섯이 될 공간이 없음 | 죽은 줄 |
OL | 한 수 두면 장목 | (표준·렌쥬에서만 의미) |
B1 F1 | 막힌/열린 일 | |
B2 F2 F2A F2B | 막힌/열린 이. 열린 이는 간격 모양에 따라 셋으로 나눔 | 이 |
B3 B3S | 막힌 삼. S 는 한 줄 안에서 사를 두 번 이어 둘 수 있는 모양 | 닫힌 삼 |
F3 F3S | 열린 삼. S 는 열린 사로 가는 자리가 둘 | 활삼 |
B4 B4S | 막힌 사. S 는 막혀도 같은 줄에 사가 또 남는 모양 | 사 |
F4 | 열린 사 (다섯 자리가 둘) | 활사 |
F5 | 다섯 완성 | 오목 |
B 는 blocked(한쪽이 막힘), F 는 flex(양쪽이 열림)입니다. B3S·B4S 는 2026년 7월에 추가된 비교적 새 분류이고, 가중치 저장소도 같은 달 이 16종 체계로 변환되었습니다.
재미있는 것은 분류 방법 입니다. 사람이 규칙을 하나하나 적어 넣지 않고, “빈칸에 내 돌을 하나 더 놓아 보면 무엇이 되는가” 를 재귀적으로 따지는 동적 계획법으로 정의합니다 (game/pattern.cpp:224-251 의 주석).
- 실제로 이어진 돌이 6개 이상이면 장목(
OL)- 5개면 오목(
F5)- 다섯이 될 공간이 5칸 미만이면
DEAD- 그 밖에는 빈칸마다 돌을 하나 더 놓아 본 결과로 분류합니다.
- 오목이 되는 칸이 2곳 이상 → 열린 사(
F4)- 오목이 되는 칸이 1곳 → 사(
B4). 그 자리를 막아도 같은 줄에 삼 이상이 남으면B4S- 열린 사가 되는 칸이 2곳 이상 →
F3S, 1곳 →F3- … 이런 식으로
B1/DEAD까지 내려갑니다.
“사란 한 수 더 두면 오목이 되는 모양”, “삼이란 한 수 더 두면 열린 사가 되는 모양” 이라는 오목의 정의를 그대로 코드로 옮긴 셈입니다. 이 분류는 프로그램이 시작할 때 가능한 모든 줄 모양에 대해 한 번만 계산해 표로 만들어 둡니다. 자유룰은 가운데 칸 좌우 4칸씩, 표준·렌쥬는 장목 판정을 위해 좌우 5칸씩을 보므로 표 크기가 각각 14,641개와 132,496개입니다 (game/pattern.h:53-68). 실전에서는 표를 한 번 읽는 것으로 끝납니다.
1.3 네 방향의 조합: Pattern4 14종#
한 칸의 네 방향 패턴을 합쳐 다시 14종으로 요약합니다 (core/types.h:103-120).
Pattern4 | 뜻 | 의미 |
|---|---|---|
A_FIVE | 오목 완성 | 즉시 승리 |
B_FLEX4 | 열린 사, 또는 사 두 개(쌍사) | 막을 수 없음 |
C_BLOCK4_FLEX3 | 사 + 열린 삼 (사삼) | 대개 필승 |
D_BLOCK4_PLUS / E_BLOCK4 | 사 (+ 약한 모양) | 상대의 응수를 강제 |
F_FLEX3_2X | 열린 삼 두 개(삼삼) | 대개 필승 |
G_FLEX3_PLUS / H_FLEX3 | 열린 삼 (+ 약한 모양) | 위협 |
I ~ L | 삼·이 조합 | |
FORBID | 렌쥬 흑 금수 후보 | |
NONE | 특별할 것 없음 |
네 방향 패턴의 조합(순서 무관)은 C(16+3, 4) = 3,876가지뿐이라 이것도 미리 번호(pcode)를 매겨 둡니다 (eval/scoretables.h:40). 판의 각 빈칸은 흑·백 각각에 대해 “이 칸에 두면 무엇이 되는가” 를 Pattern4 로 들고 있고, 판 전체에 A_FIVE 가 몇 개, B_FLEX4 가 몇 개인지 개수 도 유지합니다.
1.4 증분 업데이트#
돌 하나를 두면 바뀌는 것은 그 돌을 지나는 네 줄뿐입니다. Board::move() 는 네 방향으로 앞뒤 4~5칸의 빈칸만 다시 표를 읽고, 패턴이 바뀐 칸에 대해서만 Pattern4 개수와 고전 평가 합계를 고칩니다. 변경 내역은 되돌리기(undo)용 기록에 쌓고, 신경망 평가기에도 “이 칸이 바뀌었다” 고 알립니다 (game/board.cpp:212-385).
이 구조 덕분에 판 전체를 다시 훑지 않아도 “상대에게 사가 있는가”, “나에게 열린 사가 있는가” 를 개수 하나 읽는 것 으로 알 수 있습니다. 이 점이 뒤에 나오는 즉승 판정과 VCF 탐색을 싸게 만듭니다.
2. 렌쥬 금수의 정확한 판정#
렌쥬 입문 Part 1 에서 본 것처럼 흑의 삼삼·사사·장목은 금수입니다. 까다로운 것은 삼삼 입니다. 렌쥬 규칙에서 “삼” 은 “열린 사를 만들 수 있는 모양” 인데, 그 열린 사를 만드는 자리가 다시 금수라면 그 삼은 삼으로 치지 않습니다. 정의가 재귀적입니다.
Rapfi 는 이를 두 단계로 처리합니다.
- 표 단계:
Pattern4를 계산할 때 흑에게 장목, 사+사, 삼+삼 조합이 보이면FORBID로 표시합니다 (game/pattern.cpp:410-418). 이것은 후보 일 뿐입니다. - 정밀 단계:
Board::checkForbiddenPoint()가 확정합니다 (game/board.cpp:474-554).- 어느 방향이든 장목이면 금수입니다.
- 사가 두 방향 이상이면 금수입니다.
- 남은 경우는 삼삼 후보입니다. 그 자리에 흑을 실제로 놓아 보고, 열린 삼인 각 방향에서 열린 사가 되는 칸을 찾습니다. 그 칸이 또 금수 후보라면
checkForbiddenPoint를 재귀 호출 해 진짜 금수인지 확인합니다. 진짜 삼이 두 개 이상일 때만 금수입니다.
한 줄 안에 사가 두 개 생기는 렌쥬 특유의 사사(예: OXXX_*_XXXO)는 패턴 단계에서 장목으로 분류해 금수로 만드는데, 코드 주석은 스스로 이것을 “a dirty fix” 라고 부릅니다 (game/pattern.cpp:285-299).
금수 판정은 수 생성, 수 정렬, VCF 방어, 즉승 판정, 데이터베이스 기록 곳곳에서 쓰이고, 프로토콜 명령 YXSHOWFORBID 로 직접 볼 수도 있습니다 (Part 4 에서 실행해 봅니다). 렌쥬에서 흑이 막아야 할 자리가 금수여서 못 막는 경우 도 VCF 방어 루틴이 따로 처리합니다 (search/ab/search.cpp:1835-1838).
3. 평가: 고전 평가와 NNUE#
3.1 고전 평가#
고전 평가는 세 가지 표로 이루어집니다 (eval/scoretables.h:79-81).
EVALS[rule][pcode]: 빈칸 하나의 네 방향 조합이 누구에게 얼마나 유리한지의 점수. 흑·백 합계를 증분으로 유지합니다.EVALS_THREAT[rule][mask]: “누가 오목 자리를 갖고 있는가, 열린 사가 있는가…” 를 11비트로 요약한 위협 상태별 점수. 합산으로는 표현하기 어려운 비선형 상황을 보정합니다.P4SCORES: 평가가 아니라 수 정렬용 점수 입니다. 신경망이 없을 때 어느 수부터 읽을지 정합니다.
이 표들은 설정 파일의 [model] 에 이진 파일(model210901.bin 등)로 주거나 TOML 표로 직접 적을 수 있고, tuning 명령으로 기보에서 학습시킬 수도 있습니다. 설정 파일이 없으면 신경망 없는 내장 설정으로 돌아갑니다 (internalConfig.cpp).
평가값은 승률의 로짓을 늘린 값이라 승률 = 1 / (1 + exp(−eval / ScalingFactor)) 로 바꿀 수 있습니다 (eval/scoretables.h:103-112). 기본 설정이 쓰는 model210901.bin 의 ScalingFactor 는 200 입니다. 예를 들어 평가값 +400 은 승률 약 88%, −320 은 약 17% 입니다.
3.2 NNUE: mix9svq#
현재 기본 신경망은 mix9svq 입니다 (가중치 저장소의 config-example/config.toml). 코드에는 mix10 과 ONNX Runtime 평가기도 있지만 공식 가중치는 mix9svq 만 배포됩니다. mix6~mix9 같은 옛 구조는 2024~2025년에 코드에서 지워졌습니다.
구조를 단계별로 풀면 다음과 같습니다 (eval/mix9svqnnue.cpp, eval/mixcommon.h).
flowchart TD
I["각 칸, 네 방향마다<br/>가운데 칸을 중심으로 한 11칸 줄 모양"] --> CB["모양 번호 → 코드북 조회<br/>(65,536개 항목 × 64채널, 정수)"]
CB --> S["네 방향 벡터 합산 + ReLU<br/>칸마다 64채널"]
S --> DW["3x3 depthwise 합성곱<br/>(32채널)"]
DW --> V1["판 전체 합산 +<br/>3x3 구역별 합산"]
V1 --> V2["구역 → 사분면 → MLP"]
V2 --> VO["가치: 승 / 패 / 무 확률"]
DW --> P1["전역 특징으로<br/>1x1 합성곱의 가중치를 생성"]
P1 --> PO["정책: 칸마다 점수"]
style CB fill:#FFD700,color:#000000
style VO fill:#90EE90,color:#000000
style PO fill:#87CEEB,color:#000000
- 입력: 각 칸에서 네 방향으로 11칸짜리 줄 모양을 봅니다. 빈칸·흑·백에 판 가장자리까지의 거리 정보를 더해 모양 하나를 번호로 바꿉니다. 가능한 모양은 442,503가지입니다 (
eval/mixcommon.h:46-48). 두는 쪽 관점으로 색을 뒤집으므로 흑용·백용 누산기를 따로 둡니다. - 코드북: 모양 번호를 64차원 정수 벡터로 바꿉니다. 모양마다 벡터를 따로 두지 않고 65,536개짜리 코드북 을 가리키게 해 크기를 줄였습니다. 가로·세로가 하나, 두 대각선이 하나의 매핑을 공유합니다. 이것이 논문의 “학습한 매핑 신경망을 패턴 색인 코드북으로 굽는다” 는 부분이고, 이름의 “svq” 는 이 벡터 양자화를 가리키는 것으로 보입니다(코드에 약어 풀이는 없습니다).
- 합성곱: 칸마다 네 방향 벡터를 더하고, 3x3 depthwise 합성곱으로 이웃 칸 정보를 섞습니다.
- 가치 머리: 판 전체와 3x3 구역별로 특징을 합산하고, 구역을 사분면으로 묶은 뒤 작은 MLP 로 승·패·무 확률 을 냅니다.
- 정책 머리: 전역 특징으로 1x1 합성곱의 가중치를 동적으로 생성 해 칸마다 적용합니다. 결과는 칸마다 “둘 만한 정도” 점수입니다.
가중치는 int8·int16 으로 양자화되어 있고, SIMD 백엔드(SSE, AVX2, AVX-512, VNNI, NEON, WASM SIMD)는 컴파일할 때 고릅니다 (eval/simd/isa.h).
증분 업데이트가 핵심입니다. 돌 하나가 바뀌면 영향을 받는 것은 네 방향 × 11칸 이내의 칸뿐이므로 그 칸들의 벡터만 다시 계산합니다. 되돌리기는 버전 번호 하나를 줄이는 O(1) 연산입니다. 게다가 변경을 지연 큐 에 쌓아 두었다가 평가가 실제로 필요할 때만 반영하므로, 두었다가 바로 무르는 수는 신경망을 전혀 건드리지 않습니다 (eval/mixcommon.h:134-226, :300-365).
한 가지 실무적인 제약이 있습니다. 가중치 파일 머리에는 지원하는 룰과 판 크기가 적혀 있는데, 배포 가중치 기준으로 자유룰은 13~22줄, 표준과 렌쥬는 15줄만 지원합니다. 그 밖의 판 크기에서는 조용히 고전 평가만으로 둡니다 (eval/evalconfig.cpp:55-103).
3.3 둘을 섞는 방식#
Rapfi 는 매 노드마다 신경망을 부르지 않습니다. 평가 함수 전체가 이렇습니다 (eval/eval.cpp:97-118).
Value basicEval = (evaluateBasic(st0, self) + evaluateBasic(st1, self)) / 2;
Value threatEval = evaluateThreat<R>(st0, self);
Value eval = std::clamp(basicEval + threatEval, VALUE_EVAL_MIN, VALUE_EVAL_MAX);
if (board.evaluator()) {
// Use evaluator eval if classical eval are in alpha-beta window margin
int margin = classicalEvalMargin(eval);
if (eval >= alpha - margin && eval <= beta + margin)
return computeEvaluatorValue(board).value();
}
return eval;
값싼 고전 평가를 먼저 계산하고, 그 값이 탐색 창(alpha, beta) 근처일 때만 신경망을 부릅니다. 고전 평가만으로 “이 국면은 확실히 좋다/나쁘다” 가 드러나면 비싼 계산을 건너뜁니다. 여유폭(margin)은 고전 평가가 승패 쪽으로 기울수록 좁아지게 설정되어 있습니다. 판단이 갈리는 국면에만 정밀한 평가를 쓰는 셈입니다.
4. 알파-베타 탐색#
4.1 뼈대: Stockfish#
기본 탐색기는 알파-베타이고 (search/searchengine.cpp:121), 구조는 체스 엔진 Stockfish 를 거의 그대로 따릅니다. 노드 함수가 “Step 1 … Step 21” 로 번호 매겨진 것까지 같습니다. AUTHORS 파일은 Stockfish 코드 일부를 채택했다고 밝히고, 개별 파일에도 “code from stockfish” 라는 표시가 있습니다 (search/skill.h:59-60).
- 반복 심화 + PVS: 깊이 1부터 한 단계씩 늘리며 탐색합니다. 첫 수만 전체 창으로 읽고 나머지는 좁은 창으로 확인만 하다가, 더 좋아 보이면 다시 읽습니다.
- Aspiration window: 깊이 5부터는 이전 반복의 값 주변 좁은 창으로 시작합니다.
- 분수 깊이: 깊이가 정수가 아니라 실수라서 “0.7수만큼 연장” 같은 미세 조정이 가능합니다 (
core/types.h:30). - 치환표(TT): 64바이트 캐시 라인 하나에 항목 5개를 넣고, 잠금 없이 XOR 트릭으로 여러 스레드가 공유합니다 (
search/hashtable.cpp:38-81).
4.2 노드 하나에서 일어나는 일#
노드마다 거치는 단계를 순서대로 정리했습니다. 괄호 안의 Elo 는 코드 주석에 적힌 각 기법의 기여도 추정치 입니다. 전부 search/ab/search.cpp 입니다.
| 단계 | 하는 일 | 코드 주석의 기여도 |
|---|---|---|
| 깊이 ≤ 0 | VCF 잎 탐색으로 전환 (4.3절) | ~17 Elo |
| 즉승 판정 | Pattern4 개수만 보고 필승·필패를 즉시 판정 | |
| TT 조회 | 같은 국면을 이미 충분히 읽었으면 그 값을 씀 | |
| DB 조회 | 기보 데이터베이스에 기록된 국면이면 그 값을 씀 | |
| Razoring | 평가가 alpha 보다 한참 낮으면 VCF 만 확인하고 끝냄 | ~68 Elo |
| Futility pruning | 평가가 beta 보다 한참 높으면 더 읽지 않음 | ~121 Elo |
| Null move pruning | 한 수 쉬어도(PASS) 여전히 좋은지 얕게 확인 | ~3 Elo |
| 수 개수 가지치기 | 얕은 깊이에서 뒤쪽 후보는 아예 건너뜀 | ~107 Elo |
| 정책 가지치기 | 신경망 정책 점수가 낮은 수를 건너뜀 | ~10 Elo |
| 상대 사에 대한 응수 연장 | 상대가 사를 두면 막는 수를 더 깊게 읽음 | ~77 Elo |
| Singular extension | 최선수 하나만 유독 좋으면 그 수를 더 깊게 | ~52 Elo |
| LMR (늦은 수 감축) | 정렬 뒤쪽 수는 얕게 읽음. 정책 점수로 감축량 조절 | 정책 기반 ~59 Elo |
체스와 다른 점이 몇 가지 눈에 띕니다.
- Null move 는 PASS 수로 구현 됩니다. 오목에는 “한 수 쉬기” 가 없지만 탐색 기법으로는 그대로 씁니다. 연속 PASS 는 막고, 상대에게 오목 자리나 열린 사가 있으면 쓰지 않습니다.
- 체스의 잎 탐색은 “잡는 수만 계속 읽기(quiescence search)” 인데, Rapfi 에서는 이것이 “사만 계속 읽기(VCF)” 로 바뀌었습니다.
- LMR 을 조절하는 요인에 오목 특유의 것이 섞여 있습니다. “쓸모없는 방어 수” 는 더 줄이고, “연속 공격” 과 “렌쥬의 가짜 금수” 는 덜 줄입니다 (
search/ab/search.cpp:850-926).
4.3 즉승 판정과 VCF 잎 탐색#
즉승 판정 (game/wincheck.h:34-111) 은 탐색 없이 Pattern4 개수만으로 결론을 냅니다.
// We complete a five with this move.
if (board.p4Count(self, A_FIVE))
return mate_in(ply + 1);
// Opponent threatens a five next move: we can parry a single one, but two or more is a loss.
switch (board.p4Count(oppo, A_FIVE)) {
case 0: break;
case 1: return VALUE_ZERO;
default: return mated_in(ply + 2);
}
// A flex four is an unstoppable double threat: we win in two of our moves.
if (board.p4Count(self, B_FLEX4))
return mate_in(ply + 3);
- 내가 오목을 둘 수 있으면 승리.
- 상대의 오목 자리가 둘 이상이면 패배 (하나는 막을 수 있지만 둘은 못 막음).
- 내가 열린 사를 만들 수 있으면 3수 뒤 승리.
- 이어서 사삼(
C_BLOCK4_FLEX3), 삼삼(F_FLEX3_2X) 같은 더 복잡한 필승 유형을 상대의 반격 수단(사)이 있는지와 함께 따집니다. 렌쥬에서는 흑의 해당 수가 금수일 수 있어 조건이 더 까다롭습니다.
VCF 잎 탐색 (search/ab/search.cpp:1600-1864) 은 주 탐색이 깊이 0에 닿았을 때 넘어가는 곳입니다.
- 공격 측은 사를 만드는 수만 둡니다. 그중에서도 직전 공격 수 근처(5x5 정사각형 + 여덟 방향 직선 4칸)로 범위를 좁힙니다.
- 방어 측은 사를 막는 단 한 곳 만 둡니다. 렌쥬에서 흑이 막아야 할 자리가 금수면 그대로 패배입니다.
- 공격 측은 언제든 “더 두지 않고 현재 평가로 만족” 할 수 있습니다(stand pat).
사에 대한 응수는 하나뿐이므로 이 탐색은 분기 수가 거의 1입니다. 그래서 수십 수짜리 연속 사 수순도 싸게 끝까지 읽습니다. 이 잎 탐색이 Part 2 에서 본 “강제 수순을 정확히 읽는다” 의 실체입니다.
한편 삼으로 몰아붙이는 VCT(연속 위협 승리)를 전담하는 솔버는 현재 코드에 없습니다. 프로토콜의 INFO VCTHREAD 는 받아서 무시합니다 (command/gomocup.cpp:307-312). 삼을 이용한 수순은 주 탐색이 수 정렬, 연장, 즉승 판정의 삼삼·사삼 규칙으로 처리합니다.
4.4 수 정렬#
알파-베타는 좋은 수를 먼저 읽을수록 가지치기가 잘 됩니다. Rapfi 의 수 정렬기(search/movepick.cpp)는 이렇게 동작합니다.
- 치환표에 기록된 최선수를 가장 먼저 둡니다.
- 상대에게 위협이 있으면 방어 수만 생성합니다. 상대가 오목 자리를 가졌으면 그 자리 하나, 열린 사가 있으면 사를 막는 수와 내 VCF 수, 사삼이 있으면 그에 대한 방어 수와 VCF 수입니다.
- 나머지 수는 신경망 정책 점수 로 정렬합니다. 신경망이 없으면
P4SCORES고전 점수에 히스토리 점수를 더해 씁니다.
5. MCTS#
INFO SEARCH_TYPE mcts 로 바꿀 수 있는 두 번째 탐색기입니다 (search/mcts/). 2024년 9월에 추가되었고, 신경망이 없으면 동작하지 않습니다.
- PUCT 선택: 자식마다
Q + U를 계산해 가장 큰 쪽으로 내려갑니다 (search/mcts/search.cpp:262-283).
float U = cpuctExploration * childPolicy / (1 + childVisits);
float Q = childUtility;
// Reduce utility value for drawish child nodes for PUCT selection
if (SearchCfg.drawUtilityPenalty != 0)
Q -= SearchCfg.drawUtilityPenalty * childDraw * (1 - parentDraw);
// Account for virtual losses
if (childVirtualVisits > 0)
Q = (Q * childVisits - childVirtualVisits) / (childVisits + childVirtualVisits);
return Q + U;
- 탐색 계수는 방문 수에 따라 로그로 커집니다:
cpuct = (0.35 + 1.02·ln(1 + N/328)) · √N(:239-245). - 무승부 벌점: 무승부 확률이 높은 수의 가치를 깎아 결판이 나는 수를 더 탐색하게 합니다.
- 가상 패배(virtual loss): 여러 스레드가 같은 가지로 몰리지 않게 합니다.
- 트리가 아니라 그래프 입니다. 같은 국면은 해시로 찾아 노드 하나를 공유합니다(MCGS). 오목은 수순만 다르고 같은 국면에 이르는 경우가 많아서 효과가 큽니다.
- 새 잎의 평가 순서: 무승부 확인 → 즉승 판정 → 작은 VCF 탐색 → 그래도 결론이 없을 때만 신경망. MCTS 에서도 강제 수순은 따로 확정합니다.
- 최종 선택: 방문 수에 신뢰 하한(LCB) 보정을 더해 고르고, 증명된 필승이 있으면 가장 짧은 필승을 우선합니다.
코드에는 정직한 흔적도 있습니다. 정책 온도(temperature) 설정값이 있지만 현재는 적용되지 않는다는 BUG: 주석이 달려 있습니다 (search/movepick.cpp:92-94).
기본값이 알파-베타인 이유는 Part 2 에서 본 논문 결과 그대로입니다. Mixnet 과 결합했을 때 알파-베타가 더 강했습니다. MCTS 는 승률·무승부율·방문 수를 함께 보여 주므로 분석 도구 로서의 쓰임새가 있습니다 (Part 4 에서 출력을 봅니다).
6. 시간 관리와 멀티스레드#
시간 관리 (search/timecontrol.cpp:60-113) 도 Stockfish 방식입니다.
- 최대 시간 = min(한 수 제한, 남은 대국 시간 ÷ 예상 남은 수) − 30ms 여유
- 최적 시간 = 최대 시간 × 0.9 (설정
advanced_stop_ratio) - 대국 시간이 넉넉하지 않으면 수순의 중요도 곡선과 분기 계수로 최적 시간을 더 줄입니다.
- 반복 심화가 한 단계 끝날 때마다 평가가 떨어지고 있는지, 최선수가 자주 바뀌는지 를 보고 멈출 시점을 늘리거나 줄입니다. 형세가 흔들리는 수에서 더 오래 생각하는 장치입니다.
멀티스레드 는 Lazy SMP 입니다. 모든 스레드가 같은 반복 심화를 각자 돌리며 치환표를 공유합니다.
- 스레드가 3개 이상이면, 절반 넘는 스레드가 이미 읽고 있는 깊이는 건너뛰어 서로 다른 깊이를 맡습니다 (
search/ab/search.cpp:349-370). - 끝나면 각 스레드의 결과를 평가값과 완료 깊이로 투표 해 최종 수를 고릅니다 (
:372-415). - 스레드가 8개를 넘으면 NUMA 노드에 묶습니다.
메모리는 INFO MAX_MEMORY (바이트) 로 제한하고, 0 이면 Gomocup 기본값인 350MB 를 씁니다. 알파-베타에서는 치환표 크기, MCTS 에서는 그래프와 VCF 치환표 크기를 이 한도 안에서 정합니다.
7. 오프닝과 데이터베이스#
오프닝 책은 따로 없습니다. 탐색 없이 두는 첫 수는 코드에 박혀 있습니다 (search/opening.cpp:48-100).
- 자유 오프닝: 빈 판이면 천원(가운데).
- SWAP1: 판 가장자리 근처 몇 자리 중 무작위 (13줄 이상).
- SWAP2: 15줄 판에서 미리 정한 3돌 오프닝 9가지 중 무작위.
- 스왑 여부는 탐색한 평가값이 0 미만이면 바꾸는 식으로 정합니다.
데이터베이스 는 Yixin 과 호환되는 국면 DB 입니다 (database/). 켜 두면 탐색 중 얕은 깊이의 국면을 DB 에서 찾아 쓰고, 충분히 깊게 읽은 결과를 다시 DB 에 기록합니다. 루트 국면에 DB 가 기록한 필승수가 있으면 탐색 없이 바로 둡니다. 렌쥬 기보 형식인 Renlib(.lib)을 가져오고 내보낼 수도 있는데, 이 형식이 좌표를 4비트로 저장하므로 15줄 판 전용입니다.
균형 수 탐색 도 있습니다. YXBALANCEONE bias 는 “평가값이 bias 에 가장 가까워지는 수” 를, YXBALANCETWO 는 그런 두 수의 쌍 을 찾습니다. 알파-베타의 값을 bias − |v − bias| 로 바꿔 끼워 “너무 좋은 수” 도 나쁜 수처럼 취급하는 방식입니다 (search/searchcommon.h:37-40). 스왑2 에서 상대가 어느 색을 골라도 비슷한 국면을 제시할 때 쓰고, opengen 명령은 이것으로 균형 오프닝 집합을 대량 생성합니다.
8. 정리#
Rapfi 의 의사결정을 한 문장으로 줄이면 “오목의 정의를 표로 굽고, 신경망도 표로 굽고, 그 싼 평가로 Stockfish 식 탐색을 깊게 돌리되, 강제 수순은 VCF 로 끝까지 확인한다” 입니다.
- 판은 선 패턴 16종 → 네 방향 조합 14종 으로 읽고, 모든 분류는 미리 계산한 표 조회 한 번입니다.
- 렌쥬 금수는 표로 후보를 고르고 재귀 판정 으로 확정합니다.
- 평가는 고전 평가를 먼저, 판단이 갈릴 때만 NNUE 를 부릅니다. NNUE 도 코드북 조회 + 증분 업데이트라 매우 쌉니다.
- 탐색은 Stockfish 의 알파-베타 기법을 거의 다 가져왔고, 잎 탐색을 VCF 로 바꾸고 null move 를 PASS 로 바꾼 것이 오목식 개조입니다.
- MCTS 는 선택 사항이며, 여기서도 새 잎마다 VCF 를 먼저 확인합니다.
다음 편에서는 직접 빌드해서 이 동작들을 명령 단위로 확인합니다.
References#
1차 출처 (2025~2026)#
- Rapfi 소스 (master
3c94c2a, 2026-07-23): https://github.com/dhbloo/rapfi/tree/3c94c2a976f24a0dd1c5517623e9ab6fffe66bd7/Rapfi- 패턴:
core/types.h,game/pattern.cpp,game/pattern.h - 판과 금수:
game/board.cpp,game/wincheck.h - 평가:
eval/eval.cpp,eval/mix9svqnnue.cpp,eval/mixcommon.h,eval/evalconfig.cpp - 알파-베타:
search/ab/search.cpp,search/ab/parameter.h,search/movepick.cpp,search/hashtable.cpp - MCTS:
search/mcts/search.cpp,search/mcts/parameter.h - 시간·오프닝:
search/timecontrol.cpp,search/opening.cpp
- 패턴:
- rapfi-networks (mix9svq 가중치, 예제 설정): https://github.com/dhbloo/rapfi-networks
- Jin, Duan, Hang, “Rapfi: Distilling Efficient Neural Network for the Game of Gomoku”, arXiv:2503.13178 (2025), §3, Appendix A: https://arxiv.org/abs/2503.13178
배경 자료#
- Stockfish (탐색 구조의 원형): https://github.com/official-stockfish/Stockfish
- RIF 렌쥬 국제 규칙 (1996년 제정, 1998년 수정): https://www.renju.net/rifrules/