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


설계안이 검증을 만나면#

Part 1에서 공정성 경계와 결정적 easy 봇을 만들었고, Part 2에서 hard 봇의 정보 모델을 세웠고, Part 3에서 수 평가 함수와 전략적 패스로 실제 결정을 내렸습니다. 세 편 모두 “이 글은 설계안이고 코드는 논지를 보이기 위한 발췌"라고 밝혀 두었습니다.

그 설계안을 실제 게임에 구현하고, 시뮬레이터를 붙여 easy 봇과 같은 조건에서 붙여 봤습니다. 결과는 이랬습니다.

최초 hard 봇은 easy 봇보다 평균 종료 순위가 0.6601등 나빴습니다.

이름만 hard 였던 셈입니다. Part 3 의 마지막 절에서 “봇을 만드는 것보다 봇이 정말 세졌는지 재는 것이 더 어렵다"고 썼는데, 정작 그 문장을 쓴 설계안 자체가 재보기 전까지는 반대 방향으로 가고 있었습니다.

이 글은 그 회고입니다. 다룰 것은 넷입니다.

  • 무엇을 어떻게 쟀는가 (Part 3 §5 의 방법론이 실제로 어떤 장치가 되었는가)
  • 어떤 구조 결함이 hard 봇을 약하게 만들었는가 (셋)
  • 어떤 고급 기능이 실제로 살아남았는가
  • 정정 후의 정식 수치는 얼마인가

지표부터 못 박아 두겠습니다. 이 글의 모든 숫자는 Hard 평균 종료 순위 - Easy 평균 종료 순위 입니다. 순위는 낮을수록 좋으므로, 음수면 hard 가 더 좋다 는 뜻입니다.

한 가지 미리 말해 둘 것이 있습니다. 이 글은 Part 1~3 을 통째로 폐기하지 않습니다. 공개 정보 모델, 확실승수 판정, 전략적 패스는 검증에서도 살아남았습니다. 무너진 것은 그 재료들을 최종 점수로 합치는 경계 였습니다.


1. 먼저, 재는 장치부터#

Part 3 §5 는 “미러 매치로 분산을 죽이고, 평균 등수를 지표로 쓰고, held-out 시드로 검증하고, 좌표 하강으로 가중치를 맞춘다"는 원칙만 적어 두었습니다. 원칙은 그대로 유효했지만, 실제로 돌려 보니 원칙만으로는 부족했고 다음 장치들이 더 필요했습니다.

① 양방향 focal 구성. hard 1명 대 easy N-1 명만 재면, 측정하는 것이 “hard 의 강함"인지 “easy 무리 사이에서 튀는 정책의 이점"인지 구분되지 않습니다. 그래서 easy 1명 대 hard N-1 명도 함께 재고, 두 구성의 부호가 일관되는지 봅니다.

② 모든 최초 좌석 회전. 같은 딜을 좌석 수만큼 돌려 모든 정책이 모든 자리를 한 번씩 잡게 합니다. Part 3 에서 예고한 듀플리케이트 방식 그대로입니다.

③ label swap 부호 반전 테스트. 두 정책의 이름표만 맞바꿔 같은 실험을 돌렸을 때 delta 의 부호만 뒤집히는지 확인합니다. 뒤집히지 않으면 그것은 정책 차이가 아니라 하네스 버그입니다. 이 테스트 하나가 “좌석 배정이 한쪽으로 쏠려 있었다"류의 실수를 조용히 잡아 줍니다.

④ canonical JSON 결정성. 같은 입력을 직렬로 돌린 report 와 병렬 worker 로 돌린 report 가 byte-for-byte 같아야 합니다. worker 수와 출력 경로처럼 결과 의미와 무관한 필드는 canonical 표현에서 제외합니다. Part 1 부터 card ID tie-break 로 확보해 온 결정성이 여기서 값을 합니다. 결정성이 없으면 “이 숫자가 재현되는가"라는 질문 자체를 할 수 없습니다.

⑤ 18개 held-out cell. 4·5·6인 x 기본·고빈도 혁명 x 3·5·10판의 조합입니다. 특정 인원수나 특정 경기 길이에서만 좋아지는 정책을 걸러내기 위한 격자입니다.

⑥ seed-cluster 신뢰구간. 같은 seed 가 여러 cell 에 등장하므로, cell 별 관측을 그대로 독립 표본처럼 세면 신뢰구간이 실제보다 좁아집니다. 같은 seed 의 여러 cell 을 먼저 평균한 뒤 seed 단위로 구간을 계산합니다.

⑦ phase 별 ablation 과 decision divergence 로그. hard 의 일부 단계(리드·팔로우·세금·혁명)만 easy 로 갈아끼운 혼합 정책을 만들어 각각 재고, 같은 공개 상태에서 두 정책의 선택이 달랐던 순간만 JSONL 로 남깁니다. 이 두 가지가 이번 진단의 핵심 도구였습니다.

승격 기준은 이렇게 고정했습니다.

단계 통과 조건
tuning 후보 승격 seed 별 paired delta 의 95% 신뢰구간 상한이 0 미만
held-out 전체 gate 95% 신뢰구간 상한이 0 미만
인원수·혁명 빈도·경기 길이별 gate 95% 신뢰구간 상한이 +0.10 순위 이하
18개 cell 각 점 추정치가 +0.10 순위 이하

전체 gate 는 “확실히 개선"을, 나머지 gate 는 “어느 조건에서도 크게 나빠지지 않음"을 봅니다. 하나의 기준으로 둘 다 요구하면 통과할 수 있는 정책이 거의 없습니다.


2. 원인 1 — 같은 이득을 두 번 칭찬했다#

Part 3 의 수 평가 함수는 플랜 개선도를 이렇게 계산했습니다.

planGain = float64(before.MinPlays-after.MinPlays) +
	0.5*(after.SafeLead-before.SafeLead) -
	float64(after.Orphans-before.Orphans)

여기에 결함이 있습니다. 손패에 12 x 29 x 1 이 있고 새 필드를 여는 상황을 보겠습니다.

  • 9 x 1 을 내면 제출 횟수가 하나 줄고, 동시에 고아 카드가 하나 사라집니다.
  • 12 x 2 를 내면 제출 횟수가 하나 줄고, 고아 카드는 그대로입니다.

이 상황에서 두 항은 같은 사건을 다른 이름으로 두 번 세고 있습니다. 고아 카드 하나를 없앤 것이 곧 제출 횟수를 하나 줄인 것이기 때문입니다. 그런데 점수식은 이를 서로 다른 이득처럼 더했고, 그 결과 카드를 한 장만 버리는 9 x 1 이 두 장을 버리는 12 x 2 보다 높은 점수를 받았습니다.

창고에 불량 재고 두 상자를 한 번에 처분할 기회가 있는데, 재고 목록에서 한 줄이 깔끔하게 지워진다는 이유로 더 비싼 물건 하나만 버린 셈입니다.

수정 방향은 적용 범위를 나누는 것 입니다.

// planGain 은 후보 c 를 냈을 때의 플랜 개선도다.
// lead 와 follow 에서 항의 구성이 다르다.
func (p *HardPolicy) planGain(v GameView, l *Ledger, c *Candidate) float64 {
	before := BestPlan(v.Hand, l)
	after := BestPlan(removeCards(v.Hand, c.Cards), l)

	gain := float64(before.MinPlays-after.MinPlays) +
		0.5*(after.SafeLead-before.SafeLead)

	if v.Table != nil {
		// follow 는 제출 장수가 테이블에 고정돼 있다.
		// 후보들 사이에 배출량 차이가 없으므로, 고아 제거 가치를 남겨 둬도
		// "많이 버리는 수" 를 밀어내지 못한다.
		gain -= float64(after.Orphans - before.Orphans)
	}
	return gain
}

Orphans 지표 자체가 틀린 것은 아닙니다. Part 2에서 정의한 대로 손패 구조를 설명하는 데는 여전히 유용합니다. 문제는 그것을 MinPlays나란히 놓고 독립적으로 보상 한 것이었습니다. 지표를 만드는 것과 그 지표를 목적 함수에 더하는 것은 다른 일입니다.


3. 원인 2 — 리드를 점수식에 맡긴 것이 가장 큰 약점이었다#

중복 보상을 제거한 뒤에도 hard 봇은 계속 이런 수를 뒀습니다.

  • easy 봇: 12 x 4 제출
  • hard 봇: 3 x 1 제출

3 은 강한 카드입니다. 한 장만 내면 받을 수 있는 사람이 거의 없으니 리드권을 지킬 확률은 높아집니다. Part 3 의 controlProb x controlValue 는 이 가능성을 크게 평가했습니다. 그러나 그 대가로 귀중한 강한 카드를 태우고 손패는 겨우 한 장 줄었습니다.

이것이 몇 판의 특이 상황이 아니었다는 것을 decision divergence 로그가 보여 줬습니다. easy 와 hard 의 선택이 달랐던 44,090개의 순간 중 21,841개가 새 필드의 첫 제출이었고, 그중 13,283개가 “easy 는 약한 다중 묶음을 내는데 hard 는 한 장만 내는” 형태였습니다. 반복되는 정책 결함이었습니다.

왜 리드에서 특히 이런 일이 벌어졌는지는 후보 공간의 모양으로 설명됩니다. 이것은 저의 해석이지만 관측된 패턴과 잘 맞습니다.

  • 팔로우에서는 제출 장수가 테이블에 고정 돼 있습니다. 후보들은 전부 같은 장수를 버리므로, 점수식이 비교하는 것은 순수하게 “어느 rank 를 태울 것인가"입니다.
  • 리드에서는 장수까지 내가 정합니다. 그런데 controlProb 는 장수가 적고 rank 가 강할수록 높아지므로, “강한 카드 한 장"이 거의 언제나 확률 최댓값을 차지합니다. 반대편에서 planGain 이 배출량을 대변해야 하는데, 한 항의 최댓값과 다른 항의 최댓값이 정면으로 충돌하는 자리에서 가중치 몇 개로 균형을 맞추는 것은 너무 섬세한 요구였습니다.

그래서 결론을 바꿨습니다. Part 3 의 “리드 선택도 같은 계산이다” 절은 철회합니다. 리드는 공통 점수식이 아니라 배출 원칙이 정합니다.

  1. 자연 카드가 있으면 가장 약한 rank 의 자연 카드 전체 를 제출합니다.
  2. 자연 카드가 없고 광대만 남았을 때 광대 한 장을 냅니다.
  3. 자연 rank 를 일부만 떼거나 광대를 붙인 리드는 기본 후보에서 제외 합니다.
  4. endgame 예외는 별도 실험으로 성능이 입증될 때만 추가합니다.
// selectLead 는 점수식이 아니라 배출 원칙으로 리드를 고른다.
// 이 함수는 Part 1 의 easyLead 와 동작이 같다. 의도된 결과다.
func (p *HardPolicy) selectLead(v GameView, l *Ledger) Decision {
	groups := groupByRank(v.Hand)

	weakest := Rank(0)
	for r := range groups {
		if r != RankJester && r > weakest {
			weakest = r // 숫자가 클수록 약하다
		}
	}
	if weakest == 0 { // 손에 광대만 남았다
		return Decision{Cards: groups[RankJester][:1]}
	}
	return Decision{Cards: groups[weakest]}
}

hard 봇의 리드 정책이 easy 봇과 같은 코드로 수렴했다 는 점이 이 절의 요지입니다. Part 1 에서 “easy 봇의 단순화"라고 소개했던 규칙이, 실제로는 복잡한 평가식보다 강한 기준선이었고 지금은 hard 봇의 guardrail 로 채택돼 있습니다.

Part 3 에서 만든 리드 후보별 점수는 지웠을까요? 아닙니다. 진단 자료로는 남겨 뒀습니다. 어떤 리드가 왜 낮게 평가됐는지 로그로 볼 수 있는 것은 여전히 유용합니다. 다만 그 점수가 운영 선택을 지배하지 않을 뿐 입니다. 평가 도구와 결정 규칙을 분리한 셈입니다.


4. 원인 3 — 세금 반환에서 약한 묶음을 지키려 했다#

Part 3 §3 의 다음 두 문장은 실제 결과와 정면으로 충돌했습니다.

rank 12 는 판 초반에 반환 카드로 나쁩니다.

12를 다섯 장 쥐고 있다면 12는 소중한 묶음이지 버릴 카드가 아닙니다.

12 열 장과 11 한 장을 쥐고 한 장을 내려줘야 하는 상황을 보겠습니다. Part 3 의 giveAwayCost + planDamage 조합은 큰 12 묶음을 깨는 것을 손해로 계산했고, 그래서 더 좋은 11 을 내보내는 선택이 나올 수 있었습니다.

이 판단이 놓친 것은 세금 반환의 기본 사실이었습니다.

  • 카드를 한 장 돌려주면 내 손패가 즉시 한 장 줄어듭니다.
  • 1211 보다 약하고, 원래 먼저 없애고 싶은 카드입니다.
  • 지나치게 큰 약한 묶음은 한 장 줄어도 여전히 큰 묶음 입니다.
  • 광대를 넘기는 위험은 별도 규칙으로 막으면 됩니다.

“그래도 11 을 버리는 쪽이 제출 횟수가 적지 않은가”#

이 예시를 보면 자연스럽게 나오는 반문이 있습니다. 계산해 보면 이렇습니다.

  • 11 을 반환 → 12 x 10 만 남음 → MinPlays 1
  • 12 를 반환 → 12 x 9, 11 x 1MinPlays 2

산술은 정확합니다. 그리고 이것이 정확히 Part 3 의 planDamage 가 하던 계산입니다. 그런데도 이 정책이 진 이유는 MinPlays 가 세는 “제출 횟수"가 실제 비용이 아니기 때문 입니다. 세 가지로 나눠 보겠습니다.

① 안 받히는 수는 리드권을 소비하지 않고 돌려줍니다. 12 x 9 를 리드로 내면 이걸 받으려면 11을 아홉 장 이상 모아야 하는데, 11은 세상에 열한 장뿐이라 사실상 불가능합니다. 전원이 패스하면 트릭이 끝나고 리드권은 마지막에 낸 사람, 즉 나에게 돌아옵니다. 곧바로 11 x 1 을 내고 나갑니다. 그 사이에 다른 누구도 카드를 한 장도 내지 못했으므로 종료 순위는 12 x 10 을 한 번에 낸 것과 같습니다. 트릭 번호만 하나 늘어날 뿐입니다.

Part 3 §1 에서 “리드권은 살아 있는 전원이 연속으로 패스해야 돌아온다"고 강조해 놓고, 정작 세금 절의 평가식은 그 규칙을 쓰지 않았습니다. 달무티에서 희소한 자원은 제출 횟수가 아니라 리드권 획득 횟수 이고, 안 받히는 수는 리드권을 쓰지 않습니다. 그래서 MinPlays 2 의 실제 비용은 1턴이 아니라 0에 수렴합니다.

12 x 10 은 리드권을 벌 수단이 0인 손패입니다. 12는 자연 rank 중 최약이므로, 이 손패로는 어떤 트릭도 받을 수 없습니다. 받을 수 없으면 트릭을 이길 수 없고, 트릭을 이길 수 없으면 리드권을 가져올 방법이 없습니다.

상황 11 반환 → 12 x 10 12 반환 → 12 x 9, 11 x 1
내가 선공 한 번에 나감 12 x 9 → 전원 패스 → 리드권 회수 → 11 x 1 → 나감 (순위 동일)
내가 선공이 아님 한 장도 못 냅니다. 받을 수 있는 수가 존재하지 않아 리드권을 벌 수단이 없음 누군가 12 x 1 로 열면 11 x 1 로 받아 트릭을 가져오고, 리드권 회수 후 12 x 9 로 나감

11 x 1 은 이 손패의 유일한 재진입 티켓 입니다. Part 3 의 continuationValue 가 재려던 값이 바로 이것인데, 세금 절은 그 개념을 쓰지 않고 planDamage 만 보고 티켓을 내다 버렸습니다. (엄밀히는 예외가 하나 있습니다. 누군가 광대만 단독으로 리드하면 그 수는 rank 13 이므로 12로 받을 수 있습니다. 손에 광대만 남은 사람이 있어야 하는 희귀 상황이라 실전 기여는 없다고 봅니다.)

③ 달무티 덱에서 고아는 구조적으로 강한 카드입니다. 위 예시는 고아가 마침 11 이라 무해해 보이지만, rank r 은 정확히 r 장뿐이므로 강한 rank 일수록 손패에 한 장만 들어옵니다. 6인 게임(손패 13장) 기준으로 재보면 이렇습니다.

rank 보유 확률 보유했을 때 딱 한 장(고아)일 확률
1 16.2% 100.0%
2 30.0% 91.8%
3 41.7% 83.9%
6 66.8% 62.6%
9 81.6% 44.8%
12 90.0% 30.8%

손패에서 카드 한 장을 무작위로 집으면 rank 1~6 일 확률이 26.9% 인데, 고아 카드 중에서 한 장을 집으면 그 확률이 49.1% 로 뜁니다. 즉 “묶음을 보존하고 고아를 내보낸다"는 규칙은 평균적으로 “강한 카드를 농노에게 헌납하라” 로 작동합니다.

정직하게 덧붙이자면, 위 예시에서 내가 대달무티(첫 트릭 선공이 확정) 라면 12 x 10 을 지키는 쪽이 아주 미세하게 낫습니다. 12 x 10 은 다 내는 순간 받히든 말든 이미 나간 것이지만, 12 x 9 는 만에 하나 받히면 11 한 장이 남기 때문입니다. 이 손패는 기각된 규칙이 방어 가능한 몇 안 되는 경우입니다. 규칙이 진 이유는 이 손패에서 져서가 아니라, 이 손패가 예외인데 규칙은 나머지 전부에도 똑같이 적용됐기 때문입니다.

리드를 고친 뒤 세금을 고치기 전의 중간 진단에서, 세금 정책만 easy 방식으로 갈아끼운 변형이 당시 hard 보다 평균 순위를 0.1517 개선 했습니다. 리드 다음으로 큰 결함이었습니다.

현재 규칙은 셋뿐입니다.

  • 광대는 반환 후보에서 제외 합니다.
  • 일반 카드 중 가장 약한 rank 부터 반환합니다.
  • 같은 rank 는 card ID 로 끊어 재현성을 확보합니다.

즉 Part 1 의 weakerFirst 규칙 그대로입니다. 여기서도 easy 규칙이 hard 의 guardrail 이 되었습니다.

기각된 고급 모델은 지우지 않았다#

giveAwayCost, 조합 완전탐색, 옌센 부등식 논의는 삭제하지 않고 “시도했지만 기각된 모델” 로 남겨 뒀습니다. 그 논리 자체는 여전히 옳습니다. 가치 함수가 비선형이면 f(E[K] + 1)E[f(K+1)] 은 실제로 다른 값이고, 2장을 내려주는 대달무티에게 조합 최소와 개별 최소가 다른 것도 맞습니다.

기각된 이유는 논리가 틀려서가 아니라 단위가 맞지 않아서 였습니다. giveAwayCost 는 “상대 묶음의 가치 증가"를 재고, planDamage 는 “내 플랜의 손상"을 재는데, 이 둘을 같은 저울에 올릴 정규화가 없었습니다. 거기에 “내 손패가 한 장 준다"는 가장 확실한 이득은 아예 항으로 들어가 있지도 않았습니다. 세 값의 크기가 제각각인 상태에서 최소화를 돌리면, 가장 큰 값을 뱉는 항이 결정을 독점합니다.

다시 도입하려면 조건은 이렇습니다.

  • 자기 손패 한 장 감소의 이득과 상대 강화 비용을 같은 단위로 정규화 한다.
  • 기존 규칙 대비 개선을 별도 held-out 으로 검증 한다.

그 전까지는 흥미로운 연구 가설이지 운영 정책이 아닙니다.


5. 목적 함수 정정 — 20판 누적 우승은 빠졌다#

Part 3 §3 은 목적 함수 후보 세 개를 놓고 “20판 누적 점수 우승” 을 골랐습니다. 그리고 그 선택 위에 GameView.Score, GameView.HandsLeft, Weights.Rivalry, leaderThreat 를 얹었습니다. (Part 1 의 GameView 정의는 이 정정에 맞게 갱신했고, Part 3 의 코드는 당시 설계 기록으로 남겨 두었습니다.)

현재 구현은 두 번째, 판 단위 평균 순위 를 목적 함수로 씁니다. 각 판에서 가능한 한 좋은 순위를 얻는 greedy 정책이고, 경기 전체의 점수 경쟁을 별도로 모델링하지 않습니다. 그래서 다음을 제거했습니다.

  • Weights.Rivalry
  • leaderThreat 분기와 HandsLeft 조건
  • 혁명 판단의 누적 점수 선두 고려
type Weights struct {
	Plan           float64 // 손패 구조 개선도
	Control        float64 // 리드권 확보 가치
	Tempo          float64 // 상대 진행 위험
	InfoLeak       float64 // 혁명 선언의 정보 노출 비용
	Hold           float64 // 상대가 낼 수 있어도 참을 성향
	BlockThreshold float64 // 차단 의무 발동 임계값
}

func (p *HardPolicy) DeclareRevolution(v GameView) bool {
	if countJesters(v.Hand) < 2 {
		return false
	}
	me := v.SeatOf(v.Me)

	if me.Title == GreaterPeon {
		return true // 대혁명. 좌석 전복의 가치가 다른 모든 항을 압도한다
	}
	if v.Round == 1 {
		return false // 첫 판에는 세금이 없다. 취소할 것이 없다
	}

	gain := taxLossIfNotCancelled(me.Title)             // ① 내 손패의 세금 손익
	gain += p.W.Control * p.blockedTribute(v, me.Title) // ② 상대 세금 차단
	cost := p.W.InfoLeak * float64(len(v.Seats)-1)      // ③ 정보 노출 비용
	return gain > cost
}

Part 3 의 혁명 절에서 살아남은 것은 ①②③ 과 대혁명 우선 분기, 그리고 “상대 손패 강화 방지는 상인 전용 항이 아니다"라는 논지입니다. 사라진 것은 ④ 누적 점수 경쟁 효과뿐입니다. 누적 경기 메타전략은 향후 확장 으로 분리해 두었습니다.

한 가지 더 정정할 것이 있습니다. Part 3 은 상인의 조건부 혁명 선언을 “봇에게서 이런 수가 나오면 상대는 이 봇이 뭔가 생각하고 있다고 느낀다"며 비중 있게 다뤘습니다. 실제 기여도는 그렇지 않았습니다. 같은 중간 진단에서 혁명 정책만 easy 방식으로 갈아끼운 ablation 은 전체 0.0022, held-out 0.0000 차이 였습니다. 혁명 판단은 이번 개선의 원인이 아니었습니다. 사람 눈에 영리해 보이는 수와 성적에 기여하는 수는 다릅니다.


6. 살아남은 것들#

무너진 것만 적으면 그림이 틀어집니다. 다음 요소는 검증에서 유지됐습니다.

유지된 요소 어디서 다뤘나
상대 손패를 넘기지 않는 GameView 공정성 경계 Part 1
패스를 일급 이벤트로 남기는 공개 행동 로그 Part 1
card ID 결정적 tie-break Part 1
rank 별 미공개 카드 장부 Part 2
패스를 확정 배제가 아닌 soft evidence 로 취급 Part 2
구조적 확실승수 판정과 좌석별 위험도 Part 2
광대 배치를 매 턴 재계산하는 플랜 Part 2
팔로우 단계의 전략적 패스 Part 3
종료 임박 좌석에 대한 차단 의무 Part 3
세금 반환에서 광대 제외 Part 1

이 중 전략적 패스 는 따로 강조할 만합니다. hard 가 easy 보다 약했다는 결과만 보면 “낼 수 있어도 참는 기능이 문제였나” 싶지만, 측정 결과는 정반대였습니다. 리드와 세금 구조를 고친 뒤 합법 수가 있으면 무조건 내도록 제한한 no-voluntary-pass 변형은 기본 hard 보다 평균 순위가 0.2013 나빠졌습니다. 이 글에 나오는 어떤 개별 수정보다도 큰 폭입니다.

즉 Part 3 §2 의 알고리즘은 그대로 유효합니다. 다만 다음 회고를 덧붙여야 합니다.

전략적 패스는 hard 의 유효한 장점이었지만, 이 기능 하나가 전체 성적을 보장하지는 않았습니다. 리드와 세금의 구조 결함이 그 이득보다 더 컸기 때문입니다. 고급 기능의 존재와 전체 정책의 강함은 별개이므로, phase 별 ablation 없이는 둘을 구분할 수 없습니다.


7. 구조를 고친 다음에야 가중치#

Part 3 §5 는 “가중치는 좌표 하강으로 맞춥니다"라고 썼습니다. 순서를 하나 추가해야 합니다. 구조 결함을 남긴 채 가중치를 돌리면 특정 seed 에서 우연히 좋아진 값을 최적이라고 부르게 됩니다. 그래서 위의 세 결함을 모두 제거한 뒤에야 tuning 을 실행했습니다.

조건은 6인 5판, easy 1개와 hard 후보 5개, 64 seed, seed 별 6회 좌석 회전, 최대 3 sweep 입니다. 축 하나당 다섯 개 값을 격자로 봅니다.

첫 sweep 의 Control 축 결과입니다.

Control 평균 순위 delta 95% 신뢰구간
0.5 -0.1563 [-0.2903, -0.0222]
0.75 -0.0906 [-0.2243, +0.0430]
1.0 (기준) 0.0000
1.25 +0.1089 [-0.0138, +0.2315]
1.5 +0.0505 [-0.0857, +0.1867]

Control 을 절반으로 낮춘 후보만 신뢰구간 상한이 0 미만이라 승격됐습니다. 이것이 이번 tuning 에서 움직인 유일한 축 입니다. Plan·Tempo·InfoLeak·Hold·BlockThreshold 는 어느 방향으로도 신뢰구간이 0을 가로질러 기본값에 머물렀고, 오히려 값을 올린 후보들이 유의하게 나빠졌습니다(Hold 를 0.35에서 0.45로 올리면 +0.2219, 신뢰구간 [+0.0832, +0.3606]).

두 번째 sweep 에서 Control 을 0.25까지 더 낮춘 후보는 점 추정치가 -0.1229 로 더 좋아 보였지만 신뢰구간이 [-0.2636, +0.0178] 이라 상한이 0을 넘어 승격되지 않았습니다. 점 추정치만 보고 채택했다면 근거 없는 값을 운영에 올렸을 것입니다. 두 번째 sweep 에서 아무 축도 움직이지 않아 좌표 하강은 2 sweep 만에 수렴했습니다.

최종 가중치입니다.

비고
Plan 1.0 기본값 유지
Control 0.5 1.0 에서 절반으로
Tempo 1.0 기본값 유지
InfoLeak 0.5 기본값 유지
Hold 0.35 기본값 유지
BlockThreshold 0.5 기본값 유지

Control 이 절반이 됐다는 것은 리드권이 쓸모없다는 뜻이 아닙니다. 손패를 줄인다는 기본 목적보다 당장의 주도권을 과하게 우선하지 않도록 영향력을 조정한 것입니다. 원인 2 에서 본 3 x 1 리드의 잔향이 팔로우 단계에도 남아 있었던 셈입니다.


8. 정식 검증 결과#

가중치를 확정한 뒤, tuning 에 쓰지 않은 seed 범위에서 cell 당 128 seed 로 held-out 을 돌렸습니다. 18개 cell x 128 seed = 2,304개 관측입니다.

구분 Hard - Easy 95% 신뢰구간
전체 -0.0707 [-0.0948, -0.0467]
4인 -0.0985 [-0.1441, -0.0528]
5인 -0.0433 [-0.0814, -0.0052]
6인 -0.0705 [-0.1078, -0.0331]
기본 혁명 빈도 -0.0684 [-0.0943, -0.0426]
고빈도 혁명 -0.0730 [-0.0973, -0.0488]
3판 -0.0619 [-0.0914, -0.0325]
5판 -0.0874 [-0.1148, -0.0599]
10판 -0.0629 [-0.0890, -0.0369]

18개 개별 cell 과 전체·marginal gate 모두 통과했습니다. 방향이 뒤집혔고, 어느 조건에서도 나빠지지 않았습니다.

전체 흐름을 한 표로 정리하면 이렇습니다.

단계 Hard - Easy 표본
최초 소규모 held-out +0.6601 tuning 4 seed / held-out cell 당 2 seed
구조 수정 후 같은 소규모 held-out +0.0112 위와 동일
정식 held-out -0.0707 tuning 64 seed / held-out cell 당 128 seed

표본 크기가 다르다는 점을 반드시 함께 읽어야 합니다. 최초 진단은 실행 경로와 소요 시간을 확인하기 위한 pilot 이었고, 그 결과는 운영 가중치의 근거로 쓰지 않았습니다. 따라서 0.6601 - (-0.0707) = 0.7308 을 엄밀한 동일 표본 효과 크기로 해석해서는 안 됩니다. 정확히 말할 수 있는 것은 둘입니다.

  • 같은 소규모 조건 에서 기존 열세 0.6601 중 약 0.6489 가 구조 수정만으로 사라졌습니다.
  • 정식 조건 에서 hard 는 easy 보다 평균 0.0707 등 앞섰고, 신뢰구간 상한이 0 미만입니다.

그리고 솔직하게 덧붙이자면, 0.0707 등은 작은 차이 입니다. 사람이 몇 판 붙어 보고 “hard 가 확실히 세다"고 느낄 만한 폭이 아닙니다. Part 3 이 그렸던 “카운팅하고 참을 줄 아는 봇"의 그림에 비하면 소박한 결과이고, 이것이 현재의 정직한 위치입니다.


9. Part 3 정정 요약#

Part 3 의 주장 현재 결론 근거
리드도 후보별 공통 점수식으로 고른다 철회. 가장 약한 자연 rank 묶음 전체를 내는 guardrail 이 우선 lead divergence 21,841건 중 13,283건이 “다중 묶음 대 1장”
planGain 에서 MinPlaysOrphans 를 함께 보상한다 범위 분리. 리드에서는 Orphans 항 제거, 팔로우에서만 유지 12 x 2 대신 9 x 1 을 고르는 반례
rank 12 는 판 초반에 반환 카드로 나쁘다 철회. 광대를 뺀 가장 약한 카드부터 반환 세금 ablation 0.1517 개선
giveAwayCost 조합 최소화로 반환 카드를 고른다 기각. 단위 정규화와 별도 held-out 검증 후에나 재도입 위와 같음
목적 함수는 20판 누적 점수 우승 변경. 판 단위 평균 순위 현재 구현의 정책 범위
Rivalry·leaderThreat·HandsLeft 로 경쟁자를 견제한다 제거. 향후 확장으로 분리 위와 같음
상인의 조건부 혁명 선언이 인상적인 차별점 축소. 유지하되 성능 기여는 사실상 0 혁명 ablation 전체 0.0022, held-out 0.0000
전략적 패스가 hard 의 핵심 유지. 실제로 가장 큰 단일 기여 no-voluntary-pass0.2013 나빠짐
검증은 미러 매치 + held-out + 좌표 하강 유지·확장. 양방향 focal, label swap, 18 cell, seed-cluster CI 추가

마치며#

네 가지를 배웠습니다.

복잡한 봇이 자동으로 강한 봇은 아닙니다. hard 봇은 easy 봇보다 더 많은 공개 정보를 쓰고 더 많은 후보를 계산했습니다. 그런데 평가 항이 겹치거나 부차적 목표를 과대평가하면, 계산량이 많을수록 잘못된 결정을 더 일관되게 내립니다. easy 봇의 실수는 산발적이지만 hard 봇의 실수는 체계적이었습니다. Part 2 의 마지막에 이 문장을 넣었어야 했습니다. 더 정확한 장부는 더 좋은 판단 재료일 뿐입니다.

기본 원칙은 고급 전략의 안전망이어야 합니다. 약한 카드를 여러 장 버리고 강한 카드와 광대를 아끼는 규칙은 단순하지만 강했습니다. 고급 전략은 이 원칙을 대체 하는 것이 아니라, 예외가 정말 이득임을 입증했을 때만 넘어설 수 있어야 합니다. 지금 hard 봇의 리드와 세금 반환이 easy 봇과 같은 코드인 것은 후퇴가 아니라 경계 설정입니다.

가중치보다 구조를 먼저 고쳐야 합니다. 중복 보상과 잘못된 후보 경계를 남긴 채 가중치만 돌리면, 특정 seed 에 맞춘 숫자가 나옵니다. 구조를 고친 뒤 돌린 좌표 하강에서는 여섯 축 중 다섯 개가 기본값에 머물렀습니다. 튜닝으로 벌 수 있는 폭은 원래 그리 크지 않았고, 성적을 되돌린 것은 가중치가 아니라 구조 수정이었습니다.

평가 하네스가 본체입니다. 이번 작업에서 실제로 가치를 만든 것은 새 알고리즘이 아니라 phase 별 ablation 과 decision divergence 로그였습니다. “hard 가 약하다"는 결과만으로는 아무것도 고칠 수 없고, “새 필드 첫 제출에서 13,283번 한 장만 냈다"는 관측이 있어야 고칠 수 있습니다. 봇을 만들기 전에 봇을 재는 장치부터 만드는 편이 결국 빠릅니다.

Part 3 의 마지막 문단에서 “봇이 강해지는 방향은 정보를 더 주는 쪽이 아니라, 같은 정보를 더 성실하게 세는 쪽이어야 한다"고 썼습니다. 그 문장은 지금도 유효하다고 생각합니다. 다만 한 줄을 덧붙이겠습니다. 성실하게 센 값을 어떻게 합칠지는 세는 것과 완전히 다른 문제이고, 그건 재봐야만 알 수 있습니다.


References#