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


Part 5 와 Part 6 에서 hard 봇의 부품을 다 갖췄습니다. 이제 두 가지를 물을 차례입니다.

  • hard 는 easy 보다 강한가.
  • 저울의 추 일곱 개, 땡큐의 템포 추 하나, 스톱 손잡이 세 개는 전부 사람이 손으로 정한 값입니다. 그 열한 개는 실제로 무엇을 하고 있는가.

Part 4 의 끝에서 이미 답을 예고했습니다. 앞선 두 시리즈 모두 봇을 만드는 것보다 봇이 약하다는 사실을 알아내는 것이 어려웠습니다. 브라우저에서 몇 판 해 보는 것으로는 “조금 약하다” 를 절대 찾을 수 없습니다. 도구가 필요합니다.

이번 편은 그 도구를 만들고, 도구가 무엇을 잡았으며, 열한 개 추를 하나씩 빼 보니 무엇이 보였는지를 순서대로 씁니다. 결론은 Part 8 에서 냅니다.


1. 재는 장치 — 딜 운을 지우는 법#

훌라는 딜 운의 분산이 큽니다. 7 넉 장을 받으면 무엇을 해도 이기고, 조합이 하나도 없는 손패를 받으면 무엇을 해도 집니다. 그 차이가 정책의 차이보다 훨씬 크므로, 봇 두 개를 그냥 붙여 놓고 승률을 세면 운을 재는 것이 됩니다.

지우는 방법은 렉시오 Part 6 과 같습니다. 같은 딜에서 자리만 바꿔 앉힙니다.

flowchart LR
    S["딜 하나<br/>seed 로 고정"] --> A["hard 1명 + easy 3명<br/>hard 를 네 자리에<br/>차례로 앉힘"]
    S --> B["easy 1명 + hard 3명<br/>easy 를 네 자리에<br/>차례로 앉힘"]
    A --> C["혼자 hard 였을 때<br/>그 자리의 정산 포인트"]
    B --> D["혼자 easy 였을 때<br/>그 자리의 정산 포인트"]
    C --> E["차이 = 이 딜에서<br/>정책이 만든 몫"]
    D --> E
    style S fill:#87CEEB,color:#000000
    style A fill:#90EE90,color:#000000
    style B fill:#FFD700,color:#000000
    style C fill:#90EE90,color:#000000
    style D fill:#FFD700,color:#000000
    style E fill:#FF9999,color:#000000

같은 카드를 받은 같은 자리에서 hard 였을 때와 easy 였을 때의 판당 정산 포인트 차이 가 지표입니다. 이 시리즈에서는 이것을 그냥 “차이” 라고 부르겠습니다. 딜은 seed 하나로 고정되고, 정책이 난수를 얼마나 쓰든 다음 판의 딜이 달라지지 않도록 판마다 덱을 미리 만들어 둡니다.

양방향으로 돌리는 이유가 있습니다. “혼자 hard” 만 돌리면 hard 의 실력과 “혼자 다른 정책이라서 생기는 효과” 가 섞입니다. 반대 방향도 돌려 그 효과를 상쇄합니다. 3인·4인·5인 각각에서 자리를 전부 돌리면 seed 하나에 24경기, 3판 매치로 72판입니다.

지표가 승률이 아니라 포인트인 이유는 Part 1 에 있습니다. 훌라는 더 자주 이기고도 한 번 크게 져서 누적이 마이너스일 수 있는 게임입니다.

규칙은 한 벌만#

시뮬레이터는 규칙을 다시 구현하지 않습니다. 실제 서버의 규칙 코드를 그대로 불러 판을 굴립니다. 카드를 뽑고, 등록하고, 땡큐를 가리고, 정산하는 코드가 서버와 시뮬레이터에서 같은 함수입니다. 규칙이 두 벌이면 시뮬레이터가 서버와 다른 승자를 뽑고, 그러면 잰 숫자가 서버의 것이 아니게 됩니다.

대신 시계는 흉내 냅니다. 5초짜리 땡큐 창을 실제로 기다리지 않고, 창이 열리면 봇들에게 바로 묻고 바로 닫습니다.

도구가 옳은지부터#

도구를 만들자마자 한 검사가 둘입니다.

첫째, easy 대 easy 를 돌리면 차이가 정확히 0 이어야 합니다. 0 이 아니면 자리 회전이나 집계에 편향이 있는 것입니다. 이 검사가 시뮬레이터를 증명하지는 않습니다. 양쪽에 똑같이 걸리는 규칙 버그도 0 을 만듭니다. 그래도 “없는 신호를 지어내지 않는다” 는 것은 보여 줍니다.

둘째, 판을 일부러 망가뜨려 검사기가 잡는지 봅니다. 카드 한 장을 몰래 지우고, 점수를 몰래 얹어서, 52장 검사와 제로섬 검사가 실제로 실패하는지 확인합니다. 늘 통과하는 검사기는 아무것도 지키지 않습니다.

시뮬레이터를 먼저 만든 이유#

원래 계획에서는 hard 봇을 먼저 만들고 시뮬레이터를 나중에 붙이려 했습니다. 순서를 뒤집었습니다. 시뮬레이터는 “hard 가 센가” 를 재는 도구만이 아니라, 봇을 짓는 동안 카드가 사라지지 않는가, 정산이 제로섬인가, 판이 끝나는가 를 매 수 확인해 주는 유일한 장치입니다. 봇 뒤에 붙이면 판이 안 끝나는 봇을 한참 동안 모르고 짓게 됩니다.


2. 도구가 먼저 잡은 것 — 다른 봇을 재고 있었다#

hard 봇을 붙여 처음 돌렸을 때, 도구는 봇의 실력이 아니라 도구 자신의 버그 를 먼저 잡았습니다. 렉시오 Part 6 에서 겪은 것과 같은 순서입니다.

시뮬레이터가 봇을 부를 때 스냅샷만 넘기고 장부와 턴 시작 등록 여부를 넘기지 않았습니다. Part 5 의 그 두 가지입니다. 봇은 빈 장부를 받았고, 빈 장부에서 “안 보이는 카드” 는 52장 전부였습니다. 자기 손패까지 안 보이는 카드로 센 것입니다. 그리고 턴 시작 등록 여부가 늘 “미등록” 이어서, 손패를 비우는 계획은 전부 훌라 로 보였습니다.

flowchart LR
    A["시뮬레이터가<br/>스냅샷만 넘김"] --> B["빈 장부<br/>안 보이는 카드 52장"]
    A --> C["턴 시작 등록 여부<br/>늘 미등록"]
    B --> D["붙이기 자리 계산 전부 틀림"]
    C --> E["모든 종료가 훌라로 보임"]
    D --> F["서버의 hard 와<br/>다른 봇을 재는 중"]
    E --> F
    style A fill:#FF9999,color:#000000
    style B fill:#FFD700,color:#000000
    style C fill:#FFD700,color:#000000
    style D fill:#FF9999,color:#000000
    style E fill:#FF9999,color:#000000
    style F fill:#FF6666,color:#000000

이 상태로 성적을 재고 추를 조정했다면, 시뮬레이터에서 고른 값이 서버에서는 다른 봇 이 됐을 것입니다. 시뮬레이터가 봇을 부르는 길을 서버가 봇을 부르는 길과 같은 함수 로 통일해 고쳤습니다. 저장해 놓고 옮기지 않은 값이 이 프로젝트에서 여섯 번째였습니다.

작은 것도 하나 있었습니다. 봇의 생각 시간을 정책별로 나누지 않고 합쳐 세는 바람에, easy 의 0ms 가 섞여 hard 의 중앙값이 0ms 로 나왔습니다. 갈라 세니 Part 5 의 그 숫자들이 나왔습니다.


3. 첫 성적 — 8 seed 로는 아무 말도 못 한다#

도구를 고치고 hard 를 재었습니다. 이때의 hard 는 Part 5 의 계획과 저울만 있고 땡큐와 일반 스톱은 없는 상태였습니다.

seed 수판 수차이 (판당)95% 신뢰구간
8576+3.60 을 포함
322,304+13.8+3.0 ~ +24.6

8 seed 에서는 hard 가 앞서 보였지만 신뢰구간이 0 을 걸쳤습니다. 이길 수도 질 수도 있다는 뜻입니다. 32 seed 에서야 구간이 0 을 벗어났습니다. seed 별 차이를 보면 절반 가까이가 정확히 0 이었습니다. 그 딜에서는 hard 와 easy 가 완전히 같은 결과를 낸 것입니다. 나머지 절반에서 벌어진 차이가 평균을 만듭니다.

브라우저에서 몇 판 해 보고 “hard 가 센 것 같다” 고 느끼는 것이 얼마나 근거가 없는지 이 표가 보여 줍니다. 576판을 돌리고도 확신할 수 없었습니다. 판당 턴 수는 easy 18.7, hard 17.2 로 8% 차이였고, 한 턴에 등록·붙이기를 몇 번 하는지의 분포는 둘이 거의 같았습니다. 화면으로는 누가 센지 알 수 없습니다.

다만 이 숫자는 합격이 아닙니다. 추를 조정하는 데 쓸 seed 에서 잰 값이기 때문입니다. 시험 문제로 공부한 뒤 같은 문제로 시험을 보면 점수가 부풀려집니다. 합격 판정은 Part 8 에서 한 번도 쓰지 않은 seed 로 합니다.

여기에 Part 6 의 땡큐가 붙자 차이가 판당 4점 넘게 벌어졌고, 일반 스톱이 붙자 더 벌어져 32 seed 기준 판당 10.1점 이 됐습니다. 이 값이 이제부터의 기준선입니다.


4. 추를 하나씩 빼 본다#

열한 개 추는 전부 손으로 정한 값이고 한 번도 재 본 적이 없습니다. 읽어서는 답이 안 나옵니다. 추를 하나 빼고 돌려 봐야 그 추가 실제로 무엇을 하는지 압니다.

방법은 단순합니다. 기준선을 한 번 재고, 추를 하나씩 0 으로 놓고 같은 seed 로 다시 잽니다. 차이가 줄면 그 추는 일하고 있던 것이고, 그대로면 놀고 있던 것이며, 늘면 해를 끼치고 있던 것입니다. 32 seed 로 열두 번 돌리는 데 약 14분이 걸렸습니다.

뺀 추기준선 대비읽기
상대 손패 치우침−2.48균등으로 돌아가면 스톱박이 75건에서 392건으로
남은 카드값−1.41없으면 손패를 안고 앉는다. Part 5 의 2♥
상대가 먼저 끝낼 위험−0.63없으면 스톱을 네 번에 한 번만 건다
스톱 안전 여유−0.13잡음 안. 그러나 스톱박이 75건에서 116건으로
열어 준 붙이기 자리−0.06잡음 안
덮지 못하는 카드−0.02잡음 안. 결정 셋만 바뀜
덮지 못하는 카드의 값0.000수가 하나도 안 바뀜
손에 남긴 70.000수가 하나도 안 바뀜
미등록으로 끝남0.000수가 하나도 안 바뀜
미등록 + 7 의 훌라 가능성0.000수가 하나도 안 바뀜
땡큐 템포+0.13빼는 편이 낫다

잡음의 눈금은 약 0.14 입니다. 그 아래의 차이는 우연과 구분되지 않습니다.

정확히 0 인 넷#

표 아래쪽의 넷이 이 편의 발견입니다. 5만 번의 결정에서 수가 하나도 바뀌지 않았습니다. 스톱 수도 땡큐 수도 기준선과 자릿수 하나 다르지 않습니다. 추가 저울에 닿지 않아서가 아닙니다. 무게를 1000 으로 놓으면 수가 바뀌는 것을 검사가 확인했습니다. 기본 무게에서 다른 추에 눌리는 것 입니다.

왜 그런지 7 을 예로 보겠습니다. 7 한 장을 손에 남기면 Part 5 의 저울에서 이미 벌점이 큽니다. 덮지 못하는 카드 3.0 에 카드값 7 × 0.3 = 2.1 을 더해 5.1 입니다. 그러니 7 을 등록하는 계획이 언제나 이깁니다. “손에 남긴 7” 추 2.0 과 “훌라 가능성” 추 1.2 는 이미 정해진 순서를 뒤집을 만큼 크지 않습니다.

flowchart LR
    A["7 을 손에 남김"] --> B["덮지 못하는 카드 −3.0<br/>남은 카드값 −2.1<br/>합계 −5.1"]
    A --> C["손에 남긴 7 −2.0<br/>훌라 가능성 +1.2<br/>합계 −0.8"]
    B --> D["등록이 이미 이김<br/>오른쪽 추는 순서를<br/>못 뒤집는다"]
    C --> D
    style A fill:#87CEEB,color:#000000
    style B fill:#FF9999,color:#000000
    style C fill:#D3D3D3,color:#000000
    style D fill:#FFD700,color:#000000

이것이 무엇을 뜻하는지 분명히 해 둡니다. Part 4 의 네 번째 자리, “7 을 전략적으로 보유한다” 는 hard 봇에 후보로는 존재하지만 실제로는 한 번도 발동하지 않습니다. 이 봇은 easy 와 마찬가지로 7 이 들어오면 사실상 즉시 내려놓습니다. 차이는 easy 는 규칙이 그렇게 시켜서이고 hard 는 저울이 그렇게 기울어서라는 것뿐입니다.

“덮지 못하는 카드의 값” 은 다른 이유로 0 입니다. 이 추는 덮지 못하는 카드의 카드값을 1점당 0.15 로 세는데, “남은 카드값” 추가 같은 카드를 1점당 0.3 으로 이미 세고 있습니다. 같은 것을 더 작게 한 번 더 세는 중복 보상 입니다. 렉시오 Part 5 에서 “같은 사실을 두 항이 벌했다” 던 그 실수의 훌라 판입니다.

크게 일하는 셋#

위쪽의 셋은 반대입니다. 셋 다 Part 6 의 스톱 손잡이거나 Part 5 의 첫 번째 추입니다. 상대 손패 치우침을 빼면 Part 6 §4 의 36% 스톱박이 그대로 돌아옵니다. 남은 카드값을 빼면 봇이 손패를 안고 앉아 스톱 기회 자체가 줄어듭니다. 상대가 먼저 끝낼 위험을 빼면 기다림의 값이 늘 끝낼 확률만큼이라 스톱을 잘 안 겁니다.

저울에서 실제로 일하는 추는 열한 개 중 서너 개 였습니다.


5. 조정 — 좋아질 때만 옮긴다#

놀고 있는 넷과 결정을 셋만 바꾸는 하나를 빼고, 남은 여섯 개를 조정했습니다.

방법은 한 번에 추 하나씩입니다. 처음 값의 0배, 0.5배, 0.75배, 1.5배, 2배를 넣어 보고 좋아질 때만 옮깁니다. 후보를 “지금 값” 이 아니라 “처음 값” 의 배수로 잡는 이유가 있습니다. 지금 값의 배수로 하면 한 번 0 이 된 추는 영영 돌아오지 못합니다. 여섯 개를 다 돌고 나서 한 바퀴 더 돌아 아무것도 안 움직이면 멈춥니다. 걸음마다 어느 추를 얼마에서 얼마로 왜 옮겼는지 기록합니다. 설명할 수 없는 탐색은 아무도 반박할 수 없는 값을 만듭니다.

59번 재서 두 걸음을 옮겼습니다.

걸음추변경이득
1상대 손패 치우침0.25 → 0.5+0.18
2땡큐 템포1.0 → 0+0.22

기준선 10.14 가 10.54 로 올랐습니다. 나머지 넷은 다섯 가지 배수 모두 처음 값을 이기지 못했습니다.

그대로 앉히지 않은 이유#

두 가지가 걸렸습니다.

첫째, 두 걸음의 이득이 잡음 눈금 0.14 와 같은 자릿수 입니다. 이 정도 이득은 seed 를 외운 것일 수 있습니다.

둘째, 확률입니다. 치우침을 0.5 로 올리자 스톱 선언이 23% 에서 14% 로 줄고 스톱박이 75건에서 14건으로 줄었습니다. 그런데 봇이 믿은 성공률은 0.87 인데 실제 성공률은 0.98 입니다. Part 6 §4 에서 0.5 를 고르지 않은 이유가 정확히 이것이었고, 조정이 같은 자리에 다시 도착했습니다. 점수는 올랐는데 확률은 더 틀렸습니다. 조정의 목적 함수가 판당 포인트 하나뿐이라, 확률이 맞는지는 애초에 점수에 들어 있지 않습니다.

그래서 이 값을 그대로 앉히지 않고 다음 단계로 넘겼습니다. 참고로 이 40분짜리 조정 실행이 중간에 죽어 통째로 날아간 적이 있습니다. 그 경험이 Part 8 의 검증 도구가 칸마다 중간 저장을 하는 이유가 됩니다.


6. 어디서 갈리는가 — 그림자 정책#

추를 빼 보는 것은 “어느 추가 점수를 움직이는가” 를 답합니다. 남은 질문은 “어디서, 왜” 입니다.

방법은 이렇습니다. 추 하나만 다른 두 저울을 준비합니다. 한 저울이 실제로 판을 두고, 매 결정마다 같은 상황을 다른 저울에게도 보여 주어 계산만 시킵니다. 둘이 다른 수를 고른 자리를 기록합니다. 두 판을 따로 돌리지 않는 이유는, 첫 갈림 이후의 판은 이미 다른 판이라 어느 한 수의 결과라고 말할 수 없기 때문입니다.

flowchart LR
    A["같은 상황"] --> B["실제 저울<br/>수를 둔다"]
    A --> C["그림자 저울<br/>계산만 한다"]
    B --> D{"둘이 다른가"}
    C --> D
    D -->|"예"| E["상황과 두 수를<br/>통째로 기록"]
    D -->|"아니오"| F["다음 결정"]
    style A fill:#87CEEB,color:#000000
    style B fill:#90EE90,color:#000000
    style C fill:#D3D3D3,color:#000000
    style D fill:#FFD700,color:#000000
    style E fill:#FF9999,color:#000000
    style F fill:#D3D3D3,color:#000000

기록에는 원인을 짐작해 붙이지 않습니다. 짐작을 꼬리표로 달면 그 짐작이 원인 통계가 되어 자기 짐작을 데이터로 착각합니다. 어느 단계에서 어떤 종류의 수가 어떤 수로 바뀌었는지만 적습니다.

세 가지를 물었습니다.

“덮지 못하는 카드의 값” 은 정말 아무것도 안 하는가#

그렇습니다. 16,394번의 결정에서 갈린 자리가 0 이었습니다. §4 에서 본 0.000 은 집계가 상쇄된 것이 아니라 행동이 처음부터 같았다는 뜻입니다. 집계가 0 인 것과 행동이 0 인 것은 다른데, 여기서는 둘 다였습니다. 이 추는 무게가 아니라 정의 가 문제입니다. Part 8 에서 지웁니다.

치우침 0.25 와 0.5 는 어디서 갈리는가#

양방향으로 돌렸습니다.

실제 저울그림자 저울갈린 자리바뀐 수갈린 판의 정산
0.250.565전부 “스톱 → 뽑기”최악 −51, 중앙값 0
0.50.2584전부 “뽑기 → 스톱”최악 −4, 중앙값 −1

두 값의 차이는 오직 일반 스톱을 거느냐 였습니다. 그리고 꼬리가 한쪽으로만 깁니다. 0.25 가 걸었는데 0.5 라면 안 걸었을 스톱은 최악의 경우 판 정산이 −51 이었습니다. 반대로 0.5 가 안 걸었는데 0.25 라면 걸었을 스톱을 놓친 손해는 최악 −4 였습니다. 걸어서 틀리는 비용이 안 걸어서 놓치는 비용보다 한 자릿수 큽니다. Part 1 의 스톱박이 그만큼 무겁습니다.

여기서 §5 의 판단을 하나 정정 했습니다. “0.5 는 예측 0.87 에 실제 0.98 로 확률이 틀렸다” 고 했는데, 실제 성공률은 건 스톱에 대해서만 잽니다. 조심스러운 저울일수록 안전한 것만 골라 걸므로 성공률이 기계적으로 오릅니다. 이것은 선택 효과이지 확률 모델이 틀린 증거가 아닙니다. 진짜로 확률이 맞는지 알려면 걸지 않은 기회까지 포함해 “걸었다면 이겼을까” 를 물어야 하는데, 그 답은 없습니다. 그러니 조정이 0.5 를 고른 것은 잡음을 좇은 것이 아니라 스톱박의 꼬리를 피한 것 입니다.

다만 이름과 하는 일이 어긋납니다. 이 손잡이는 “상대 손패가 낮게 치우쳐 있다” 는 자리에 붙어 있는데, 실제로 하는 일은 “스톱박의 꼬리를 피한다” 입니다. 손잡이 하나가 두 가지 일을 하고 있습니다.

땡큐를 93~98% 부르는 것은 판단인가#

판단입니다. 템포 추를 1.0 에서 0 으로 바꾸자 갈린 자리는 창 2,300여 개 중 26개 였고, 전부 “부른다 → 안 부른다” 였으며, 최악의 손해가 −4 였습니다. 높은 비율은 템포 추가 밀어 올린 것이 아니라 Part 6 의 저울이 가져가는 쪽을 낫다고 본 결과입니다. 훌라에서 카드 한 장을 더 갖는 값이 실제로 큽니다.

갈린 자리를 고정해 둔다#

갈린 자리 중 손해가 큰 순으로 다섯 개씩을 회귀 테스트 로 남겼습니다. 지키는 것은 “이 수가 옳다” 가 아니라 “이 상황에서 이 저울은 이 수를 둔다” 입니다. 나중에 누가 저울을 고쳤을 때 이 자리의 수가 바뀌면 테스트가 알려 줍니다. 그래서 파일에 저울의 값과 당시 코드의 버전을 함께 적어 둡니다.


정리#

이번 편에서 알게 된 것입니다.

  1. 같은 딜에서 자리를 바꿔 앉히면 딜 운이 지워지고 정책의 몫만 남습니다. 규칙은 서버 코드를 그대로 씁니다.
  2. 도구는 봇의 실력보다 도구 자신의 버그 를 먼저 잡았습니다. 시뮬레이터는 서버와 다른 봇을 재고 있었습니다.
  3. 8 seed 로는 아무 말도 못 하고 32 seed 에서야 방향이 보였습니다. 화면으로는 누가 센지 알 수 없습니다.
  4. 추를 하나씩 빼 보니 열한 개 중 넷은 5만 번의 결정에서 수를 하나도 바꾸지 않았습니다. “7 을 전략적으로 보유한다” 는 후보로만 존재합니다.
  5. 조정은 두 걸음을 옮겼지만 이득이 잡음 눈금과 같은 자릿수였습니다.
  6. 그림자 저울로 갈린 자리를 보니 스톱 손잡이의 차이는 오직 스톱박의 꼬리 였고, 걸어서 틀리는 비용이 안 걸어서 놓치는 비용보다 한 자릿수 컸습니다.

이제 값을 동결 할 차례입니다. 그런데 실험이 고른 값과 최종 값이 하나 다릅니다. 그 이유, 한 번도 쓰지 않은 seed 에서의 합격 판정, 그리고 Part 4 의 여덟 자리 결산을 Part 8 에서 다룹니다.


시리즈 목록#

References#