Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 24 additions & 199 deletions src/hooks/useAnalysisController/useBoardDescription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
StockfishEvaluation,
} from 'src/types'
import { MAIA_MODELS } from './constants'
import { describePosition } from './useDescriptionGenerator'

type ColorSanMapping = {
[move: string]: {
Expand Down Expand Up @@ -33,205 +34,29 @@ export const useBoardDescription = (
return ''
}

const isBlackTurn = currentNode.turn === 'b'
const playerColor = isBlackTurn ? 'Black' : 'White'
const opponent = isBlackTurn ? 'White' : 'Black'
const stockfish = moveEvaluation.stockfish
const maia = moveEvaluation.maia
const topMaiaMove = Object.entries(maia.policy).sort(
(a, b) => b[1] - a[1],
)[0]

const topStockfishMoves = Object.entries(stockfish.cp_vec)
.sort((a, b) => (isBlackTurn ? a[1] - b[1] : b[1] - a[1]))
.slice(0, 3)

const cp = stockfish.model_optimal_cp
const absCP = Math.abs(cp)
const cpAdvantage = cp > 0 ? 'White' : cp < 0 ? 'Black' : 'Neither player'
const topStockfishMove = topStockfishMoves[0]

// Calculate winrate for more nuanced description (using centipawn to approximate winrate)
// Formula approximates winrate from CP value: 1/(1+10^(-cp/400))
const rawWinrate = 1 / (1 + Math.pow(10, -cp / 400))
const winrate = Math.max(0.01, Math.min(0.99, rawWinrate)) // Clamp between 1% and 99%
const toMoveWinrate = isBlackTurn ? 1 - winrate : winrate
const toMoveAdvantage = toMoveWinrate > 0.5

// Check if top Maia move matches top Stockfish move
const maiaMatchesStockfish = topMaiaMove[0] === topStockfishMove[0]

// Get top few Maia moves and their cumulative probability
const top3MaiaMoves = Object.entries(maia.policy)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
const top3MaiaProbability =
top3MaiaMoves.reduce((sum, [_, prob]) => sum + prob, 0) * 100

// Get second best moves to analyze move clarity
const secondBestMaiaMove = top3MaiaMoves[1]
const secondBestMaiaProbability = secondBestMaiaMove
? secondBestMaiaMove[1] * 100
: 0

// Calculate spread between first and second-best moves
const probabilitySpread = topMaiaMove[1] * 100 - secondBestMaiaProbability

// Get move classifications
const blunderProbability = blunderMeter.blunderMoves.probability
const okProbability = blunderMeter.okMoves.probability
const goodProbability = blunderMeter.goodMoves.probability

// Check for patterns in stockfish evaluation
const stockfishTop3Spread =
topStockfishMoves.length > 2
? Math.abs(topStockfishMoves[0][1] - topStockfishMoves[2][1])
: 0

// Get move spreads to detect sharp positions
const moveCpSpread = Object.values(stockfish.cp_relative_vec).reduce(
(maxDiff, cp, _, arr) => {
const min = Math.min(...arr)
const max = Math.max(...arr)
return Math.max(maxDiff, max - min)
},
0,
)

// Calculate position complexity based on distribution of move quality
const isPositionComplicated =
(blunderProbability > 30 && okProbability > 20 && goodProbability < 50) ||
moveCpSpread > 300 ||
stockfishTop3Spread > 100

// Check for tactical position
const isTacticalPosition = moveCpSpread > 500 || stockfishTop3Spread > 150

// Check if there's a clear best move
const topMaiaProbability = topMaiaMove[1] * 100
const isClearBestMove = topMaiaProbability > 70 || probabilitySpread > 40

// Check if there are multiple equally good moves
const hasMultipleGoodMoves =
top3MaiaProbability > 75 && topMaiaProbability < 50

// Calculate agreement between Maia rating levels
const maiaModelsAgree = Object.entries(currentNode.analysis.maia || {})
.filter(([key]) => MAIA_MODELS.includes(key))
.every(([_, evaluation]) => {
const topMove = Object.entries(evaluation.policy).sort(
(a, b) => b[1] - a[1],
)[0]
return topMove && topMove[0] === topMaiaMove[0]
})

// Check if evaluation is decisive
const isDecisiveAdvantage = absCP > 300
const isOverwhelming = absCP > 800

// Check for high blunder probability
const isBlunderProne = blunderProbability > 50
const isVeryBlunderProne = blunderProbability > 70

// Check if there's forced play
const isForcedPlay = topMaiaProbability > 85 && maiaMatchesStockfish

// Check if position is balanced but with complexity
const isBalancedButComplex = absCP < 50 && isPositionComplicated

// Generate descriptions
let evaluation = ''
let suggestion = ''

// Evaluation description that considers whose turn it is
if (isOverwhelming) {
if (cpAdvantage === playerColor) {
evaluation = `${playerColor} has a completely winning position with a ${Math.round(toMoveWinrate * 100)}% win probability.`
} else {
evaluation = `${playerColor} faces a nearly lost position with only a ${Math.round(toMoveWinrate * 100)}% win probability.`
}
} else if (cp === 0) {
evaluation = isBalancedButComplex
? 'The position is balanced but filled with complications.'
: 'The position is completely equal.'
} else if (absCP < 30) {
evaluation = `The evaluation is almost perfectly balanced with only the slightest edge ${cpAdvantage === playerColor ? 'for' : 'against'} ${playerColor}.`
} else if (absCP < 80) {
if (cpAdvantage === playerColor) {
evaluation = `${playerColor} has a slight but tangible advantage with a win probability of ${Math.round(toMoveWinrate * 100)}%.`
} else {
evaluation = `${playerColor} faces a slight disadvantage with a win probability of ${Math.round(toMoveWinrate * 100)}%.`
const fen = currentNode.fen
const whiteToMove = currentNode.turn === 'w'

const stockfishEvals = moveEvaluation.stockfish.cp_vec
const maiaEvals: Record<string, number[]> = {}
const allMaiaAnalysis = currentNode.analysis.maia || {}

Object.keys(moveEvaluation.maia.policy).forEach((move) => {
maiaEvals[move] = new Array(MAIA_MODELS.length).fill(0)
})

MAIA_MODELS.forEach((model, index) => {
const modelAnalysis = allMaiaAnalysis[model]
if (modelAnalysis?.policy) {
Object.entries(modelAnalysis.policy).forEach(([move, probability]) => {
if (!maiaEvals[move]) {
maiaEvals[move] = new Array(MAIA_MODELS.length).fill(0)
}
maiaEvals[move][index] = probability
})
}
} else if (absCP < 150) {
if (cpAdvantage === playerColor) {
evaluation = `${playerColor} has a clear positional advantage that could be decisive with careful play.`
} else {
evaluation = `${playerColor} must play accurately as ${opponent} holds a clear positional advantage.`
}
} else if (absCP < 300) {
if (cpAdvantage === playerColor) {
evaluation = `${playerColor} has a significant advantage (${Math.round(toMoveWinrate * 100)}% win rate) that should be convertible with proper technique.`
} else {
evaluation = `${playerColor} faces a difficult position as ${opponent} has a significant advantage (${Math.round((1 - toMoveWinrate) * 100)}% win rate).`
}
} else if (absCP < 500) {
if (cpAdvantage === playerColor) {
evaluation = `${playerColor} is winning and only needs to avoid major blunders to convert.`
} else {
evaluation = `${playerColor} is in serious trouble and needs to find resilient defensive moves.`
}
} else {
if (cpAdvantage === playerColor) {
evaluation = `${playerColor} has a completely winning position with a ${Math.round(toMoveWinrate * 100)}% win probability.`
} else {
evaluation = `${playerColor} faces a nearly lost position with only a ${Math.round(toMoveWinrate * 100)}% win probability.`
}
}

// Suggestion/description of move quality
if (isVeryBlunderProne) {
suggestion = `This critical position is extremely treacherous with a ${blunderProbability.toFixed(0)}% chance of ${playerColor} making a significant error.`
} else if (isBlunderProne && isTacticalPosition) {
suggestion = `The sharp tactical nature of this position creates many opportunities for mistakes (${blunderProbability.toFixed(0)}% blunder chance).`
} else if (isBlunderProne) {
suggestion = `This position is quite treacherous with ${blunderProbability.toFixed(0)}% chance of ${playerColor} making a significant mistake.`
} else if (isForcedPlay) {
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
suggestion = `${playerColor} must play ${moveSan}, as all other moves lead to a significantly worse position.`
} else if (isTacticalPosition && maiaMatchesStockfish) {
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
suggestion = `The tactical complexity demands precision, with ${moveSan} being the only move that maintains the balance.`
} else if (isPositionComplicated && hasMultipleGoodMoves) {
suggestion = `This complex position offers several equally promising continuations for ${playerColor}.`
} else if (isPositionComplicated) {
suggestion = `This is a complex position requiring careful calculation of the many reasonable options.`
} else if (isClearBestMove && maiaMatchesStockfish && maiaModelsAgree) {
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
suggestion = `Players of all levels agree ${moveSan} stands out as clearly best in this position.`
} else if (isClearBestMove && maiaMatchesStockfish) {
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
suggestion = `${playerColor} should play ${moveSan}, which both human intuition and concrete calculation confirm as best.`
} else if (isClearBestMove && maiaModelsAgree) {
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
suggestion = `Human players at all levels strongly prefer ${moveSan} (${topMaiaProbability.toFixed(0)}%), though the engine suggests otherwise.`
} else if (isClearBestMove) {
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
suggestion = `Maia strongly suggests ${moveSan} (${topMaiaProbability.toFixed(0)}% likely), though Stockfish calculates a different approach.`
} else if (goodProbability > 80) {
suggestion = `This is a forgiving position where almost any reasonable move by ${playerColor} maintains the evaluation.`
} else if (goodProbability > 60) {
suggestion = `Most moves ${playerColor} is likely to consider will maintain the current position assessment.`
} else if (maiaMatchesStockfish) {
const moveSan = colorSanMapping[topMaiaMove[0]]?.san || topMaiaMove[0]
suggestion = `Both human intuition and engine calculation agree that ${moveSan} is the best continuation here.`
} else if (hasMultipleGoodMoves) {
suggestion = `${playerColor} has several equally strong options, suggesting flexibility in planning.`
} else if (top3MaiaProbability < 50) {
suggestion = `This unusual position creates difficulties for human calculation, with no clearly favored continuation.`
} else {
suggestion = `There are several reasonable options for ${playerColor} to consider in this position.`
}
})

return `${evaluation} ${suggestion}`
}, [currentNode, moveEvaluation, blunderMeter, colorSanMapping])
return describePosition(fen, stockfishEvals, maiaEvals, whiteToMove)
}, [currentNode, moveEvaluation])
}
157 changes: 157 additions & 0 deletions src/hooks/useAnalysisController/useDescriptionGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Chess, PieceSymbol } from 'chess.ts'

type StockfishEvals = Record<string, number>
type MaiaEvals = Record<string, number[]>

const A = 1
const B = 0.8
const EPS = 0.08

const winRate = (p: number) => 1 / (1 + Math.exp(-(p - A) / B))
const wdl = (p: number) => {
const w = winRate(p)
const l = winRate(-p)
return { w, d: 1 - w - l }
}

export function describePosition(
fen: string,
sf: StockfishEvals,
maia: MaiaEvals,
whiteToMove: boolean,
eps = EPS,
): string {
const chess = new Chess(fen)

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

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

const seval: Record<string, number> = {}
moves.forEach((m) => {
seval[m] = (whiteToMove ? 1 : 1) * sf[m]
})

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

const good = moves.filter((m) => {
const { w, d } = wdl(seval[m])
return Math.abs(w - wOpt) <= eps && Math.abs(d - dOpt) <= eps
})

const nGood = good.length
const abundance =
nGood === 1 ? 'only one move' : nGood === 2 ? 'two moves' : 'several moves'

const uciToSan = (uci: string): string => {
const from = uci.slice(0, 2)
const to = uci.slice(2, 4)
const promotion = uci.length > 4 ? uci[4] : undefined
const mv = chess.move({ from, to, promotion: promotion as PieceSymbol })
const san = mv?.san ?? uci
chess.undo()
return san
}

const bestGoodMoves = [...good]
.sort((a, b) => seval[b] - seval[a])
.slice(0, 3)
const moveList = bestGoodMoves.map(uciToSan).join(', ')
const bestMoveSan = uciToSan(opt)

const avgGood = good.reduce((s, m) => s + seval[m], 0) / nGood

let outcome: string
if (avgGood > 2.5) outcome = 'to cleanly win'
else if (avgGood > 1.0) outcome = 'to win'
else if (avgGood > 0.35) outcome = 'for an advantage'
else if (avgGood >= -0.35) outcome = 'to keep the balance'
else if (avgGood >= -1.0) outcome = 'to hold the position'
else outcome = 'to stay in the game'

let setLevels = 0
let optLevels = 0
let temptLevels = 0
const temptCount: Record<string, number> = {}

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

const [p1, m1] = probs[0]
const [p2, m2] = probs[1] ?? [0, '']
const [p3, m3] = probs[2] ?? [0, '']

const inGood = good.includes(m1)
if (inGood) setLevels++
if (m1 === opt) optLevels++

const nearTop = (prob: number) => p1 - prob <= eps
const addTempt = (uci: string) => {
temptCount[uci] = (temptCount[uci] ?? 0) + 1
return true
}

const tempting =
inGood &&
((m2 && !good.includes(m2) && nearTop(p2) && addTempt(m2)) ||
(m3 && !good.includes(m3) && nearTop(p3) && addTempt(m3)))

if (tempting) temptLevels++
}

const tier = (k: number) => (k <= 2 ? 0 : k <= 6 ? 1 : 2)
const setTier = tier(setLevels)
const optTier = tier(optLevels)

const phrSet =
setTier === 0
? 'hard for human players to find'
: setTier === 1
? 'findable for skilled players'
: 'straightforward for players across skill levels to find'

let phrBest =
optTier === 0
? 'hard for human players to find'
: optTier === 1
? 'findable for skilled players'
: 'straightforward for players across skill levels to find'

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

const verb = nGood === 1 ? 'is' : 'are'
const pron = nGood === 1 ? 'it is' : 'they are'

let temptText = ''
const hasTempting = setLevels > 0 && temptLevels > setLevels / 2
if (hasTempting) {
const topTemptUci = Object.entries(temptCount).sort(
(a, b) => b[1] - a[1],
)[0]?.[0]
const temptSan = topTemptUci ? uciToSan(topTemptUci) : ''
temptText =
temptSan !== ''
? ` There are also tempting alternatives, such as ${temptSan}.`
: ' There are also tempting alternatives.'
if (!(optTier < setTier) && setTier == 2) {
temptText = ` However, there are tempting alternatives, such as ${temptSan}.`
}
}

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

if (optTier < setTier) {
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}, but the best move (${bestMoveSan}) is ${phrBest}.${temptText}`
}

return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${temptText}`
}