이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


Part 5 끝에서 “다섯 가운데 무엇이 실제로 차이를 내는지는 아직 모른다” 고 적었습니다. 이번 편은 그것을 재는 이야기입니다. 재는 장치를 먼저 만들고, 장치가 맞다는 것을 확인하고, hard 의 항목을 하나씩 켜고 꺼 보고, 남은 것의 가중치를 고릅니다.

순서가 중요합니다. Part 1 에서 적었듯 시뮬레이터는 hard 보다 먼저 만들었습니다. 이 편의 1~3절은 Part 3 의 장부보다 먼저 있었던 일입니다.


1. 재는 장치 — 같은 경로, 다른 속도#

1.1 운영과 같은 결정 경로#

훌라 시리즈의 가장 뼈아픈 사고는 시뮬레이터가 다른 봇을 재고 있었다 는 것이었습니다. 정책 함수는 같았지만 시야를 만드는 코드가 달라 빈 장부가 넘어갔습니다. easy 대 easy 는 정상이었으므로 아무도 눈치채지 못했습니다.

그래서 이번에는 운영 서버의 봇 일감이 하는 “시야 만들기 → 정책 호출 → 커맨드 만들기” 를 함수 하나(DecideCommand)로 꺼내고, 운영과 시뮬레이터가 같은 함수 를 부르게 했습니다. 판 사이의 전이(다음 판 열기, 계급과 조공1, 네 판마다 자리 섞기, 순위와 점수)도 저장소 안에서 SQL 과 섞여 있던 것을 순수 함수로 꺼내 둘이 함께 씁니다. 규칙은 한 벌입니다.

그리고 그것을 테스트가 지킵니다. 같은 seed2·같은 정책이면 세 경로가 수마다 같은 기록과 같은 결과 를 내야 합니다.

경로무엇인가
메모리 엔진시뮬레이터가 실제로 도는 곳
운영 저장소 드라이버운영 DB 코드로 같은 매치를 돌린 것
운영 서비스 경로봇 일감·사람 커맨드·“다음 판” 버튼까지 실제 서버 코드

4·5·6인 × seed 셋 × 8판에서 셋이 수마다 일치했고, hard 를 만든 뒤에는 결정마다 봇에게 넘어가는 시야가 같은지 까지 대조합니다. 훌라의 사고는 바로 그 자리에서 났으니까요.

1.2 6ms 에서 30µs 로#

처음에는 운영 저장소 코드를 메모리 SQLite 위에서 그대로 돌렸습니다. 수 하나에 약 6ms, 초당 2~3판이었습니다. 일꾼3을 늘려도 경합 때문에 18판/s 근처에서 멈췄습니다. 이 속도로는 최종 시험 한 번이 1시간을 넘고, 가중치 튜닝은 수십 시간입니다.

그래서 판 사이 전이를 순수 함수로 꺼내는 리팩토링(동작 불변)을 먼저 하고, 그 함수들로 메모리 엔진을 만들었습니다. 수 하나가 20~34µs4, 일꾼 하나로 645판/s, 넷으로 1,450판/s 입니다. 최종 시험 한 번(92,160판)이 easy 기준 약 1분입니다. hard 를 올리고 결정마다 이력을 읽게 한 뒤에도 1.5분 안입니다.

1.3 무작위는 seed 에서만#

판마다의 덱, 첫 자리 배치, 네 판마다의 자리 순열은 모두 seed 에서 나옵니다. 정책이 무엇을 하든 다음 판의 덱은 바뀌지 않습니다. 그래야 “같은 딜에서 easy 와 hard 를 바꿔 앉혀” 비교할 수 있습니다. 봇 자체에 난수가 없다는 것(Part 5)과 합쳐, 같은 seed 는 언제 돌려도 같은 결과입니다. 실제로 일꾼 1과 4로 돌린 보고서가 바이트 단위로 같았고, 중간에 끊고 재개한 것도 같았습니다.

판마다 불변식5 도 봅니다. 54장이 보존되는가, 판 점수의 합이 0 인가, 순위가 1~N 의 순열인가, 수가 상한을 넘지 않았는가. 하나라도 어기면 그 실행 전체를 판정 불가 로 둡니다. 그 판만 빼고 성적을 내지 않습니다.


2. 무엇을 재는가 — 양방향 비교#

봇의 성적은 판당 평균 순위 로 잽니다. 낮을수록 좋습니다. 순위는 반칙패6와 나락7을 포함한 실제 엔진의 순위입니다. 4인 판에서 공정한 봇의 평균 순위는 2.5 입니다.

후보 봇 하나를 기준 봇 셋 사이에 앉혀 평균 순위를 재면 될 것 같지만, 그것만으로는 안 된다는 것을 처음 쓴 약한 봇 이 보여 줬습니다.

flowchart TB
    subgraph A["방향 A — 후보 1 + 기준 3"]
        direction LR
        a1["후보"]:::cand
        a2["기준"]:::base
        a3["기준"]:::base
        a4["기준"]:::base
        a1 ~~~ a2 ~~~ a3 ~~~ a4
    end
    subgraph B["방향 B — 기준 1 + 후보 3"]
        direction LR
        b1["기준"]:::base
        b2["후보"]:::cand
        b3["후보"]:::cand
        b4["후보"]:::cand
        b1 ~~~ b2 ~~~ b3 ~~~ b4
    end
    A --> R["Δ = (Δ_A + Δ_B) / 2<br/>음수면 후보가 낫다<br/>방향별 값은 따로 보고"]
    B --> R
    classDef cand fill:#90EE90,color:#000000
    classDef base fill:#87CEEB,color:#000000
    style R fill:#FFD700,color:#000000

장치 검사용으로 “받기에서는 늘 패스하고 리드8는 가장 강한 수로” 하는 약한 봇을 만들었습니다. 방향 A(약한 봇 하나 + easy 셋)에서 약한 봇은 +0.98 로 크게 밀렸습니다. 예상대로입니다. 그런데 방향 B(easy 하나 + 약한 봇 셋)에서는 easy 가 −0.92 로 밀렸습니다. 약한 봇들만 앉은 탁자에서는 아무도 리드를 받지 않으니, 혼자 앉은 easy 가 받고 또 받다가 손패가 꼬였습니다. 두 방향을 평균하면 Δ ≈ 0, 즉 “약하지 않다” 가 됩니다. 한 방향만 봤다면 “약하다” 고, 평균만 봤다면 “같다” 고 잘못 결론 낼 수 있었습니다.

그래서 추정량을 이렇게 고정했습니다.

  • 방향 A 와 B 를 둘 다 돌리고, 자리도 돌려 가며 앉힙니다.
  • Δ9 = (Δ_A + Δ_B) / 2 를 정본으로 쓰되, 방향별 값을 늘 따로 보고 합니다.
  • 4·5·6인 × 4·8·12판, 아홉 칸10을 같은 무게로 봅니다.
  • 신뢰구간11은 seed 단위입니다. 같은 seed 에서 나온 아홉 칸을 독립으로 세지 않습니다. 달무티 때 그렇게 세어 구간이 좁게 나온 버그가 있었습니다.

이 추정량은 hard 를 만들기 전에 고정했고, 이 편과 다음 편의 모든 숫자가 같은 자로 잰 것입니다.


3. 장치 검사#

장치를 믿어도 되는지 세 가지로 확인했습니다.

easy 대 easy 는 정확히 0. 아홉 칸 × seed 32 의 관측 288개 모두에서 Δ_A = Δ_B = 0 이었습니다. 처음 돌렸을 때는 ±4×10⁻¹⁶ 가 나왔습니다. 실수 평균을 다시 평균하면서 생긴 반올림 오차였고, 순위 합을 정수로 모아 계산하게 고쳤습니다. 작은 일이지만 “정확히 0 이어야 한다” 는 검사가 없었으면 그냥 지나갔을 것입니다.

약한 봇은 약하게 나온다. 2절의 사고 뒤 약한 봇을 “무엇이든 한 장씩만 낸다” 로 바꿨습니다. easy 대비 Δ +0.349 [+0.319, +0.380], 두 방향과 아홉 칸 모두 양수였습니다. 장치가 방향을 가립니다.

easy 의 반칙패율. Part 2 에서 적은 숫자가 여기서 나왔습니다. easy 끼리 4인 판에서 참가자당 판당 반칙패 10.7~11.7%, 나락 8.2~8.9%. hard 가 노릴 자리입니다.


4. 첫 파일럿 — 세다고 말하지 않는다#

hard 1차(Part 5 의 구조에 손으로 정한 출발 가중치)를 easy 와 견준 첫 결과입니다. 튜닝용 seed 16개, 아홉 칸, 11,520판.

값
Δ (전체)−0.579 [−0.624, −0.535]
4인−0.559
hard 반칙패0 (easy 8~12%)
easy 가 대신 둔 수0
hard 결정 시간 p99121.77ms (최대 12.2ms)

반칙패 0 은 Part 5 의 ③ 정상 종료 가드가 한 일입니다. 나락은 조금 늘었는데, hard 가 대부호가 되는 판이 많아서입니다. 나락은 대부호만 당합니다.

이 숫자를 보고 “세다” 고 적지 않았습니다. 상대가 easy 뿐이고, 가중치는 손으로 정한 출발값입니다. 무엇이 이 차이를 냈는지 모르는 상태에서 숫자가 좋은 것은 운이 좋은 것과 구별되지 않습니다. 그것을 가르는 것이 다음 절입니다.

결정 시간은 따로 적어 둘 것이 있습니다. 처음 구현은 같은 무늬 11장 + 조커 둘 손패의 리드가 21ms 였습니다. 후보를 엔진의 합법 수 목록 대신 Part 4 계획기의 묶음 목록에서 만들게 바꾸고(같은 수·같은 순서가 나오는지 테스트가 지킵니다) 9.6ms 가 됐습니다. 예산 100ms 안이지만, “평균 손패에서 빠르다” 와 “최악 손패에서 빠르다” 는 다른 이야기입니다.


5. 켜기·끄기 — 어느 항이 일을 하는가#

hard 1차의 점수 층에는 항이 일곱 개(진척13·통제14·온존15·8컷16·혁명17·위협·♠3)와 문턱 둘(문턱·차단), 조공 보호가 있었습니다. 어느 것이 실제로 행동과 성적을 바꾸는지 모릅니다. 그래서 하나씩 켜 보고, 하나씩 꺼 보고, 의심되는 짝은 함께 꺼 봤습니다. seed 32개, 아홉 칸, 변형마다 easy 대비와 기본 hard 와의 맞대결 을 둘 다 잽니다.

맞대결 Δ 는 “변형 하나 + 기본 hard 셋” 과 그 반대로 잰 값이고, 음수면 변형이 낫습니다.

5.1 규칙만 남기면#

변형easy 대비기본 hard 와 맞대결
기본 hard−0.5880
규칙만 (점수 항 전부 0, 차단 끔, 조공 보호 끔)−0.535+0.066

Part 5 의 ①~③(즉시 끝내기·확정 종료 줄·정상 종료 가드)만 남겨도 easy 대비 −0.535 입니다. 기본 hard 의 −0.588 가운데 91% 입니다. 점수 층 전체가 하는 일은 나머지 −0.053 입니다.

이것이 이 시리즈에서 가장 큰 발견입니다. 대부호에서 hard 가 easy 를 이기는 이유는 정교한 점수가 아니라 끝낼 수 있는 길을 처음부터 지키는 것 이었습니다. Part 2 에서 easy 가 열 판에 한 판을 반칙패(꼴찌)로 끝낸다고 적었는데, 가드는 그 반칙패를 0 으로 만들 뿐 아니라, 반칙패까지 가지 않더라도 강한 카드만 남아 리드를 받고도 끝내지 못하는 자리를 미리 피하게 합니다. 점수 항 없이도 그 효과가 −0.535 입니다.

5.2 하나씩 끄면#

끈 항easy 대비맞대결 Δ결정이 갈린 비율읽기
진척−0.345+0.57716.1%점수 층의 중심. 끄면 크게 나빠진다
온존−0.130+0.3259.5%끄면 나빠진다
통제−0.580−0.0208.9%끄나 켜나 같다
문턱 (언제든 바꿈)−0.472+0.1263.7%가드레일18은 쓸모가 있다
조공 보호−0.536+0.0400.5%조금 낫다
위협−0.649−0.0721.7%끄는 편이 낫다
♠3−0.596−0.0631.1%끄는 편이 낫다
8컷−0.596−0.0020.24%효과가 잡히지 않는다
차단−0.583−0.0050.10%효과가 잡히지 않는다
혁명−0.5870.0000.01%83만 결정 중 리드 50 만 바뀐다

“결정이 갈린 비율” 은 그림자 정책19으로 센 것입니다. 기본 hard 가 진행한 판의 매 결정에서 변형에게도 “너라면 무엇을 냈겐냐” 를 물어 다른 답이 나온 비율입니다. 게임은 바꾸지 않습니다. 훌라 시리즈에서 쓴 그 장치입니다.

5.3 읽기#

진척이 중심입니다. 혼자 켜도(규칙 + 진척) 기본 hard 를 맞대결 −0.119 로 이기고, 끄면 +0.577 로 가장 크게 나빠집니다.

통제와 온존은 서로를 지웁니다. 통제만 켜면 센 카드로 받아 버티려 하고(+0.427), 온존만 켜면 센 카드를 아끼느라 털지 못합니다(+0.787). 둘 다 켠 기본값에서는 서로 상쇄하고, 둘 다 끄면 오히려 −0.082 낫습니다. 하나만 보고는 알 수 없고, 짝으로 꺼 봐야 보이는 종류의 사실입니다.

위협과 ♠3 은 지금 정의로는 해롭습니다. 하나씩 꺼도 낫고, 둘 다 끄면 −0.128 로 거의 더해집니다. 위협 항은 Part 5 의 4절에서 적은 한계 그대로입니다. 상대의 마지막 두 장이 2 두 장이면 그것으로 끝내는 것은 반칙인데, 위협으로 셉니다. ♠3 항은 조커가 남은 동안 ♠3 을 아끼게 하는데, ♠3극상 기회20는 이미 97% 를 잡고 있었습니다. 아끼는 이득보다 털지 못하는 비용 이 컸습니다.

문턱은 장식이 아닙니다. easy 의 수에서 언제든 점수대로 바꾸게 하면 +0.126 나빠집니다. 달무티의 교훈이 대부호에서도 맞았습니다.

혁명 항은 지웠습니다. 끄면 83만 결정 가운데 리드 50 만 바뀌고 성적은 소수 셋째 자리까지 0.000 입니다. 정의가 작았고(혁명 뒤 남은 손패의 평균 강함 변화 × 0.5), 혁명이 계획에 미치는 효과는 진척 항이 혁명 뒤 상태로 이미 재고 있었습니다. 배선이 끊어진 것은 아닌지 확인한 뒤(가중치를 키우면 수가 바뀌는 테스트) 0 으로 남기지 않고 코드에서 지웠습니다. 행동을 거의 바꾸지 않는 항이라도 지우면 드물게 수가 바뀌므로 정책 버전을 올렸습니다.

8컷과 차단은 효과가 잡히지 않습니다. 8컷 항은 다른 항에 눌려 결정의 0.24% 만 바꾸고, 차단은 발동 조건(장수가 같은 상대 + 위험 0.5 이상)이 드물어 0.1% 입니다. 둘은 남겨 두되 튜닝 축에서 뺐습니다. 차단은 가중치가 아니라 우선순위 규칙 이기도 합니다.

5.4 실험의 건강#

조공 보호를 켜는 변형의 첫 실행에서 시간 초과가 세 번 나왔습니다. 일꾼 여덟에 그림자 결정까지 겹친 기계 경합이었습니다. 그 변형은 판정 불가 로 두고, 일꾼 넷으로 다시 돌렸습니다. 대체 0, Δ 는 소수 셋째 자리까지 같았습니다. 대체된 결정 세 개가 성적을 바꾸지 않았다는 것을 확인한 셈이지만, 확인하기 전까지는 그 숫자를 쓰지 않았습니다.


6. 튜닝 — 좋아질 때만 옮긴다#

실험이 남긴 튜닝 축은 일곱입니다(진척·통제·온존·위협·♠3·문턱·조공 보호). 방법은 좌표 하강21 입니다. 축 하나를 골라 후보 값 몇 개를 “후보 하나 + 지금 최선 셋” 맞대결로 재고, 가장 좋은 것으로 옮기고, 다음 축으로 갑니다. 한 바퀴 돌아 바뀐 것이 없으면 멈춥니다.

채택 조건은 둘입니다.

  1. 지금 최선과의 맞대결 95% 신뢰구간 상한이 0 보다 작다. 우연으로 좋아 보이는 것은 받지 않습니다.
  2. easy 대비가 지금 최선보다 0.02 넘게 나빠지지 않는다. 기본 hard 만 잘 이기고 easy 에게는 약해지는 방향을 막습니다.
축지금 → 채택맞대결 Δ [신뢰구간 상한]
위협1.5 → 0−0.070 [−0.045]
♠30.5 → 0−0.049 [−0.026]
통제0.5 (0·0.125·0.25 모두 유의하지 않음)—
온존1.2 (0·0.3·0.6 모두 나쁨 — 0 이면 +0.361)—
진척1 (0.5·1.5·2 모두 나쁘거나 같음)—
문턱0.1 → 0.4−0.106 [−0.082]
조공 보호1 (0 이면 +0.038)—

두 가지가 눈에 띕니다.

문턱이 표의 끝에서 멈췄습니다. 후보 표가 0~0.4 였는데 0.4 가 가장 좋았습니다. 끝에서 멈추면 그 밖에 더 좋은 값이 있는지 모르므로, 표를 0.6·0.8·1.2·1.6 으로 넓혀 다시 쟀습니다. 0.6 은 +0.003, 1.6 은 +0.237 이었습니다. 0.4 가 안쪽의 최적 입니다. easy 의 수에서 바꾸는 문턱이 처음 값의 네 배가 됐습니다.

통제와 온존은 묶여 있었습니다. 5.3 에서 “둘 다 끄면 낫다” 고 했는데, 위협과 ♠3 을 끈 뒤에는 둘을 따로 움직여도 나아지지 않았습니다. 하나만 줄이면 다른 하나가 상대적으로 커지는 셈이라, 둘은 한 쌍으로만 움직입니다. 좌표 하강은 한 축씩 움직이므로 이 쌍을 더 좋은 자리로 옮기지 못했습니다. 이것은 남은 것 으로 적어 둡니다.

6.1 확인 — 튜닝에 쓰지 않은 seed 에서#

튜닝 seed 에 맞춘 것은 아닌지, 쓰지 않은 seed 64개(101~164)에서 다시 쟀습니다.

비교Δ [95% CI]4인
튜닝 결과 대 튜닝 전 hard 맞대결−0.241 [−0.261, −0.221]−0.186
튜닝 결과 대 easy−0.754 [−0.776, −0.732]−0.710
튜닝 전 hard 대 easy−0.563−0.611

튜닝 seed 에서 세 걸음의 개선 합이 약 −0.23 이었는데, 새 seed 에서 −0.241 로 그대로 나왔습니다. 과적합22의 흔적이 없습니다.

6.2 동결#

0 이 된 위협·♠3 항은 가중치 0 으로 남기지 않고 코드에서 지웠습니다. 지운 코드가 “가중치 0” 과 같은 게임을 두는지는, 둘의 easy 대비 Δ 가 소수 여섯째 자리까지 같다(−0.754246)는 것으로 확인했습니다.

남은 것이 Part 5 에서 적은 가중치입니다.

// DefaultWeights 는 운영과 시뮬레이터의 기본값입니다 — hard-3 (튜닝 seed 1–32 좌표 하강, 확인 seed 101–164).
// 값만 바꾸면 다른 정책입니다 (버전을 올립니다).
func DefaultWeights() Weights {
    return Weights{
        Progress: 1, Control: 0.5, Preserve: 1.2, EightCut: 0.4,
        Margin: 0.4, BlockThreshold: 0.5, ReturnPlan: 1,
    }
}

이 버전을 hard-3 이라 부릅니다. 1차가 hard-1, 혁명 항을 지운 것이 hard-2 입니다. 버전은 코드 상수와 기준 결정 fixture23 로 지킵니다. 고정 seed 국면 60개에서 정책이 낸 결정을 파일로 두고, 행동이 바뀌면 테스트가 “버전을 올리라” 고 실패합니다. hard-3 으로 넘어갈 때 60 국면 중 10 이 바뀌었습니다. 위협·♠3 을 지운 효과입니다.

그리고 운영에서 봇이 둔 수마다 어느 정책의 어느 버전이 두었는지, easy 가 대신 두었다면 왜인지 를 이벤트에 적습니다. 서버가 새 바이너리로 다시 떠서 한 매치가 두 버전에 걸치면, 그 참가자의 기록은 “혼합” 으로 갈립니다. 나중에 사람 상대 성적을 셀 때 어느 버전의 성적인지 가를 수 있어야 하기 때문입니다.


정리#

재는 장치는 운영과 같은 결정 경로를 타고, 수 하나를 30µs 에 두며, 봇 하나를 셋 사이에 양방향 으로 앉혀 잽니다. 같은 봇끼리는 정확히 0 이 나오고, 약한 봇은 약하게 나옵니다.

그 장치로 hard 의 항목을 하나씩 켜고 꺼 본 결과는 이렇습니다.

  • 규칙 세 개(즉시 끝내기·확정 종료 줄·정상 종료 가드)가 개선의 91% 입니다. 반칙패를 처음부터 피하는 것이 대부호 hard 의 본체입니다.
  • 점수 항 가운데 진척 이 중심이고, 통제와 온존 은 서로를 지우며, 위협과 ♠3 은 해로워서 지웠고, 혁명 은 행동을 바꾸지 않아 지웠습니다.
  • easy 의 수에서 바꾸는 문턱 은 0.1 에서 0.4 로 올랐습니다. 가드레일을 더 높인 셈입니다.

튜닝 결과는 쓰지 않은 seed 에서 easy 대비 −0.754 였습니다. 하지만 이 숫자는 아직 시험 성적이 아닙니다. 튜닝 seed 와 확인 seed 를 모두 본 뒤의 숫자이니까요. 시험은 미리 정한 문제로, 한 번만, 다른 seed 에서 봅니다. Part 7 입니다.


시리즈 목록#



  1. 조공: 판을 시작할 때 아래 계급이 위 계급에게 가장 강한 카드를 주고, 위 계급은 자기 손패에서 같은 장수를 골라 돌려주는 규칙입니다. ↩︎

  2. seed: 무작위로 섞은 덱을 다시 똑같이 만들어 내기 위한 번호입니다. 같은 seed 는 언제 돌려도 같은 순서로 카드가 나뉘므로, «같은 딜에서 봇만 바꿔 앉혀» 비교할 수 있습니다. ↩︎

  3. 일꾼(worker): 시뮬레이션을 동시에 나눠 돌리는 실행 단위입니다. 일꾼이 넷이면 네 판을 동시에 돕니다. ↩︎

  4. µs(마이크로초): 100만분의 1초입니다. 1ms(밀리초)는 1,000µs 입니다. ↩︎

  5. 불변식: 어떤 일이 일어나도 늘 참이어야 하는 조건입니다. 54장이 보존된다, 순위가 1~N 을 빠짐없이 한 번씩 쓴다 같은 것이고, 깨지면 프로그램 어딘가가 틀린 것입니다. ↩︎

  6. 반칙패: 조커·2(혁명 중에는 3)·8 만으로 된 수·♠3 한 장으로 손패를 비우면 그 판 꼴찌가 되는 규칙입니다. ↩︎

  7. 나락: 지난 판 대부호가 남아 있는데 다른 사람이 먼저 손패를 비우면 대부호가 그 즉시 탈락해 꼴찌가 되는 규칙입니다. ↩︎

  8. 리드 / 받기 / 필드: 필드는 테이블에 놓여 있는 지금의 카드(들)입니다. 필드가 비어 있을 때 첫 수를 내는 것이 «리드», 놓인 필드보다 센 수로 이어 내는 것이 «받기» 입니다. 모두가 패스하면 필드가 정리되고 마지막에 낸 사람이 다시 리드합니다. ↩︎

  9. Δ(델타): 두 봇의 «판당 평균 순위» 차이입니다. 음수면 후보 봇의 순위가 더 좋다는 뜻이고, −0.5 는 평균 반 등 앞선다는 뜻입니다. ↩︎

  10. 아홉 칸: 4·5·6인 × 4·8·12판의 아홉 가지 조건입니다. 봇이 특정 인원·길이에서만 강한 것이 아닌지 보려고 모두 재서 같은 무게로 합칩니다. ↩︎

  11. 95% 신뢰구간: 측정한 값이 우연에 따라 흔들릴 수 있는 범위입니다. «상한이 0 보다 작다» 는 것은 운이 나쁜 쪽으로 봐도 여전히 후보가 낫다는 뜻입니다. ↩︎

  12. p99: 측정값을 작은 순으로 줄 세웠을 때 99% 지점의 값입니다. «결정 시간 p99 1.5ms» 는 결정 100번 중 99번이 1.5ms 안에 끝났다는 뜻으로, 평균보다 «느린 경우» 를 보는 데 씁니다. ↩︎

  13. 진척(Progress): 이 수를 냈을 때 손패 계획대로 한 수만큼 줄었는가를 재는 점수 항입니다. 계획을 거스르는 수(예: 쌍을 쪼개 한 장만 내기)는 남은 수가 늘어나므로 벌점을 받습니다. ↩︎

  14. 통제(Control): 이 수가 필드에 놓인 뒤 아무도 받지 못해 다음 리드가 다시 내게 올 가능성을 재는 점수 항입니다. 장부의 위험 점수를 1 에서 뺀 값이고, 8컷처럼 필드를 바로 정리하는 수는 1 입니다. ↩︎

  15. 온존(溫存): 강한 카드를 아껴 두는 것입니다. 이 시리즈에서는 «지금 서열의 상위 카드와 조커를 쓰는 비용» 을 재는 점수 항으로, 손패가 많을수록 비용이 크고 종반에는 거의 0 이 됩니다. ↩︎

  16. 8컷 항: 8컷은 8 이 든 수를 내면 그 자리에서 필드가 정리되고 다시 내가 리드하는 규칙입니다. 점수 항으로서의 8컷은 «리드를 되찾는 데 값진 8 을 너무 일찍 쓰는 비용» 입니다. ↩︎

  17. 혁명: 같은 숫자 4장 이상을 한 번에 내면 조커를 뺀 카드 서열이 통째로 뒤집히는 규칙입니다. 다시 4장을 내면 돌아오고, 판이 끝나면 풀립니다. ↩︎

  18. 가드레일: 도로의 난간처럼, 봇이 점수 계산 때문에 엉뚱한 쪽으로 벗어나지 못하게 막는 기본값입니다. 이 시리즈에서는 «일단 easy 의 수를 두고, 뚜렷이 더 나은 근거가 있을 때만 바꾼다» 는 규칙을 뜻합니다. ↩︎

  19. 그림자 정책: 실제로 게임을 진행하는 봇 옆에서, 같은 상황을 보고 «나라면 무엇을 냈을까» 만 답하는 봇입니다. 답은 기록만 하고 게임에는 영향을 주지 않습니다. 두 정책이 어느 상황에서 갈리는지 찾는 데 씁니다. ↩︎

  20. ♠3극상: 조커 한 장은 최강의 싱글이지만 오직 ♠3 한 장으로만 받을 수 있고, 받으면 필드가 정리되는 규칙입니다. ↩︎

  21. 좌표 하강: 조정할 값이 여럿일 때 한 번에 하나만 바꿔 보고, 좋아지면 옮기고, 다음 값으로 넘어가는 것을 되풀이하는 단순한 조정 방법입니다. ↩︎

  22. 과적합: 조정에 쓴 문제에만 잘 맞게 되어 새 문제에서는 성적이 떨어지는 현상입니다. 조정에 쓰지 않은 seed 에서 다시 재면 과적합인지 알 수 있습니다. ↩︎

  23. fixture(기준 결정 고정 파일): 미리 정해 둔 상황 60개에서 봇이 낸 수를 적어 둔 파일입니다. 코드를 고쳐 수가 달라지면 테스트가 실패해 «행동이 바뀌었다» 를 알려 줍니다. ↩︎