Skip to content

Commit 90fd2c4

Browse files
Merge pull request #247 from CSSLab/codex/maia-winrate-play
Codex/maia winrate play
2 parents 09a8fe3 + cabe632 commit 90fd2c4

5 files changed

Lines changed: 285 additions & 19 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,24 @@ The project uses automated code formatting and linting to maintain consistency:
187187

188188
This ensures consistent interpretation where positive values always represent an advantage for the player whose turn it is. The conversion logic is documented in `src/hooks/useStockfishEngine/engine.ts:138-155`.
189189

190+
#### Maia Value-Head Debug Logging
191+
192+
When testing the value-head play mode (`/play/maia` with `maiaMoveSelectionMode=value_head`), you can enable an opt-in console table that prints each legal move with Maia's evaluated win probability.
193+
194+
Enable in browser DevTools:
195+
196+
```js
197+
localStorage.setItem('maia.valueHeadDebug', 'true')
198+
```
199+
200+
Disable:
201+
202+
```js
203+
localStorage.removeItem('maia.valueHeadDebug')
204+
```
205+
206+
With the flag enabled, each Maia move prints a compact table (`san`, `move`, `maiaWinProb`) in the browser console. This is local-only and does not change behavior unless the flag is set in localStorage.
207+
190208
#### State Management Architecture
191209

192210
The platform uses a Context + Custom Hooks pattern:

src/components/Common/PlaySetupModal.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useCallback, useContext, useState } from 'react'
77

88
import {
99
Color,
10+
MaiaMoveSelectionMode,
1011
PlayType,
1112
TimeControl,
1213
TimeControlOptionNames,
@@ -80,6 +81,7 @@ interface Props {
8081
maiaVersion?: string
8182
isBrain?: boolean
8283
sampleMoves?: boolean
84+
maiaMoveSelectionMode?: MaiaMoveSelectionMode
8385
simulateMaiaTime?: boolean
8486
startFen?: string
8587
}
@@ -105,6 +107,10 @@ export const PlaySetupModal: React.FC<Props> = (props: Props) => {
105107
const [sampleMoves, setSampleMoves] = useState<boolean>(
106108
props.sampleMoves || true,
107109
)
110+
const [maiaMoveSelectionMode, setMaiaMoveSelectionMode] =
111+
useState<MaiaMoveSelectionMode>(
112+
props.maiaMoveSelectionMode || 'move_matching',
113+
)
108114
const [simulateMaiaTime, setSimulateMaiaTime] = useState<boolean>(
109115
props.simulateMaiaTime !== undefined ? props.simulateMaiaTime : true,
110116
)
@@ -174,6 +180,7 @@ export const PlaySetupModal: React.FC<Props> = (props: Props) => {
174180
maiaVersion: maiaVersion,
175181
timeControl: timeControl,
176182
sampleMoves: sampleMoves,
183+
maiaMoveSelectionMode: maiaMoveSelectionMode,
177184
simulateMaiaTime: simulateMaiaTime,
178185
startFen: fen,
179186
},
@@ -188,6 +195,7 @@ export const PlaySetupModal: React.FC<Props> = (props: Props) => {
188195
timeControl: timeControl,
189196
isBrain: isBrain,
190197
sampleMoves: sampleMoves,
198+
maiaMoveSelectionMode: maiaMoveSelectionMode,
191199
simulateMaiaTime: simulateMaiaTime,
192200
startFen: fen,
193201
},
@@ -202,6 +210,7 @@ export const PlaySetupModal: React.FC<Props> = (props: Props) => {
202210
maiaVersion,
203211
timeControl,
204212
sampleMoves,
213+
maiaMoveSelectionMode,
205214
simulateMaiaTime,
206215
fen,
207216
isBrain,
@@ -455,6 +464,35 @@ export const PlaySetupModal: React.FC<Props> = (props: Props) => {
455464
</div>
456465
</div>
457466

467+
{props.playType == 'againstMaia' ? (
468+
<div>
469+
<label
470+
htmlFor="maia-play-style-select"
471+
className="mb-1 block text-sm font-medium text-primary"
472+
>
473+
Maia play style:
474+
</label>
475+
<div id="maia-play-style-select">
476+
<OptionSelect
477+
options={['move_matching', 'value_head']}
478+
labels={['Human move-match', 'Best win rate']}
479+
selected={maiaMoveSelectionMode}
480+
onChange={(selected) =>
481+
setMaiaMoveSelectionMode(
482+
selected as MaiaMoveSelectionMode,
483+
)
484+
}
485+
selectedClassName="border-human-4 bg-human-4 text-white hover:bg-human-4/90"
486+
/>
487+
</div>
488+
<p className="mt-2 text-xs text-secondary">
489+
{maiaMoveSelectionMode === 'move_matching'
490+
? 'Classic Maia: chooses the move it expects a human to play.'
491+
: 'Local Maia 2 mode: evaluates every legal move and picks the one with the best perceived win rate.'}
492+
</p>
493+
</div>
494+
) : null}
495+
458496
<div className="flex items-center gap-2">
459497
<input
460498
type="checkbox"

src/hooks/usePlayController/useVsMaiaController.ts

Lines changed: 166 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect } from 'react'
1+
import { useCallback, useContext, useEffect } from 'react'
22
import { Chess } from 'chess.ts'
33
import { PlayGameConfig } from 'src/types'
44
import { backOff } from 'exponential-backoff'
@@ -7,6 +7,26 @@ import { usePlayController } from 'src/hooks/usePlayController'
77
import { fetchGameMove, logGameMove, fetchPlayPlayerStats } from 'src/api'
88
import { useSound } from 'src/hooks/useSound'
99
import { safeUpdateRating } from 'src/lib/ratingUtils'
10+
import { MaiaEngineContext } from 'src/contexts'
11+
12+
const MAIA_VALUE_HEAD_DEBUG_KEY = 'maia.valueHeadDebug'
13+
14+
const isTruthy = (value: string | null | undefined): boolean => {
15+
if (!value) return false
16+
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase())
17+
}
18+
19+
const isMaiaValueHeadDebugEnabled = (): boolean => {
20+
if (typeof window === 'undefined') {
21+
return false
22+
}
23+
24+
try {
25+
return isTruthy(window.localStorage.getItem(MAIA_VALUE_HEAD_DEBUG_KEY))
26+
} catch {
27+
return false
28+
}
29+
}
1030

1131
const playStatsLoader = async () => {
1232
const stats = await fetchPlayPlayerStats()
@@ -25,6 +45,126 @@ export const useVsMaiaPlayController = (
2545
const controller = usePlayController(id, playGameConfig)
2646
const [stats, incrementStats, updateRating] = useStats(playStatsLoader)
2747
const { playMoveSound } = useSound()
48+
const maiaEngine = useContext(MaiaEngineContext)
49+
const valueHeadModel =
50+
playGameConfig.maiaMoveSelectionMode === 'value_head'
51+
? maiaEngine.maia
52+
: null
53+
const valueHeadStatus =
54+
playGameConfig.maiaMoveSelectionMode === 'value_head'
55+
? maiaEngine.status
56+
: null
57+
const playerRatingForValueHead = stats.rating ?? 1500
58+
const valueHeadDebugEnabled = isMaiaValueHeadDebugEnabled()
59+
60+
const selectValueHeadMove = useCallback(async () => {
61+
if (
62+
!controller.currentNode ||
63+
valueHeadStatus !== 'ready' ||
64+
!valueHeadModel
65+
) {
66+
if (valueHeadDebugEnabled) {
67+
console.log('[Maia value-head debug] selector unavailable', {
68+
hasCurrentNode: !!controller.currentNode,
69+
valueHeadStatus,
70+
hasModel: !!valueHeadModel,
71+
})
72+
}
73+
return null
74+
}
75+
76+
const currentFen = controller.currentNode.fen
77+
const chess = new Chess(currentFen)
78+
const legalMoves = chess.moves({ verbose: true }) as Array<{
79+
from: string
80+
to: string
81+
promotion?: string
82+
}>
83+
84+
if (legalMoves.length === 0) {
85+
return null
86+
}
87+
88+
const candidateMoves = legalMoves.map(
89+
(move) => `${move.from}${move.to}${move.promotion ?? ''}`,
90+
)
91+
const candidateBoards = candidateMoves.map((moveUci) => {
92+
const board = new Chess(currentFen)
93+
board.move(moveUci, { sloppy: true })
94+
return board.fen()
95+
})
96+
97+
const maiaRating = parseInt(
98+
playGameConfig.maiaVersion.replace('maia_kdd_', ''),
99+
10,
100+
)
101+
const modelElo = Number.isNaN(maiaRating) ? 1500 : maiaRating
102+
103+
// After Maia makes a candidate move, it is the human's turn.
104+
// Maia's value head conditions on the side to move as elo_self,
105+
// so the resulting boards must use the human as elo_self and Maia as elo_oppo.
106+
const { result } = await valueHeadModel.batchEvaluate(
107+
candidateBoards,
108+
Array(candidateBoards.length).fill(playerRatingForValueHead),
109+
Array(candidateBoards.length).fill(modelElo),
110+
)
111+
112+
const maiaIsWhite = controller.player === 'black'
113+
let bestMove = candidateMoves[0]
114+
let bestScore = maiaIsWhite ? result[0].value : 1 - result[0].value
115+
116+
for (let index = 1; index < candidateMoves.length; index++) {
117+
const whiteWinProb = result[index].value
118+
const maiaWinProb = maiaIsWhite ? whiteWinProb : 1 - whiteWinProb
119+
120+
if (maiaWinProb > bestScore) {
121+
bestMove = candidateMoves[index]
122+
bestScore = maiaWinProb
123+
}
124+
}
125+
126+
if (valueHeadDebugEnabled) {
127+
const candidateSummaries = candidateMoves
128+
.map((moveUci, index) => {
129+
const moveResult = result[index]
130+
const whiteWinProb = moveResult.value
131+
const maiaWinProb = maiaIsWhite ? whiteWinProb : 1 - whiteWinProb
132+
const board = new Chess(currentFen)
133+
const moveObj = board.move(moveUci, { sloppy: true })
134+
135+
return {
136+
move: moveUci,
137+
san: moveObj?.san ?? moveUci,
138+
maiaWinProb: Number(maiaWinProb.toFixed(4)),
139+
}
140+
})
141+
.sort((a, b) => b.maiaWinProb - a.maiaWinProb)
142+
143+
console.groupCollapsed(
144+
`[Maia value-head debug] ${playGameConfig.maiaVersion} selected ${bestMove}`,
145+
)
146+
console.table(candidateSummaries)
147+
console.groupEnd()
148+
}
149+
150+
const estimatedDelaySeconds = Math.min(
151+
3,
152+
0.35 + legalMoves.length * 0.04 + Math.random() * 0.25,
153+
)
154+
155+
return {
156+
top_move: bestMove,
157+
move_delay: estimatedDelaySeconds,
158+
}
159+
}, [
160+
controller.currentNode,
161+
controller.player,
162+
playGameConfig.maiaVersion,
163+
playerRatingForValueHead,
164+
valueHeadModel,
165+
valueHeadStatus,
166+
valueHeadDebugEnabled,
167+
])
28168

29169
const makePlayerMove = async (moveUci: string) => {
30170
const moveTime = controller.updateClock()
@@ -48,20 +188,28 @@ export const useVsMaiaPlayController = (
48188
? parseInt(controller.timeControl.split('+')[0]) * 60
49189
: 0
50190

51-
const maiaMoves = await backOff(
52-
() =>
53-
fetchGameMove(
54-
controller.moveList,
55-
playGameConfig.maiaVersion,
56-
playGameConfig.startFen,
57-
null,
58-
simulateMaiaTime ? initialClock : 0,
59-
simulateMaiaTime ? maiaClock : 0,
60-
),
61-
{
62-
jitter: 'full',
63-
},
64-
)
191+
const maiaMoves =
192+
playGameConfig.maiaMoveSelectionMode === 'value_head'
193+
? await selectValueHeadMove()
194+
: await backOff(
195+
() =>
196+
fetchGameMove(
197+
controller.moveList,
198+
playGameConfig.maiaVersion,
199+
playGameConfig.startFen,
200+
null,
201+
simulateMaiaTime ? initialClock : 0,
202+
simulateMaiaTime ? maiaClock : 0,
203+
),
204+
{
205+
jitter: 'full',
206+
},
207+
)
208+
209+
if (!maiaMoves?.top_move) {
210+
return
211+
}
212+
65213
const nextMove = maiaMoves['top_move']
66214
const moveDelay = maiaMoves['move_delay']
67215

@@ -107,8 +255,11 @@ export const useVsMaiaPlayController = (
107255
controller.game.termination,
108256
controller.moveList.length,
109257
playGameConfig.maiaVersion,
258+
playGameConfig.maiaMoveSelectionMode,
110259
playGameConfig.startFen,
111260
simulateMaiaTime,
261+
selectValueHeadMove,
262+
valueHeadStatus,
112263
])
113264

114265
useEffect(() => {

0 commit comments

Comments
 (0)