Skip to content

Commit 15155d0

Browse files
feat: incorporate new board description generator + fix lint errors
1 parent d0c13e5 commit 15155d0

2 files changed

Lines changed: 44 additions & 218 deletions

File tree

src/hooks/useAnalysisController/useBoardDescription.ts

Lines changed: 24 additions & 199 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
StockfishEvaluation,
77
} from 'src/types'
88
import { MAIA_MODELS } from './constants'
9+
import { describePosition } from './useDescriptionGenerator'
910

1011
type ColorSanMapping = {
1112
[move: string]: {
@@ -33,205 +34,29 @@ export const useBoardDescription = (
3334
return ''
3435
}
3536

36-
const isBlackTurn = currentNode.turn === 'b'
37-
const playerColor = isBlackTurn ? 'Black' : 'White'
38-
const opponent = isBlackTurn ? 'White' : 'Black'
39-
const stockfish = moveEvaluation.stockfish
40-
const maia = moveEvaluation.maia
41-
const topMaiaMove = Object.entries(maia.policy).sort(
42-
(a, b) => b[1] - a[1],
43-
)[0]
44-
45-
const topStockfishMoves = Object.entries(stockfish.cp_vec)
46-
.sort((a, b) => (isBlackTurn ? a[1] - b[1] : b[1] - a[1]))
47-
.slice(0, 3)
48-
49-
const cp = stockfish.model_optimal_cp
50-
const absCP = Math.abs(cp)
51-
const cpAdvantage = cp > 0 ? 'White' : cp < 0 ? 'Black' : 'Neither player'
52-
const topStockfishMove = topStockfishMoves[0]
53-
54-
// Calculate winrate for more nuanced description (using centipawn to approximate winrate)
55-
// Formula approximates winrate from CP value: 1/(1+10^(-cp/400))
56-
const rawWinrate = 1 / (1 + Math.pow(10, -cp / 400))
57-
const winrate = Math.max(0.01, Math.min(0.99, rawWinrate)) // Clamp between 1% and 99%
58-
const toMoveWinrate = isBlackTurn ? 1 - winrate : winrate
59-
const toMoveAdvantage = toMoveWinrate > 0.5
60-
61-
// Check if top Maia move matches top Stockfish move
62-
const maiaMatchesStockfish = topMaiaMove[0] === topStockfishMove[0]
63-
64-
// Get top few Maia moves and their cumulative probability
65-
const top3MaiaMoves = Object.entries(maia.policy)
66-
.sort((a, b) => b[1] - a[1])
67-
.slice(0, 3)
68-
const top3MaiaProbability =
69-
top3MaiaMoves.reduce((sum, [_, prob]) => sum + prob, 0) * 100
70-
71-
// Get second best moves to analyze move clarity
72-
const secondBestMaiaMove = top3MaiaMoves[1]
73-
const secondBestMaiaProbability = secondBestMaiaMove
74-
? secondBestMaiaMove[1] * 100
75-
: 0
76-
77-
// Calculate spread between first and second-best moves
78-
const probabilitySpread = topMaiaMove[1] * 100 - secondBestMaiaProbability
79-
80-
// Get move classifications
81-
const blunderProbability = blunderMeter.blunderMoves.probability
82-
const okProbability = blunderMeter.okMoves.probability
83-
const goodProbability = blunderMeter.goodMoves.probability
84-
85-
// Check for patterns in stockfish evaluation
86-
const stockfishTop3Spread =
87-
topStockfishMoves.length > 2
88-
? Math.abs(topStockfishMoves[0][1] - topStockfishMoves[2][1])
89-
: 0
90-
91-
// Get move spreads to detect sharp positions
92-
const moveCpSpread = Object.values(stockfish.cp_relative_vec).reduce(
93-
(maxDiff, cp, _, arr) => {
94-
const min = Math.min(...arr)
95-
const max = Math.max(...arr)
96-
return Math.max(maxDiff, max - min)
97-
},
98-
0,
99-
)
100-
101-
// Calculate position complexity based on distribution of move quality
102-
const isPositionComplicated =
103-
(blunderProbability > 30 && okProbability > 20 && goodProbability < 50) ||
104-
moveCpSpread > 300 ||
105-
stockfishTop3Spread > 100
106-
107-
// Check for tactical position
108-
const isTacticalPosition = moveCpSpread > 500 || stockfishTop3Spread > 150
109-
110-
// Check if there's a clear best move
111-
const topMaiaProbability = topMaiaMove[1] * 100
112-
const isClearBestMove = topMaiaProbability > 70 || probabilitySpread > 40
113-
114-
// Check if there are multiple equally good moves
115-
const hasMultipleGoodMoves =
116-
top3MaiaProbability > 75 && topMaiaProbability < 50
117-
118-
// Calculate agreement between Maia rating levels
119-
const maiaModelsAgree = Object.entries(currentNode.analysis.maia || {})
120-
.filter(([key]) => MAIA_MODELS.includes(key))
121-
.every(([_, evaluation]) => {
122-
const topMove = Object.entries(evaluation.policy).sort(
123-
(a, b) => b[1] - a[1],
124-
)[0]
125-
return topMove && topMove[0] === topMaiaMove[0]
126-
})
127-
128-
// Check if evaluation is decisive
129-
const isDecisiveAdvantage = absCP > 300
130-
const isOverwhelming = absCP > 800
131-
132-
// Check for high blunder probability
133-
const isBlunderProne = blunderProbability > 50
134-
const isVeryBlunderProne = blunderProbability > 70
135-
136-
// Check if there's forced play
137-
const isForcedPlay = topMaiaProbability > 85 && maiaMatchesStockfish
138-
139-
// Check if position is balanced but with complexity
140-
const isBalancedButComplex = absCP < 50 && isPositionComplicated
141-
142-
// Generate descriptions
143-
let evaluation = ''
144-
let suggestion = ''
145-
146-
// Evaluation description that considers whose turn it is
147-
if (isOverwhelming) {
148-
if (cpAdvantage === playerColor) {
149-
evaluation = `${playerColor} has a completely winning position with a ${Math.round(toMoveWinrate * 100)}% win probability.`
150-
} else {
151-
evaluation = `${playerColor} faces a nearly lost position with only a ${Math.round(toMoveWinrate * 100)}% win probability.`
152-
}
153-
} else if (cp === 0) {
154-
evaluation = isBalancedButComplex
155-
? 'The position is balanced but filled with complications.'
156-
: 'The position is completely equal.'
157-
} else if (absCP < 30) {
158-
evaluation = `The evaluation is almost perfectly balanced with only the slightest edge ${cpAdvantage === playerColor ? 'for' : 'against'} ${playerColor}.`
159-
} else if (absCP < 80) {
160-
if (cpAdvantage === playerColor) {
161-
evaluation = `${playerColor} has a slight but tangible advantage with a win probability of ${Math.round(toMoveWinrate * 100)}%.`
162-
} else {
163-
evaluation = `${playerColor} faces a slight disadvantage with a win probability of ${Math.round(toMoveWinrate * 100)}%.`
37+
const fen = currentNode.fen
38+
const whiteToMove = currentNode.turn === 'w'
39+
40+
const stockfishEvals = moveEvaluation.stockfish.cp_vec
41+
const maiaEvals: Record<string, number[]> = {}
42+
const allMaiaAnalysis = currentNode.analysis.maia || {}
43+
44+
Object.keys(moveEvaluation.maia.policy).forEach((move) => {
45+
maiaEvals[move] = new Array(MAIA_MODELS.length).fill(0)
46+
})
47+
48+
MAIA_MODELS.forEach((model, index) => {
49+
const modelAnalysis = allMaiaAnalysis[model]
50+
if (modelAnalysis?.policy) {
51+
Object.entries(modelAnalysis.policy).forEach(([move, probability]) => {
52+
if (!maiaEvals[move]) {
53+
maiaEvals[move] = new Array(MAIA_MODELS.length).fill(0)
54+
}
55+
maiaEvals[move][index] = probability
56+
})
16457
}
165-
} else if (absCP < 150) {
166-
if (cpAdvantage === playerColor) {
167-
evaluation = `${playerColor} has a clear positional advantage that could be decisive with careful play.`
168-
} else {
169-
evaluation = `${playerColor} must play accurately as ${opponent} holds a clear positional advantage.`
170-
}
171-
} else if (absCP < 300) {
172-
if (cpAdvantage === playerColor) {
173-
evaluation = `${playerColor} has a significant advantage (${Math.round(toMoveWinrate * 100)}% win rate) that should be convertible with proper technique.`
174-
} else {
175-
evaluation = `${playerColor} faces a difficult position as ${opponent} has a significant advantage (${Math.round((1 - toMoveWinrate) * 100)}% win rate).`
176-
}
177-
} else if (absCP < 500) {
178-
if (cpAdvantage === playerColor) {
179-
evaluation = `${playerColor} is winning and only needs to avoid major blunders to convert.`
180-
} else {
181-
evaluation = `${playerColor} is in serious trouble and needs to find resilient defensive moves.`
182-
}
183-
} else {
184-
if (cpAdvantage === playerColor) {
185-
evaluation = `${playerColor} has a completely winning position with a ${Math.round(toMoveWinrate * 100)}% win probability.`
186-
} else {
187-
evaluation = `${playerColor} faces a nearly lost position with only a ${Math.round(toMoveWinrate * 100)}% win probability.`
188-
}
189-
}
190-
191-
// Suggestion/description of move quality
192-
if (isVeryBlunderProne) {
193-
suggestion = `This critical position is extremely treacherous with a ${blunderProbability.toFixed(0)}% chance of ${playerColor} making a significant error.`
194-
} else if (isBlunderProne && isTacticalPosition) {
195-
suggestion = `The sharp tactical nature of this position creates many opportunities for mistakes (${blunderProbability.toFixed(0)}% blunder chance).`
196-
} else if (isBlunderProne) {
197-
suggestion = `This position is quite treacherous with ${blunderProbability.toFixed(0)}% chance of ${playerColor} making a significant mistake.`
198-
} else if (isForcedPlay) {
199-
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
200-
suggestion = `${playerColor} must play ${moveSan}, as all other moves lead to a significantly worse position.`
201-
} else if (isTacticalPosition && maiaMatchesStockfish) {
202-
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
203-
suggestion = `The tactical complexity demands precision, with ${moveSan} being the only move that maintains the balance.`
204-
} else if (isPositionComplicated && hasMultipleGoodMoves) {
205-
suggestion = `This complex position offers several equally promising continuations for ${playerColor}.`
206-
} else if (isPositionComplicated) {
207-
suggestion = `This is a complex position requiring careful calculation of the many reasonable options.`
208-
} else if (isClearBestMove && maiaMatchesStockfish && maiaModelsAgree) {
209-
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
210-
suggestion = `Players of all levels agree ${moveSan} stands out as clearly best in this position.`
211-
} else if (isClearBestMove && maiaMatchesStockfish) {
212-
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
213-
suggestion = `${playerColor} should play ${moveSan}, which both human intuition and concrete calculation confirm as best.`
214-
} else if (isClearBestMove && maiaModelsAgree) {
215-
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
216-
suggestion = `Human players at all levels strongly prefer ${moveSan} (${topMaiaProbability.toFixed(0)}%), though the engine suggests otherwise.`
217-
} else if (isClearBestMove) {
218-
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
219-
suggestion = `Maia strongly suggests ${moveSan} (${topMaiaProbability.toFixed(0)}% likely), though Stockfish calculates a different approach.`
220-
} else if (goodProbability > 80) {
221-
suggestion = `This is a forgiving position where almost any reasonable move by ${playerColor} maintains the evaluation.`
222-
} else if (goodProbability > 60) {
223-
suggestion = `Most moves ${playerColor} is likely to consider will maintain the current position assessment.`
224-
} else if (maiaMatchesStockfish) {
225-
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
226-
suggestion = `Both human intuition and engine calculation agree that ${moveSan} is the best continuation here.`
227-
} else if (hasMultipleGoodMoves) {
228-
suggestion = `${playerColor} has several equally strong options, suggesting flexibility in planning.`
229-
} else if (top3MaiaProbability < 50) {
230-
suggestion = `This unusual position creates difficulties for human calculation, with no clearly favored continuation.`
231-
} else {
232-
suggestion = `There are several reasonable options for ${playerColor} to consider in this position.`
233-
}
58+
})
23459

235-
return `${evaluation} ${suggestion}`
236-
}, [currentNode, moveEvaluation, blunderMeter, colorSanMapping])
60+
return describePosition(fen, stockfishEvals, maiaEvals, whiteToMove)
61+
}, [currentNode, moveEvaluation])
23762
}

src/hooks/useAnalysisController/useDescriptionGenerator.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Chess } from 'chess.js'
1+
import { Chess, PieceSymbol } from 'chess.ts'
22

33
type StockfishEvals = Record<string, number>
44
type MaiaEvals = Record<string, number[]>
@@ -19,27 +19,27 @@ export function describePosition(
1919
sf: StockfishEvals,
2020
maia: MaiaEvals,
2121
whiteToMove: boolean,
22-
eps = EPS
22+
eps = EPS,
2323
): string {
2424
const chess = new Chess(fen)
2525

2626
const legal = new Set<string>()
2727
chess
2828
.moves({ verbose: true })
29-
.forEach(m => legal.add(m.from + m.to + (m.promotion ?? '')))
29+
.forEach((m) => legal.add(m.from + m.to + (m.promotion ?? '')))
3030

31-
const moves = Object.keys(sf).filter(m => legal.has(m))
31+
const moves = Object.keys(sf).filter((m) => legal.has(m))
3232
if (!moves.length) return 'No legal moves available.'
3333

3434
const seval: Record<string, number> = {}
35-
moves.forEach(m => {
35+
moves.forEach((m) => {
3636
seval[m] = (whiteToMove ? 1 : 1) * sf[m]
3737
})
3838

3939
const opt = moves.reduce((a, b) => (seval[a] > seval[b] ? a : b))
4040
const { w: wOpt, d: dOpt } = wdl(seval[opt])
4141

42-
const good = moves.filter(m => {
42+
const good = moves.filter((m) => {
4343
const { w, d } = wdl(seval[m])
4444
return Math.abs(w - wOpt) <= eps && Math.abs(d - dOpt) <= eps
4545
})
@@ -52,13 +52,15 @@ export function describePosition(
5252
const from = uci.slice(0, 2)
5353
const to = uci.slice(2, 4)
5454
const promotion = uci.length > 4 ? uci[4] : undefined
55-
const mv = chess.move({ from, to, promotion })
55+
const mv = chess.move({ from, to, promotion: promotion as PieceSymbol })
5656
const san = mv?.san ?? uci
5757
chess.undo()
5858
return san
5959
}
6060

61-
const bestGoodMoves = [...good].sort((a, b) => seval[b] - seval[a]).slice(0, 3)
61+
const bestGoodMoves = [...good]
62+
.sort((a, b) => seval[b] - seval[a])
63+
.slice(0, 3)
6264
const moveList = bestGoodMoves.map(uciToSan).join(', ')
6365
const bestMoveSan = uciToSan(opt)
6466

@@ -79,7 +81,7 @@ export function describePosition(
7981

8082
for (let lvl = 0; lvl < 9; lvl++) {
8183
const probs = moves
82-
.map(m => [maia[m]?.[lvl] ?? 0, m] as [number, string])
84+
.map((m) => [maia[m]?.[lvl] ?? 0, m] as [number, string])
8385
.sort((a, b) => b[0] - a[0])
8486

8587
const [p1, m1] = probs[0]
@@ -112,15 +114,15 @@ export function describePosition(
112114
setTier === 0
113115
? 'hard for human players to find'
114116
: setTier === 1
115-
? 'findable for skilled players'
116-
: 'straightforward for players across skill levels to find'
117+
? 'findable for skilled players'
118+
: 'straightforward for players across skill levels to find'
117119

118120
let phrBest =
119121
optTier === 0
120122
? 'hard for human players to find'
121123
: optTier === 1
122-
? 'findable for skilled players'
123-
: 'straightforward for players across skill levels to find'
124+
? 'findable for skilled players'
125+
: 'straightforward for players across skill levels to find'
124126

125127
if (optTier === 1 && optTier < setTier) phrBest = 'only ' + phrBest
126128

@@ -130,20 +132,19 @@ export function describePosition(
130132
let temptText = ''
131133
const hasTempting = setLevels > 0 && temptLevels > setLevels / 2
132134
if (hasTempting) {
133-
const topTemptUci =
134-
Object.entries(temptCount).sort((a, b) => b[1] - a[1])[0]?.[0]
135+
const topTemptUci = Object.entries(temptCount).sort(
136+
(a, b) => b[1] - a[1],
137+
)[0]?.[0]
135138
const temptSan = topTemptUci ? uciToSan(topTemptUci) : ''
136139
temptText =
137140
temptSan !== ''
138141
? ` There are also tempting alternatives, such as ${temptSan}.`
139142
: ' There are also tempting alternatives.'
140143
if (!(optTier < setTier) && setTier == 2) {
141-
temptText = ` However, there are tempting alternatives, such as ${temptSan}.`
142-
}
143-
144+
temptText = ` However, there are tempting alternatives, such as ${temptSan}.`
145+
}
144146
}
145147

146-
147148
if (nGood === 1) {
148149
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${temptText}`
149150
}

0 commit comments

Comments
 (0)