Skip to content

Commit ec1c8b6

Browse files
Merge pull request #8 from CSSLab/BasedBerry-descriptionUpdate
Adding description generation file
2 parents 27717b5 + 15155d0 commit ec1c8b6

2 files changed

Lines changed: 181 additions & 199 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
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { Chess, PieceSymbol } from 'chess.ts'
2+
3+
type StockfishEvals = Record<string, number>
4+
type MaiaEvals = Record<string, number[]>
5+
6+
const A = 1
7+
const B = 0.8
8+
const EPS = 0.08
9+
10+
const winRate = (p: number) => 1 / (1 + Math.exp(-(p - A) / B))
11+
const wdl = (p: number) => {
12+
const w = winRate(p)
13+
const l = winRate(-p)
14+
return { w, d: 1 - w - l }
15+
}
16+
17+
export function describePosition(
18+
fen: string,
19+
sf: StockfishEvals,
20+
maia: MaiaEvals,
21+
whiteToMove: boolean,
22+
eps = EPS,
23+
): string {
24+
const chess = new Chess(fen)
25+
26+
const legal = new Set<string>()
27+
chess
28+
.moves({ verbose: true })
29+
.forEach((m) => legal.add(m.from + m.to + (m.promotion ?? '')))
30+
31+
const moves = Object.keys(sf).filter((m) => legal.has(m))
32+
if (!moves.length) return 'No legal moves available.'
33+
34+
const seval: Record<string, number> = {}
35+
moves.forEach((m) => {
36+
seval[m] = (whiteToMove ? 1 : 1) * sf[m]
37+
})
38+
39+
const opt = moves.reduce((a, b) => (seval[a] > seval[b] ? a : b))
40+
const { w: wOpt, d: dOpt } = wdl(seval[opt])
41+
42+
const good = moves.filter((m) => {
43+
const { w, d } = wdl(seval[m])
44+
return Math.abs(w - wOpt) <= eps && Math.abs(d - dOpt) <= eps
45+
})
46+
47+
const nGood = good.length
48+
const abundance =
49+
nGood === 1 ? 'only one move' : nGood === 2 ? 'two moves' : 'several moves'
50+
51+
const uciToSan = (uci: string): string => {
52+
const from = uci.slice(0, 2)
53+
const to = uci.slice(2, 4)
54+
const promotion = uci.length > 4 ? uci[4] : undefined
55+
const mv = chess.move({ from, to, promotion: promotion as PieceSymbol })
56+
const san = mv?.san ?? uci
57+
chess.undo()
58+
return san
59+
}
60+
61+
const bestGoodMoves = [...good]
62+
.sort((a, b) => seval[b] - seval[a])
63+
.slice(0, 3)
64+
const moveList = bestGoodMoves.map(uciToSan).join(', ')
65+
const bestMoveSan = uciToSan(opt)
66+
67+
const avgGood = good.reduce((s, m) => s + seval[m], 0) / nGood
68+
69+
let outcome: string
70+
if (avgGood > 2.5) outcome = 'to cleanly win'
71+
else if (avgGood > 1.0) outcome = 'to win'
72+
else if (avgGood > 0.35) outcome = 'for an advantage'
73+
else if (avgGood >= -0.35) outcome = 'to keep the balance'
74+
else if (avgGood >= -1.0) outcome = 'to hold the position'
75+
else outcome = 'to stay in the game'
76+
77+
let setLevels = 0
78+
let optLevels = 0
79+
let temptLevels = 0
80+
const temptCount: Record<string, number> = {}
81+
82+
for (let lvl = 0; lvl < 9; lvl++) {
83+
const probs = moves
84+
.map((m) => [maia[m]?.[lvl] ?? 0, m] as [number, string])
85+
.sort((a, b) => b[0] - a[0])
86+
87+
const [p1, m1] = probs[0]
88+
const [p2, m2] = probs[1] ?? [0, '']
89+
const [p3, m3] = probs[2] ?? [0, '']
90+
91+
const inGood = good.includes(m1)
92+
if (inGood) setLevels++
93+
if (m1 === opt) optLevels++
94+
95+
const nearTop = (prob: number) => p1 - prob <= eps
96+
const addTempt = (uci: string) => {
97+
temptCount[uci] = (temptCount[uci] ?? 0) + 1
98+
return true
99+
}
100+
101+
const tempting =
102+
inGood &&
103+
((m2 && !good.includes(m2) && nearTop(p2) && addTempt(m2)) ||
104+
(m3 && !good.includes(m3) && nearTop(p3) && addTempt(m3)))
105+
106+
if (tempting) temptLevels++
107+
}
108+
109+
const tier = (k: number) => (k <= 2 ? 0 : k <= 6 ? 1 : 2)
110+
const setTier = tier(setLevels)
111+
const optTier = tier(optLevels)
112+
113+
const phrSet =
114+
setTier === 0
115+
? 'hard for human players to find'
116+
: setTier === 1
117+
? 'findable for skilled players'
118+
: 'straightforward for players across skill levels to find'
119+
120+
let phrBest =
121+
optTier === 0
122+
? 'hard for human players to find'
123+
: optTier === 1
124+
? 'findable for skilled players'
125+
: 'straightforward for players across skill levels to find'
126+
127+
if (optTier === 1 && optTier < setTier) phrBest = 'only ' + phrBest
128+
129+
const verb = nGood === 1 ? 'is' : 'are'
130+
const pron = nGood === 1 ? 'it is' : 'they are'
131+
132+
let temptText = ''
133+
const hasTempting = setLevels > 0 && temptLevels > setLevels / 2
134+
if (hasTempting) {
135+
const topTemptUci = Object.entries(temptCount).sort(
136+
(a, b) => b[1] - a[1],
137+
)[0]?.[0]
138+
const temptSan = topTemptUci ? uciToSan(topTemptUci) : ''
139+
temptText =
140+
temptSan !== ''
141+
? ` There are also tempting alternatives, such as ${temptSan}.`
142+
: ' There are also tempting alternatives.'
143+
if (!(optTier < setTier) && setTier == 2) {
144+
temptText = ` However, there are tempting alternatives, such as ${temptSan}.`
145+
}
146+
}
147+
148+
if (nGood === 1) {
149+
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${temptText}`
150+
}
151+
152+
if (optTier < setTier) {
153+
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}, but the best move (${bestMoveSan}) is ${phrBest}.${temptText}`
154+
}
155+
156+
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${temptText}`
157+
}

0 commit comments

Comments
 (0)