From 8dbe80c07d39dff9ff42398e3b079c842d125add Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 00:22:50 -0500 Subject: [PATCH 01/12] Add staged Stockfish strategy and debug diagnostics panel --- src/constants/analysis.ts | 2 + src/contexts/StockfishEngineContext.tsx | 851 ++++++++++- .../useEngineAnalysis.ts | 85 +- src/lib/engine/stockfish.ts | 1310 ++++++++++++++++- src/types/analysis.ts | 27 + src/types/engine.ts | 12 + src/types/node.ts | 45 +- 7 files changed, 2282 insertions(+), 50 deletions(-) diff --git a/src/constants/analysis.ts b/src/constants/analysis.ts index 21fc8914..b8cd86a2 100644 --- a/src/constants/analysis.ts +++ b/src/constants/analysis.ts @@ -20,6 +20,8 @@ export const MOVE_CLASSIFICATION_THRESHOLDS = { export const DEFAULT_MAIA_MODEL = 'maia_kdd_1500' as const export const MIN_STOCKFISH_DEPTH = 12 as const export const LEARN_FROM_MISTAKES_DEPTH = 12 as const +export const STOCKFISH_DEBUG_RERUN_EVENT = 'maia:stockfish-debug-rerun' as const +export const STOCKFISH_DEBUG_RERUN_KEY = 'maia.stockfishDebugRerunTs' as const export const COLORS = { good: ['#238b45', '#41ab5d', '#74c476', '#90D289', '#AEDFA4'], diff --git a/src/contexts/StockfishEngineContext.tsx b/src/contexts/StockfishEngineContext.tsx index 74fa9d58..31560ca5 100644 --- a/src/contexts/StockfishEngineContext.tsx +++ b/src/contexts/StockfishEngineContext.tsx @@ -7,11 +7,29 @@ import React, { useMemo, } from 'react' import toast from 'react-hot-toast' -import { StockfishStatus, StockfishEngine } from 'src/types' +import { + StockfishStatus, + StockfishEngine, + StockfishEvaluation, + StockfishMoveMapStrategy, +} from 'src/types' +import { + STOCKFISH_DEBUG_RERUN_EVENT, + STOCKFISH_DEBUG_RERUN_KEY, +} from 'src/constants/analysis' import Engine from 'src/lib/engine/stockfish' const STOCKFISH_LOADING_TOAST_DELAY_MS = 800 const STOCKFISH_DEBUG_LOADING_KEY = 'maia.stockfishDebugLoading' +const SF_STRATEGY_KEY = 'maia.stockfishMoveMapStrategy' +const SF_DIAGNOSTICS_KEY = 'maia.stockfishDiagnostics' +const SF_DEBUG_PANEL_KEY = 'maia.stockfishDebugPanel' + +const SF_STRATEGIES: StockfishMoveMapStrategy[] = [ + 'staged-root-probe', + 'multipv-all', + 'searchmoves-all', +] const isTruthy = (value: string | null | undefined): boolean => { if (!value) return false @@ -76,6 +94,822 @@ const getOrCreateStockfishEngine = (): Engine => { return sharedClientStockfishEngine } +const getRunKey = (run: StockfishEvaluation): string => { + const diagnostics = run.diagnostics + if (!diagnostics) { + return `${run.depth}:${run.model_move}:${Object.keys(run.cp_vec).length}` + } + return `${diagnostics.positionId}:${diagnostics.strategy}:${diagnostics.targetDepth}` +} + +const formatMs = (value?: number): string => { + if (typeof value !== 'number' || Number.isNaN(value)) return '-' + return `${Math.round(value)} ms` +} + +const formatCp = (value?: number): string => { + if (typeof value !== 'number' || Number.isNaN(value)) return '-' + return value > 0 ? `+${value}` : `${value}` +} + +const formatMaiaProb = (value?: number): string => { + if (typeof value !== 'number' || Number.isNaN(value)) return '-' + return `${(value * 100).toFixed(1)}%` +} + +const formatSelectedBy = (value?: 'sf-top' | 'maia-95' | 'both'): string => { + if (!value) return '-' + if (value === 'sf-top') return 'SF top-K' + if (value === 'maia-95') return 'Maia 95%' + return 'Both' +} + +type MoveTableSortKey = + | 'move' + | 'cp' + | 'depth' + | 'phase' + | 'selectedBy' + | 'mateIn' + | 'maiaProb' + +type MoveTableSort = { + key: MoveTableSortKey + direction: 'asc' | 'desc' +} + +type ComparisonTableSortKey = + | 'move' + | 'thisCp' + | 'gtCp' + | 'diff' + | 'thisDepth' + | 'gtDepth' + | 'phase' + | 'maiaProb' + +type ComparisonTableSort = { + key: ComparisonTableSortKey + direction: 'asc' | 'desc' +} + +const summarizeFen = (fen?: string): string => { + if (!fen) return '-' + return fen.split(' ').slice(0, 2).join(' ') +} + +type WindowWithSfDiagnostics = Window & { + __maiaStockfishDiagnostics?: StockfishEvaluation[] +} + +const StockfishDebugPanel: React.FC = () => { + const [enabled, setEnabled] = useState(false) + const [collapsed, setCollapsed] = useState(false) + const [diagnosticsEnabled, setDiagnosticsEnabled] = useState(false) + const [strategy, setStrategy] = + useState('staged-root-probe') + const [runs, setRuns] = useState([]) + const [selectedRunKey, setSelectedRunKey] = useState(null) + const [moveTableSort, setMoveTableSort] = useState({ + key: 'cp', + direction: 'desc', + }) + const [comparisonTableSort, setComparisonTableSort] = + useState({ + key: 'diff', + direction: 'desc', + }) + + useEffect(() => { + if (typeof window === 'undefined') return + + const sync = () => { + const panelOn = isTruthy( + window.localStorage.getItem(SF_DEBUG_PANEL_KEY) ?? + (process.env.NEXT_PUBLIC_STOCKFISH_DEBUG_PANEL || null), + ) + setEnabled(panelOn) + + const strategyRaw = + window.localStorage.getItem(SF_STRATEGY_KEY) ?? + window.localStorage.getItem('stockfishMoveMapStrategy') ?? + process.env.NEXT_PUBLIC_STOCKFISH_MOVE_MAP_STRATEGY ?? + 'staged-root-probe' + setStrategy( + SF_STRATEGIES.includes(strategyRaw as StockfishMoveMapStrategy) + ? (strategyRaw as StockfishMoveMapStrategy) + : 'staged-root-probe', + ) + + const diagnosticsOn = isTruthy( + window.localStorage.getItem(SF_DIAGNOSTICS_KEY) ?? + window.localStorage.getItem('stockfishDiagnostics') ?? + (process.env.NEXT_PUBLIC_STOCKFISH_DIAGNOSTICS || null), + ) + setDiagnosticsEnabled(diagnosticsOn) + + const buffer = (window as WindowWithSfDiagnostics) + .__maiaStockfishDiagnostics + setRuns([...(buffer || [])].reverse()) + } + + sync() + const interval = window.setInterval(sync, 500) + window.addEventListener('storage', sync) + return () => { + window.clearInterval(interval) + window.removeEventListener('storage', sync) + } + }, []) + + useEffect(() => { + if (!runs.length) { + setSelectedRunKey(null) + return + } + + if ( + !selectedRunKey || + !runs.some((run) => getRunKey(run) === selectedRunKey) + ) { + setSelectedRunKey(getRunKey(runs[0])) + } + }, [runs, selectedRunKey]) + + const selectedRun = useMemo(() => { + if (!selectedRunKey) return runs[0] + return runs.find((run) => getRunKey(run) === selectedRunKey) || runs[0] + }, [runs, selectedRunKey]) + + const comparableGroundTruth = useMemo(() => { + if (!selectedRun?.diagnostics) return null + return ( + runs.find((run) => { + if (run === selectedRun) return false + const d = run.diagnostics + if (!d) return false + return ( + d.strategy === 'searchmoves-all' && + d.fen === selectedRun.diagnostics?.fen && + d.targetDepth === selectedRun.diagnostics?.targetDepth + ) + }) || null + ) + }, [runs, selectedRun]) + + const comparisonRows = useMemo(() => { + if (!selectedRun) return [] + + const selectedMoves = selectedRun.diagnostics?.moves || {} + const groundTruthMoves = comparableGroundTruth?.diagnostics?.moves || {} + const allMoves = Array.from( + new Set([ + ...Object.keys(selectedMoves), + ...Object.keys(groundTruthMoves), + ]), + ) + + return allMoves.map((move) => { + const s = selectedMoves[move] + const g = groundTruthMoves[move] + const absDiff = + typeof s?.cp === 'number' && typeof g?.cp === 'number' + ? Math.abs(s.cp - g.cp) + : undefined + return { + move, + selectedCp: s?.cp, + groundTruthCp: g?.cp, + absDiff, + selectedDepth: s?.depth, + groundTruthDepth: g?.depth, + phase: s?.phase, + mateIn: s?.mateIn, + maiaProb: s?.maiaProb, + } + }) + }, [selectedRun, comparableGroundTruth]) + + const comparisonSummary = useMemo(() => { + const diffs = comparisonRows + .map((row) => row.absDiff) + .filter((v): v is number => typeof v === 'number') + if (!diffs.length) return null + const mae = diffs.reduce((sum, v) => sum + v, 0) / diffs.length + const max = Math.max(...diffs) + return { mae, max, comparedMoves: diffs.length } + }, [comparisonRows]) + + const toggleMoveTableSort = useCallback((key: MoveTableSortKey) => { + setMoveTableSort((prev) => { + if (prev.key === key) { + return { + key, + direction: prev.direction === 'asc' ? 'desc' : 'asc', + } + } + return { key, direction: 'desc' } + }) + }, []) + + const sortedMoveRows = useMemo(() => { + const rows = Object.entries(selectedRun?.diagnostics?.moves || {}).map( + ([move, info]) => ({ + move, + info, + }), + ) + + const directionMultiplier = moveTableSort.direction === 'asc' ? 1 : -1 + const textValue = (value?: string) => value || '' + const numericValue = (value?: number) => + typeof value === 'number' && Number.isFinite(value) + ? value + : Number.NEGATIVE_INFINITY + + return rows.sort((a, b) => { + let cmp = 0 + switch (moveTableSort.key) { + case 'move': + cmp = a.move.localeCompare(b.move) + break + case 'cp': + cmp = numericValue(a.info.cp) - numericValue(b.info.cp) + break + case 'depth': + cmp = numericValue(a.info.depth) - numericValue(b.info.depth) + break + case 'phase': + cmp = textValue(a.info.phase).localeCompare(textValue(b.info.phase)) + break + case 'selectedBy': + cmp = textValue(a.info.selectedBy).localeCompare( + textValue(b.info.selectedBy), + ) + break + case 'mateIn': + cmp = numericValue(a.info.mateIn) - numericValue(b.info.mateIn) + break + case 'maiaProb': + cmp = numericValue(a.info.maiaProb) - numericValue(b.info.maiaProb) + break + } + + if (cmp === 0) { + return a.move.localeCompare(b.move) + } + return cmp * directionMultiplier + }) + }, [selectedRun, moveTableSort]) + + const sortIndicator = useCallback( + (key: MoveTableSortKey) => { + if (moveTableSort.key !== key) return '↕' + return moveTableSort.direction === 'asc' ? '↑' : '↓' + }, + [moveTableSort], + ) + + const toggleComparisonTableSort = useCallback( + (key: ComparisonTableSortKey) => { + setComparisonTableSort((prev) => { + if (prev.key === key) { + return { + key, + direction: prev.direction === 'asc' ? 'desc' : 'asc', + } + } + return { key, direction: 'desc' } + }) + }, + [], + ) + + const sortedComparisonRows = useMemo(() => { + const rows = [...comparisonRows] + const directionMultiplier = comparisonTableSort.direction === 'asc' ? 1 : -1 + const textValue = (value?: string) => value || '' + const numericValue = (value?: number) => + typeof value === 'number' && Number.isFinite(value) + ? value + : Number.NEGATIVE_INFINITY + + return rows.sort((a, b) => { + let cmp = 0 + switch (comparisonTableSort.key) { + case 'move': + cmp = a.move.localeCompare(b.move) + break + case 'thisCp': + cmp = numericValue(a.selectedCp) - numericValue(b.selectedCp) + break + case 'gtCp': + cmp = numericValue(a.groundTruthCp) - numericValue(b.groundTruthCp) + break + case 'diff': + cmp = numericValue(a.absDiff) - numericValue(b.absDiff) + break + case 'thisDepth': + cmp = numericValue(a.selectedDepth) - numericValue(b.selectedDepth) + break + case 'gtDepth': + cmp = + numericValue(a.groundTruthDepth) - numericValue(b.groundTruthDepth) + break + case 'phase': + cmp = textValue(a.phase).localeCompare(textValue(b.phase)) + break + case 'maiaProb': + cmp = numericValue(a.maiaProb) - numericValue(b.maiaProb) + break + } + + if (cmp === 0) { + return a.move.localeCompare(b.move) + } + return cmp * directionMultiplier + }) + }, [comparisonRows, comparisonTableSort]) + + const comparisonSortIndicator = useCallback( + (key: ComparisonTableSortKey) => { + if (comparisonTableSort.key !== key) return '↕' + return comparisonTableSort.direction === 'asc' ? '↑' : '↓' + }, + [comparisonTableSort], + ) + + const updateStrategy = useCallback( + (nextStrategy: StockfishMoveMapStrategy) => { + if (typeof window === 'undefined') return + window.localStorage.setItem(SF_STRATEGY_KEY, nextStrategy) + setStrategy(nextStrategy) + }, + [], + ) + + const updateDiagnosticsFlag = useCallback((nextValue: boolean) => { + if (typeof window === 'undefined') return + window.localStorage.setItem(SF_DIAGNOSTICS_KEY, nextValue ? '1' : '0') + setDiagnosticsEnabled(nextValue) + }, []) + + const clearRuns = useCallback(() => { + if (typeof window === 'undefined') return + ;(window as WindowWithSfDiagnostics).__maiaStockfishDiagnostics = [] + setRuns([]) + }, []) + + const triggerRerun = useCallback(() => { + if (typeof window === 'undefined') return + window.localStorage.setItem(STOCKFISH_DEBUG_RERUN_KEY, `${Date.now()}`) + window.dispatchEvent( + new CustomEvent(STOCKFISH_DEBUG_RERUN_EVENT, { + detail: { ts: Date.now() }, + }), + ) + }, []) + + const hidePanel = useCallback(() => { + if (typeof window === 'undefined') return + window.localStorage.setItem(SF_DEBUG_PANEL_KEY, '0') + setEnabled(false) + }, []) + + if (!enabled) { + return null + } + + return ( +
+
+
+
+
+ Stockfish Debug +
+
+ {statusLabel(selectedRun)} +
+
+
+ + +
+
+ + {!collapsed && ( +
+
+ + +
+ Controls +
+ + + +
+
+
+ +
+
+
+ Recent runs ({runs.length}) +
+
+ {runs.length === 0 && ( +
+ No diagnostics yet. Run analysis on any supported page. +
+ )} + {runs.map((run) => { + const key = getRunKey(run) + const d = run.diagnostics + return ( + + ) + })} +
+
+ +
+
+ Selected summary +
+ {selectedRun?.diagnostics ? ( +
+
+ Strategy:{' '} + {selectedRun.diagnostics.strategy} +
+
+ Timing:{' '} + {formatMs(selectedRun.diagnostics.totalTimeMs)} +
+
+ Best:{' '} + {selectedRun.model_move} ( + {formatCp(selectedRun.model_optimal_cp)}) +
+
+ Moves:{' '} + {Object.keys(selectedRun.diagnostics.moves || {}).length}/ + {selectedRun.diagnostics.legalMoveCount} +
+
+ Final@target:{' '} + {selectedRun.diagnostics.moveCounts?.finalAtTargetDepth ?? + '-'} +
+
+ Screened/Deepened:{' '} + {selectedRun.diagnostics.moveCounts?.screened ?? 0}/ + {selectedRun.diagnostics.moveCounts?.deepened ?? 0} +
+
+ ) : ( +
Select a run.
+ )} +
+
+ + {selectedRun?.diagnostics && ( +
+
+ Phase timings +
+
+ {Object.entries(selectedRun.diagnostics.phaseTimesMs || {}) + .length ? ( + Object.entries( + selectedRun.diagnostics.phaseTimesMs || {}, + ).map(([phase, ms]) => ( + + {phase}: {formatMs(ms)} + + )) + ) : ( + + Not broken out for this strategy/depth snapshot. + + )} +
+
+ )} + +
+
+
+ Ground-truth comparison (vs latest `searchmoves-all` same + FEN/depth) +
+ {comparisonSummary && ( +
+ MAE {comparisonSummary.mae.toFixed(1)} cp | Max{' '} + {comparisonSummary.max} cp | n= + {comparisonSummary.comparedMoves} +
+ )} +
+ {!comparableGroundTruth ? ( +
+ No matching `searchmoves-all` run found for this FEN/target + depth. +
+ ) : ( +
+ + + + + + + + + + + + + + + {sortedComparisonRows.map((row) => ( + + + + + + + + + + + ))} + +
+ + + + + + + + + + + + + + + +
+ {row.move} + + {formatCp(row.selectedCp)} + + {formatCp(row.groundTruthCp)} + + {typeof row.absDiff === 'number' + ? row.absDiff + : '-'} + + {row.selectedDepth ?? '-'} + + {row.groundTruthDepth ?? '-'} + {row.phase || '-'} + {formatMaiaProb(row.maiaProb)} +
+
+ )} +
+ +
+
+ Selected run moves/evals +
+
+ + + + + + + + + + + + + + {sortedMoveRows.map(({ move, info }) => ( + + + + + + + + + + ))} + +
+ + + + + + + + + + + + + +
+ {move} + {formatCp(info.cp)}{info.depth}{info.phase || '-'} + {formatSelectedBy(info.selectedBy)} + + {typeof info.mateIn === 'number' ? info.mateIn : '-'} + + {formatMaiaProb(info.maiaProb)} +
+
+
+ +
+ Global switches are stored in localStorage and apply to all + client-side Stockfish analysis using this shared engine provider. +
+
+ )} +
+
+ ) +} + +const statusLabel = (run?: StockfishEvaluation): string => { + if (!run?.diagnostics) return 'No diagnostic snapshots yet' + const d = run.diagnostics + return `${d.strategy} • d${run.depth}/${d.targetDepth} • ${formatMs( + d.totalTimeMs, + )} • ${d.legalMoveCount} legal` +} + export const StockfishEngineContext = React.createContext({ streamEvaluations: () => { throw new Error( @@ -118,12 +952,22 @@ export const StockfishEngineContextProvider: React.FC<{ const loadingToastTimerRef = useRef(null) const streamEvaluations = useCallback( - (fen: string, legalMoveCount: number, depth?: number) => { + ( + fen: string, + legalMoveCount: number, + depth?: number, + options?: Parameters[3], + ) => { if (!engineRef.current) { console.error('Engine not initialized') return null } - return engineRef.current.streamEvaluations(fen, legalMoveCount, depth) + return engineRef.current.streamEvaluations( + fen, + legalMoveCount, + depth, + options, + ) }, [], ) @@ -251,6 +1095,7 @@ export const StockfishEngineContextProvider: React.FC<{ return ( {children} + ) } diff --git a/src/hooks/useAnalysisController/useEngineAnalysis.ts b/src/hooks/useAnalysisController/useEngineAnalysis.ts index 582354b5..cf64ca59 100644 --- a/src/hooks/useAnalysisController/useEngineAnalysis.ts +++ b/src/hooks/useAnalysisController/useEngineAnalysis.ts @@ -1,7 +1,11 @@ import { Chess } from 'chess.ts' import { fetchOpeningBookMoves } from 'src/api' -import { useEffect, useContext } from 'react' +import { useEffect, useContext, useRef, useState } from 'react' import { MAIA_MODELS } from 'src/constants/common' +import { + STOCKFISH_DEBUG_RERUN_EVENT, + STOCKFISH_DEBUG_RERUN_KEY, +} from 'src/constants/analysis' import { GameNode, MaiaEvaluation } from 'src/types' import { MaiaEngineContext, StockfishEngineContext } from 'src/contexts' @@ -14,6 +18,37 @@ export const useEngineAnalysis = ( ) => { const maia = useContext(MaiaEngineContext) const stockfish = useContext(StockfishEngineContext) + const [stockfishDebugRerunToken, setStockfishDebugRerunToken] = useState(0) + const lastConsumedStockfishRerunTokenRef = useRef(0) + + const readRerunTokenFromStorage = () => { + if (typeof window === 'undefined') return 0 + const raw = window.localStorage.getItem(STOCKFISH_DEBUG_RERUN_KEY) + const parsed = raw ? Number.parseInt(raw, 10) : 0 + return Number.isFinite(parsed) ? parsed : 0 + } + + useEffect(() => { + if (typeof window === 'undefined') return + + const onDebugRerun = () => { + const token = readRerunTokenFromStorage() || Date.now() + setStockfishDebugRerunToken(token) + } + + setStockfishDebugRerunToken(readRerunTokenFromStorage()) + window.addEventListener(STOCKFISH_DEBUG_RERUN_EVENT, onDebugRerun) + + const intervalId = window.setInterval(() => { + const token = readRerunTokenFromStorage() + setStockfishDebugRerunToken((prev) => (token > prev ? token : prev)) + }, 500) + + return () => { + window.removeEventListener(STOCKFISH_DEBUG_RERUN_EVENT, onDebugRerun) + window.clearInterval(intervalId) + } + }, []) async function inferenceMaiaModel(board: Chess): Promise<{ [key: string]: MaiaEvaluation @@ -131,9 +166,17 @@ export const useEngineAnalysis = ( useEffect(() => { if (!currentNode) return + + const shouldForceStockfishRerun = + stockfishDebugRerunToken > lastConsumedStockfishRerunTokenRef.current + if (shouldForceStockfishRerun) { + lastConsumedStockfishRerunTokenRef.current = stockfishDebugRerunToken + } + if ( currentNode.analysis.stockfish && - currentNode.analysis.stockfish?.depth >= targetDepth + currentNode.analysis.stockfish?.depth >= targetDepth && + !shouldForceStockfishRerun ) return @@ -158,10 +201,38 @@ export const useEngineAnalysis = ( } const chess = new Chess(currentNode.fen) + const legalMoves = new Set( + chess + .moves({ verbose: true }) + .map((move) => `${move.from}${move.to}${move.promotion || ''}`), + ) + const maiaPolicy = currentNode.analysis.maia?.[currentMaiaModel]?.policy + const maiaCandidateMoves: string[] = [] + + if (maiaPolicy) { + let cumulative = 0 + const sortedMaiaMoves = Object.entries(maiaPolicy) + .filter(([, prob]) => Number.isFinite(prob) && prob > 0) + .sort(([, a], [, b]) => b - a) + + for (const [move, prob] of sortedMaiaMoves) { + if (!legalMoves.has(move)) continue + maiaCandidateMoves.push(move) + cumulative += prob + if (cumulative >= 0.95) { + break + } + } + } + const evaluationStream = stockfish.streamEvaluations( chess.fen(), chess.moves().length, targetDepth, + { + maiaCandidateMoves, + maiaPolicy, + }, ) if (evaluationStream && !cancelled) { @@ -196,5 +267,13 @@ export const useEngineAnalysis = ( cancelled = true clearTimeout(timeoutId) } - }, [currentNode, stockfish, currentMaiaModel, setAnalysisState, targetDepth]) + }, [ + currentNode, + stockfish, + currentMaiaModel, + setAnalysisState, + targetDepth, + stockfishDebugRerunToken, + currentNode?.analysis.maia?.[currentMaiaModel]?.policy, + ]) } diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts index f9758ba8..91b159f1 100644 --- a/src/lib/engine/stockfish.ts +++ b/src/lib/engine/stockfish.ts @@ -1,7 +1,13 @@ import { Chess } from 'chess.ts' import { cpToWinrate } from 'src/lib' import StockfishWeb from 'lila-stockfish-web' -import { StockfishEvaluation } from 'src/types' +import { + StockfishDiagnostics, + StockfishEvaluation, + StockfishMoveDiagnostic, + StockfishMoveMapStrategy, + StockfishStreamOptions, +} from 'src/types' import { StockfishModelStorage } from './stockfishStorage' const DEFAULT_NNUE_FETCH_TIMEOUT_MS = 30000 @@ -14,6 +20,39 @@ type StockfishInitPhase = | 'ready' | 'error' +type RootMoveScore = { + cp: number + depth: number + mateIn?: number +} + +type RootSearchResult = { + move: string + cp: number + depth: number + mateIn?: number +} + +type RootProbePlan = { + screeningDepth: number +} + +type RootMovePhase = 'multipv' | 'screen' | 'deep' | 'ground-truth' +type CandidateSelectionSource = 'sf-top' | 'maia-95' | 'both' + +type AnalysisRunContext = { + positionId: string + fen: string + legalMoves: string[] + legalMoveCount: number + maiaCandidateMoves: string[] + maiaPolicy: Record + kSf: number + targetDepth: number + analysisStartedAtMs: number + diagnosticsToConsole: boolean +} + class Engine { private fen: string private moves: string[] @@ -34,6 +73,10 @@ class Engine { private initError: string | null private initInFlight: boolean private initPhase: StockfishInitPhase + private currentTargetDepth: number + private currentMoveMapStrategy: StockfishMoveMapStrategy + private currentAnalysisStartedAtMs: number + private currentDiagnosticsToConsole: boolean constructor() { this.fen = '' @@ -49,6 +92,10 @@ class Engine { this.initError = null this.initInFlight = false this.initPhase = 'idle' + this.currentTargetDepth = 0 + this.currentMoveMapStrategy = 'staged-root-probe' + this.currentAnalysisStartedAtMs = 0 + this.currentDiagnosticsToConsole = false this.onMessage = this.onMessage.bind(this) @@ -109,6 +156,7 @@ class Engine { fen: string, legalMoveCount: number, targetDepth = 18, + options?: StockfishStreamOptions, ): AsyncGenerator { if (this.stockfish && this.isReady) { if (typeof global !== 'undefined' && typeof global.gc === 'function') { @@ -126,13 +174,79 @@ class Engine { this.fen = fen this.currentPositionId = fen + '_' + Date.now() this.isEvaluating = true + this.currentTargetDepth = targetDepth + this.currentAnalysisStartedAtMs = Date.now() this.evaluationGenerator = this.createEvaluationGenerator() + const moveMapStrategy = this.resolveMoveMapStrategy(options) + this.currentMoveMapStrategy = moveMapStrategy + this.currentDiagnosticsToConsole = this.resolveDiagnosticsToConsole() + const maiaCandidateMoves = Array.from( + new Set(options?.maiaCandidateMoves || []), + ).filter((move) => this.moves.includes(move)) + const maiaPolicy = Object.fromEntries( + Object.entries(options?.maiaPolicy || {}).filter( + ([move, prob]) => this.moves.includes(move) && Number.isFinite(prob), + ), + ) + const kSfFromOptions = Number.isFinite(options?.kSf) + ? Math.max(1, Math.floor(options?.kSf || 1)) + : undefined + const runContext: AnalysisRunContext = { + positionId: this.currentPositionId, + fen, + legalMoves: [...this.moves], + legalMoveCount: this.legalMoveCount, + maiaCandidateMoves, + maiaPolicy, + kSf: kSfFromOptions || 0, + targetDepth, + analysisStartedAtMs: this.currentAnalysisStartedAtMs, + diagnosticsToConsole: this.currentDiagnosticsToConsole, + } + + if ( + moveMapStrategy === 'searchmoves-all' && + runContext.legalMoves.length > 0 + ) { + try { + yield* this.streamEvaluationsSearchmovesAll(runContext) + return + } catch (error) { + console.warn( + 'Searchmoves-all Stockfish analysis failed, falling back to MultiPV-all:', + error, + ) + this.store = {} + if (!this.isActiveRun(runContext.positionId)) { + return + } + } + } + if ( + moveMapStrategy === 'staged-root-probe' && + runContext.legalMoves.length > 0 + ) { + try { + yield* this.streamEvaluationsStaged(runContext) + return + } catch (error) { + console.warn( + 'Staged Stockfish move-map analysis failed, falling back to MultiPV-all:', + error, + ) + this.store = {} + if (!this.isActiveRun(runContext.positionId)) { + return + } + } + } + this.sendMessage('ucinewgame') this.sendMessage(`position fen ${fen}`) this.sendMessage(`go depth ${targetDepth}`) - while (this.isEvaluating) { + while (this.isActiveRun(runContext.positionId)) { try { const evaluation = await this.getNextEvaluation() if (evaluation) { @@ -165,6 +279,10 @@ class Engine { } } + private isActiveRun(positionId: string): boolean { + return this.isEvaluating && this.currentPositionId === positionId + } + private async waitForReady(): Promise { if (!this.stockfish) return @@ -195,6 +313,1105 @@ class Engine { }) } + private resolveMoveMapStrategy( + options?: StockfishStreamOptions, + ): StockfishMoveMapStrategy { + const fromOptions = options?.moveMapStrategy + + let fromLocalStorage: string | null = null + if (typeof window !== 'undefined') { + try { + fromLocalStorage = + window.localStorage.getItem('maia.stockfishMoveMapStrategy') ?? + window.localStorage.getItem('stockfishMoveMapStrategy') + } catch { + fromLocalStorage = null + } + } + + const rawStrategy = + fromOptions || + fromLocalStorage || + process.env.NEXT_PUBLIC_STOCKFISH_MOVE_MAP_STRATEGY || + 'staged-root-probe' + + if (rawStrategy === 'staged-root-probe') return 'staged-root-probe' + if (rawStrategy === 'searchmoves-all') return 'searchmoves-all' + return 'staged-root-probe' + } + + private resolveDiagnosticsToConsole(): boolean { + if (typeof window !== 'undefined') { + try { + const raw = + window.localStorage.getItem('maia.stockfishDiagnostics') ?? + window.localStorage.getItem('stockfishDiagnostics') + if (raw) { + return ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase()) + } + } catch { + // ignore localStorage access failures + } + } + + const envValue = process.env.NEXT_PUBLIC_STOCKFISH_DIAGNOSTICS + if (envValue) { + return ['1', 'true', 'yes', 'on'].includes(envValue.toLowerCase()) + } + + return false + } + + private getRootProbePlan( + targetDepth: number, + _legalMoveCount: number, + ): RootProbePlan { + let screeningDepth = Math.max(4, Math.min(10, targetDepth - 6)) + if (screeningDepth >= targetDepth) { + screeningDepth = Math.max(1, targetDepth - 2) + } + + return { + screeningDepth, + } + } + + private async *streamMultiPvSnapshots( + runContext: AnalysisRunContext, + targetDepth: number, + requestedMultiPv: number, + ): AsyncGenerator<{ + depth: number + elapsedMs: number + moves: RootSearchResult[] + }> { + if (!this.stockfish || requestedMultiPv <= 0) { + return + } + + const effectiveMultiPv = Math.max( + 1, + Math.min(requestedMultiPv, runContext.legalMoveCount), + ) + const engine = this.stockfish + const originalListen = engine.listen + + await this.waitForReady() + if (!this.isActiveRun(runContext.positionId)) { + return + } + engine.uci(`setoption name MultiPV value ${effectiveMultiPv}`) + await this.waitForReady() + if (!this.isActiveRun(runContext.positionId)) { + return + } + const startedAtMs = Date.now() + const isBlackTurn = new Chess(runContext.fen).turn() === 'b' + + type ParsedPvInfo = { + move: string + cp: number + depth: number + mateIn?: number + isBound: boolean + multipv: number + } + + const depthBuckets: Record> = {} + let lastEmittedDepth = 0 + const snapshotQueue: { + depth: number + elapsedMs: number + moves: RootSearchResult[] + }[] = [] + let pendingResolver: + | ((value: { depth: number; elapsedMs: number; moves: RootSearchResult[] } | null) => void) + | null = null + let finished = false + + const shouldReplace = ( + current: ParsedPvInfo | undefined, + incoming: ParsedPvInfo, + ) => { + if (!current) return true + if (incoming.depth !== current.depth) { + return incoming.depth > current.depth + } + if (current.isBound !== incoming.isBound) { + return current.isBound && !incoming.isBound + } + return true + } + + const enqueueSnapshot = (snapshot: { + depth: number + elapsedMs: number + moves: RootSearchResult[] + }) => { + if (pendingResolver) { + const resolve = pendingResolver + pendingResolver = null + resolve(snapshot) + return + } + snapshotQueue.push(snapshot) + } + + const finish = () => { + if (finished) return + finished = true + if (pendingResolver) { + const resolve = pendingResolver + pendingResolver = null + resolve(null) + } + } + + const emitDepthIfReady = (depth: number) => { + const bucket = depthBuckets[depth] + if (!bucket || depth <= lastEmittedDepth) { + return + } + + const moves: RootSearchResult[] = [] + for (let i = 1; i <= effectiveMultiPv; i++) { + const info = bucket[i] + if (!info) return + moves.push({ + move: info.move, + cp: info.cp, + depth: info.depth, + mateIn: info.mateIn, + }) + } + + lastEmittedDepth = depth + enqueueSnapshot({ + depth, + elapsedMs: Date.now() - startedAtMs, + moves, + }) + } + + const parseInfoLine = (line: string) => { + if (!line.startsWith('info ')) return + + const depthMatch = line.match(/\bdepth (\d+)\b/) + const multipvMatch = line.match(/\bmultipv (\d+)\b/) + const scoreMatch = line.match( + /\bscore (?:cp (-?\d+)|mate (-?\d+))(?: (upperbound|lowerbound))?\b/, + ) + if (!depthMatch || !multipvMatch || !scoreMatch) { + return + } + + const multipv = Number.parseInt(multipvMatch[1], 10) + if (multipv < 1 || multipv > effectiveMultiPv) { + return + } + + const pvMatch = line.match(/\bpv ((?:\S+\s*)+)$/) + const move = pvMatch?.[1]?.trim().split(/\s+/)[0] + if (!move || !runContext.legalMoves.includes(move)) { + return + } + + let cp = Number.parseInt(scoreMatch[1], 10) + const mate = Number.parseInt(scoreMatch[2], 10) + let mateIn: number | undefined + + if (!Number.isFinite(cp) && Number.isFinite(mate)) { + mateIn = mate + cp = mate > 0 ? 10000 : -10000 + } + + if (!Number.isFinite(cp)) { + return + } + + if (isBlackTurn) { + cp *= -1 + } + + const depth = Number.parseInt(depthMatch[1], 10) + const parsed: ParsedPvInfo = { + move, + cp, + depth, + mateIn, + isBound: !!scoreMatch[3], + multipv, + } + + if (!depthBuckets[depth]) { + depthBuckets[depth] = {} + } + if (shouldReplace(depthBuckets[depth][multipv], parsed)) { + depthBuckets[depth][multipv] = parsed + } + + emitDepthIfReady(depth) + } + + try { + engine.listen = (msg: string) => { + if (!this.isActiveRun(runContext.positionId)) { + finish() + return + } + + const lines = msg + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + for (const line of lines) { + parseInfoLine(line) + if (line.startsWith('bestmove ')) { + const knownDepths = Object.keys(depthBuckets) + .map((d) => Number.parseInt(d, 10)) + .filter((d) => Number.isFinite(d)) + .sort((a, b) => a - b) + for (const depth of knownDepths) { + emitDepthIfReady(depth) + } + finish() + return + } + } + } + + engine.uci(`position fen ${runContext.fen}`) + engine.uci(`go depth ${targetDepth}`) + + while (true) { + if (!this.isActiveRun(runContext.positionId)) { + finish() + } + + const next = + snapshotQueue.length > 0 + ? snapshotQueue.shift() || null + : await new Promise<{ + depth: number + elapsedMs: number + moves: RootSearchResult[] + } | null>((resolve) => { + if (finished) { + resolve(null) + } else { + pendingResolver = resolve + } + }) + + if (!next) { + break + } + + yield next + } + } finally { + if (this.stockfish) { + this.stockfish.listen = originalListen + this.stockfish.uci('setoption name MultiPV value 100') + } + } + } + + private async *streamEvaluationsStaged( + runContext: AnalysisRunContext, + ): AsyncGenerator { + if (!this.stockfish) { + return + } + + if (runContext.legalMoves.length === 0) { + return + } + + try { + const targetDepth = runContext.targetDepth + const plan = this.getRootProbePlan(targetDepth, runContext.legalMoveCount) + const rootScores: Record = {} + const movePhases: Record = {} + const moveSelectionSources: Record = {} + const phaseTimesMs: Record = { + screening: 0, + multipv: 0, + deepening: 0, + } + const deepenedMoves = new Set() + + const markSelectionSource = ( + move: string, + source: CandidateSelectionSource, + ) => { + const existing = moveSelectionSources[move] + if (!existing) { + moveSelectionSources[move] = source + return + } + if (existing !== source) { + moveSelectionSources[move] = 'both' + } + } + + this.sendMessage('ucinewgame') + this.sendMessage(`position fen ${runContext.fen}`) + await this.waitForReady() + if (!this.isActiveRun(runContext.positionId)) { + return + } + + for (const move of runContext.legalMoves) { + if (!this.isActiveRun(runContext.positionId)) { + return + } + + const t0 = Date.now() + const probe = await this.runRootSearch( + runContext, + plan.screeningDepth, + move, + ) + phaseTimesMs.screening += Date.now() - t0 + if (!probe) { + throw new Error(`Failed to probe root move ${move}`) + } + + rootScores[move] = { + cp: probe.cp, + depth: Math.max(1, Math.min(plan.screeningDepth, probe.depth)), + mateIn: probe.mateIn, + } + movePhases[move] = 'screen' + } + + if (Object.keys(rootScores).length !== runContext.legalMoveCount) { + throw new Error( + `Incomplete staged screening (${Object.keys(rootScores).length}/${runContext.legalMoveCount})`, + ) + } + + if (plan.screeningDepth > 0 && plan.screeningDepth < targetDepth) { + const screeningEval = this.buildEvaluationFromRootScores( + plan.screeningDepth, + rootScores, + runContext.fen, + ) + this.attachDiagnostics(screeningEval, { + runContext, + strategy: 'staged-root-probe', + targetDepth, + legalMoveCount: runContext.legalMoveCount, + phaseTimesMs, + rootScores, + movePhases, + moveCounts: { + screened: runContext.legalMoveCount, + deepened: 0, + finalAtTargetDepth: Object.values(rootScores).filter( + (score) => score.depth >= targetDepth, + ).length, + }, + }) + this.maybePublishDiagnostics( + screeningEval, + false, + runContext.diagnosticsToConsole, + ) + yield screeningEval + } + + const stagedMultiPv = Math.max( + 1, + Math.min( + runContext.legalMoveCount, + runContext.kSf > 0 ? runContext.kSf : 4, + ), + ) + + for await (const multipvSnapshot of this.streamMultiPvSnapshots( + runContext, + targetDepth, + stagedMultiPv, + )) { + if (!this.isActiveRun(runContext.positionId)) { + return + } + + phaseTimesMs.multipv = multipvSnapshot.elapsedMs + for (const score of multipvSnapshot.moves) { + const existing = rootScores[score.move] + rootScores[score.move] = { + cp: score.cp, + depth: Math.max(existing?.depth || 0, score.depth), + mateIn: score.mateIn, + } + movePhases[score.move] = 'multipv' + deepenedMoves.add(score.move) + markSelectionSource(score.move, 'sf-top') + } + + const streamingEval = this.buildEvaluationFromRootScores( + Math.max(plan.screeningDepth, multipvSnapshot.depth), + rootScores, + runContext.fen, + ) + this.attachDiagnostics(streamingEval, { + runContext, + strategy: 'staged-root-probe', + targetDepth, + legalMoveCount: runContext.legalMoveCount, + phaseTimesMs, + rootScores, + movePhases, + moveSelectionSources, + moveCounts: { + screened: runContext.legalMoveCount, + deepened: deepenedMoves.size, + finalAtTargetDepth: Object.values(rootScores).filter( + (score) => score.depth >= targetDepth, + ).length, + }, + }) + this.maybePublishDiagnostics( + streamingEval, + false, + runContext.diagnosticsToConsole, + ) + yield streamingEval + } + + for (const move of runContext.maiaCandidateMoves) { + if (!this.isActiveRun(runContext.positionId)) { + return + } + + if (!rootScores[move]) { + continue + } + + markSelectionSource(move, 'maia-95') + const current = rootScores[move] + if (current && current.depth >= targetDepth) { + continue + } + + const t0 = Date.now() + const deepProbe = await this.runRootSearch( + runContext, + targetDepth, + move, + ) + phaseTimesMs.deepening += Date.now() - t0 + if (!deepProbe) { + continue + } + + rootScores[move] = { + cp: deepProbe.cp, + depth: Math.max(1, Math.min(targetDepth, deepProbe.depth)), + mateIn: deepProbe.mateIn, + } + movePhases[move] = 'deep' + deepenedMoves.add(move) + + const maiaDeepenedEval = this.buildEvaluationFromRootScores( + targetDepth, + rootScores, + runContext.fen, + ) + this.attachDiagnostics(maiaDeepenedEval, { + runContext, + strategy: 'staged-root-probe', + targetDepth, + legalMoveCount: runContext.legalMoveCount, + phaseTimesMs, + rootScores, + movePhases, + moveSelectionSources, + moveCounts: { + screened: runContext.legalMoveCount, + deepened: deepenedMoves.size, + finalAtTargetDepth: Object.values(rootScores).filter( + (score) => score.depth >= targetDepth, + ).length, + }, + }) + this.maybePublishDiagnostics( + maiaDeepenedEval, + false, + runContext.diagnosticsToConsole, + ) + yield maiaDeepenedEval + } + + if (!this.isActiveRun(runContext.positionId)) { + return + } + + const finalEval = this.buildEvaluationFromRootScores( + targetDepth, + rootScores, + runContext.fen, + ) + this.attachDiagnostics(finalEval, { + runContext, + strategy: 'staged-root-probe', + targetDepth, + legalMoveCount: runContext.legalMoveCount, + phaseTimesMs, + rootScores, + movePhases, + moveSelectionSources, + moveCounts: { + screened: runContext.legalMoveCount, + deepened: deepenedMoves.size, + finalAtTargetDepth: Object.values(rootScores).filter( + (score) => score.depth >= targetDepth, + ).length, + }, + }) + this.maybePublishDiagnostics( + finalEval, + true, + runContext.diagnosticsToConsole, + ) + yield finalEval + } finally { + if (this.currentPositionId === runContext.positionId) { + this.isEvaluating = false + } + } + } + + private async *streamEvaluationsSearchmovesAll( + runContext: AnalysisRunContext, + ): AsyncGenerator { + if (!this.stockfish || runContext.legalMoves.length === 0) { + return + } + + try { + const targetDepth = runContext.targetDepth + const rootScores: Record = {} + const movePhases: Record = {} + const phaseTimesMs: Record = { groundTruth: 0 } + + this.sendMessage('ucinewgame') + this.sendMessage(`position fen ${runContext.fen}`) + await this.waitForReady() + if (!this.isActiveRun(runContext.positionId)) { + return + } + + for (const move of runContext.legalMoves) { + if (!this.isActiveRun(runContext.positionId)) { + return + } + + const t0 = Date.now() + const probe = await this.runRootSearch(runContext, targetDepth, move) + phaseTimesMs.groundTruth += Date.now() - t0 + if (!probe) { + throw new Error(`Failed to analyze root move ${move}`) + } + + rootScores[move] = { + cp: probe.cp, + depth: Math.max(1, Math.min(targetDepth, probe.depth)), + mateIn: probe.mateIn, + } + movePhases[move] = 'ground-truth' + } + + const finalEval = this.buildEvaluationFromRootScores( + targetDepth, + rootScores, + runContext.fen, + ) + this.attachDiagnostics(finalEval, { + runContext, + strategy: 'searchmoves-all', + targetDepth, + legalMoveCount: runContext.legalMoveCount, + phaseTimesMs, + rootScores, + movePhases, + moveCounts: { + screened: 0, + deepened: runContext.legalMoveCount, + finalAtTargetDepth: Object.values(rootScores).filter( + (score) => score.depth >= targetDepth, + ).length, + }, + }) + this.maybePublishDiagnostics( + finalEval, + true, + runContext.diagnosticsToConsole, + ) + yield finalEval + } finally { + if (this.currentPositionId === runContext.positionId) { + this.isEvaluating = false + } + } + } + + private buildEvaluationFromRootScores( + depth: number, + rootScores: Record, + fen: string, + ): StockfishEvaluation { + const board = new Chess(fen) + const isBlackTurn = board.turn() === 'b' + + const sortedMoves = Object.entries(rootScores).sort(([, a], [, b]) => { + if (b.cp !== a.cp) return isBlackTurn ? a.cp - b.cp : b.cp - a.cp + + const aMate = a.mateIn + const bMate = b.mateIn + if (aMate === undefined && bMate === undefined) return 0 + if (aMate === undefined) return 1 + if (bMate === undefined) return -1 + + // Prefer faster wins and slower losses when cp values are both mapped to mate sentinels. + return Math.abs(aMate) - Math.abs(bMate) + }) + + if (sortedMoves.length === 0) { + throw new Error('No root move scores available') + } + + const [bestMove, bestScore] = sortedMoves[0] + + const cpVec: Record = {} + const cpRelativeVec: Record = {} + const winrateVec: Record = {} + const winrateLossVec: Record = {} + const rootMoveDepthVec: Record = {} + let mateVec: Record | undefined + + for (const [move, score] of sortedMoves) { + cpVec[move] = score.cp + cpRelativeVec[move] = isBlackTurn + ? bestScore.cp - score.cp + : score.cp - bestScore.cp + + const winrate = cpToWinrate(score.cp * (isBlackTurn ? -1 : 1), false) + winrateVec[move] = winrate + rootMoveDepthVec[move] = score.depth + + if (score.mateIn !== undefined) { + mateVec = mateVec || {} + mateVec[move] = score.mateIn + } + } + + const evaluation: StockfishEvaluation = { + sent: true, + depth, + model_move: bestMove, + model_optimal_cp: bestScore.cp, + cp_vec: cpVec, + cp_relative_vec: cpRelativeVec, + root_move_depth_vec: rootMoveDepthVec, + winrate_vec: winrateVec, + winrate_loss_vec: winrateLossVec, + mate_vec: mateVec, + is_checkmate: board.inCheckmate(), + } + + return this.finalizeEvaluation(evaluation) + } + + private buildMoveDiagnostics( + rootScores: Record, + movePhases?: Record, + moveSelectionSources?: Record, + maiaPolicy?: Record, + ): Record { + const diagnostics: Record = {} + + for (const [move, score] of Object.entries(rootScores)) { + diagnostics[move] = { + cp: score.cp, + depth: score.depth, + mateIn: score.mateIn, + phase: movePhases?.[move], + selectedBy: moveSelectionSources?.[move], + maiaProb: maiaPolicy?.[move], + } + } + + return diagnostics + } + + private attachDiagnostics( + evaluation: StockfishEvaluation, + params: { + runContext: AnalysisRunContext + strategy: StockfishDiagnostics['strategy'] + targetDepth: number + legalMoveCount: number + phaseTimesMs?: { [phase: string]: number } + rootScores: Record + movePhases?: Record + moveSelectionSources?: Record + moveCounts?: StockfishDiagnostics['moveCounts'] + }, + ) { + const { runContext } = params + evaluation.diagnostics = { + positionId: runContext.positionId, + fen: runContext.fen, + strategy: params.strategy, + targetDepth: params.targetDepth, + legalMoveCount: params.legalMoveCount, + totalTimeMs: Date.now() - runContext.analysisStartedAtMs, + phaseTimesMs: params.phaseTimesMs + ? { ...params.phaseTimesMs } + : undefined, + moveCounts: params.moveCounts ? { ...params.moveCounts } : undefined, + moves: this.buildMoveDiagnostics( + params.rootScores, + params.movePhases, + params.moveSelectionSources, + runContext.maiaPolicy, + ), + } + } + + private cloneEvaluation( + evaluation: StockfishEvaluation, + ): StockfishEvaluation { + return { + ...evaluation, + cp_vec: { ...evaluation.cp_vec }, + cp_relative_vec: { ...evaluation.cp_relative_vec }, + root_move_depth_vec: evaluation.root_move_depth_vec + ? { ...evaluation.root_move_depth_vec } + : undefined, + winrate_vec: evaluation.winrate_vec + ? { ...evaluation.winrate_vec } + : undefined, + winrate_loss_vec: evaluation.winrate_loss_vec + ? { ...evaluation.winrate_loss_vec } + : undefined, + mate_vec: evaluation.mate_vec ? { ...evaluation.mate_vec } : undefined, + diagnostics: evaluation.diagnostics + ? { + ...evaluation.diagnostics, + phaseTimesMs: evaluation.diagnostics.phaseTimesMs + ? { ...evaluation.diagnostics.phaseTimesMs } + : undefined, + moveCounts: evaluation.diagnostics.moveCounts + ? { ...evaluation.diagnostics.moveCounts } + : undefined, + moves: evaluation.diagnostics.moves + ? Object.fromEntries( + Object.entries(evaluation.diagnostics.moves).map( + ([move, data]) => [move, { ...data }], + ), + ) + : undefined, + } + : undefined, + } + } + + private maybePublishDiagnostics( + evaluation: StockfishEvaluation, + isFinalForRun: boolean, + diagnosticsToConsole = this.currentDiagnosticsToConsole, + ) { + if (!isFinalForRun) { + return + } + + const snapshot = this.cloneEvaluation(evaluation) + + if (typeof window !== 'undefined') { + try { + const globalObj = window as typeof window & { + __maiaStockfishDiagnostics?: StockfishEvaluation[] + } + const existing = globalObj.__maiaStockfishDiagnostics || [] + existing.push(snapshot) + globalObj.__maiaStockfishDiagnostics = existing.slice(-50) + } catch { + // ignore window publish failures + } + } + + if (!diagnosticsToConsole || !snapshot.diagnostics) { + return + } + + const d = snapshot.diagnostics + console.groupCollapsed( + `[Stockfish ${d.strategy}] depth ${snapshot.depth}/${d.targetDepth} in ${d.totalTimeMs}ms (${d.legalMoveCount} legal)`, + ) + console.log('Summary', { + positionId: d.positionId, + bestMove: snapshot.model_move, + bestCp: snapshot.model_optimal_cp, + totalTimeMs: d.totalTimeMs, + phaseTimesMs: d.phaseTimesMs, + moveCounts: d.moveCounts, + }) + if (d.moves) { + console.table( + Object.entries(d.moves) + .map(([move, data]) => ({ + move, + cp: data.cp, + depth: data.depth, + mateIn: data.mateIn, + phase: data.phase, + selectedBy: data.selectedBy, + maiaProb: data.maiaProb, + })) + .sort((a, b) => b.cp - a.cp), + ) + } + console.groupEnd() + } + + private finalizeEvaluation( + evaluation: StockfishEvaluation, + ): StockfishEvaluation { + let bestWinrate = -Infinity + + const winrateVec = evaluation.winrate_vec + if (winrateVec) { + for (const m in winrateVec) { + const wr = winrateVec[m] + if (wr > bestWinrate) { + bestWinrate = wr + } + } + + const winrateLossVec = evaluation.winrate_loss_vec + if (winrateLossVec) { + for (const m in winrateVec) { + winrateLossVec[m] = winrateVec[m] - bestWinrate + } + } + } + + if (evaluation.winrate_vec) { + evaluation.winrate_vec = Object.fromEntries( + Object.entries(evaluation.winrate_vec || {}).sort( + ([, a], [, b]) => b - a, + ), + ) + } + + if (evaluation.winrate_loss_vec) { + evaluation.winrate_loss_vec = Object.fromEntries( + Object.entries(evaluation.winrate_loss_vec || {}).sort( + ([, a], [, b]) => b - a, + ), + ) + } + + if (evaluation.mate_vec && Object.keys(evaluation.mate_vec).length === 0) { + delete evaluation.mate_vec + } + + return evaluation + } + + private async runRootSearch( + runContext: AnalysisRunContext, + depth: number, + searchMove?: string, + ): Promise { + if ( + !this.stockfish || + depth <= 0 || + !this.isActiveRun(runContext.positionId) + ) { + return null + } + + await this.waitForReady() + if (!this.isActiveRun(runContext.positionId)) { + return null + } + + const engine = this.stockfish + const originalListen = engine.listen + const expectedMove = searchMove + const isBlackTurn = new Chess(runContext.fen).turn() === 'b' + + type SearchInfo = { + move: string + cp: number + depth: number + mateIn?: number + isBound: boolean + } + + let latestInfo: SearchInfo | null = null + let bestPvInfo: SearchInfo | null = null + const infosByMove: Record = {} + + const shouldReplaceSearchInfo = ( + current: SearchInfo | null, + incoming: SearchInfo, + ) => { + if (!current) return true + if (incoming.depth !== current.depth) { + return incoming.depth > current.depth + } + if (current.isBound !== incoming.isBound) { + return current.isBound && !incoming.isBound + } + return true + } + + const parseInfoLine = (line: string) => { + if (!line.startsWith('info ')) return + + const depthMatch = line.match(/\bdepth (\d+)\b/) + const multipvMatch = line.match(/\bmultipv (\d+)\b/) + const scoreMatch = line.match( + /\bscore (?:cp (-?\d+)|mate (-?\d+))(?: (upperbound|lowerbound))?\b/, + ) + if (!depthMatch || !scoreMatch) { + return + } + + const pvMatch = line.match(/\bpv ((?:\S+\s*)+)$/) + const moveFromPv = pvMatch?.[1]?.trim().split(/\s+/)[0] + if (expectedMove) { + if (!moveFromPv || moveFromPv !== expectedMove) { + return + } + } else if (!moveFromPv) { + return + } + + const move = expectedMove || moveFromPv + if (!move || !runContext.legalMoves.includes(move)) { + return + } + + let cp = Number.parseInt(scoreMatch[1], 10) + const mate = Number.parseInt(scoreMatch[2], 10) + let mateIn: number | undefined + + if (!Number.isFinite(cp) && Number.isFinite(mate)) { + mateIn = mate + cp = mate > 0 ? 10000 : -10000 + } + + if (!Number.isFinite(cp)) { + return + } + + if (isBlackTurn) { + cp *= -1 + } + + const info: SearchInfo = { + move, + cp, + depth: Number.parseInt(depthMatch[1], 10), + mateIn, + isBound: !!scoreMatch[3], + } + + if (shouldReplaceSearchInfo(latestInfo, info)) { + latestInfo = info + } + + const currentForMove = infosByMove[move] + if (shouldReplaceSearchInfo(currentForMove || null, info)) { + infosByMove[move] = info + } + + const multipv = Number.parseInt(multipvMatch?.[1] ?? '1', 10) + if (multipv === 1 && shouldReplaceSearchInfo(bestPvInfo, info)) { + bestPvInfo = info + } + } + + return new Promise((resolve) => { + let resolved = false + + const finish = (result: RootSearchResult | null) => { + if (resolved) return + resolved = true + if (this.stockfish) { + this.stockfish.listen = originalListen + } + resolve(result) + } + + engine.listen = (msg: string) => { + const lines = msg + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + for (const line of lines) { + parseInfoLine(line) + + const bestMoveMatch = line.match(/^bestmove (\S+)/) + if (!bestMoveMatch) { + continue + } + + if (!this.isActiveRun(runContext.positionId)) { + finish(null) + return + } + + const engineBestMove = + bestMoveMatch[1] && bestMoveMatch[1] !== '(none)' + ? bestMoveMatch[1] + : undefined + const resolvedMove = + expectedMove || engineBestMove || latestInfo?.move + const resolvedInfo = + (resolvedMove ? infosByMove[resolvedMove] : undefined) || + (expectedMove ? latestInfo : null) || + bestPvInfo || + latestInfo + + if ( + !resolvedInfo || + !resolvedMove || + !runContext.legalMoves.includes(resolvedMove) + ) { + finish(null) + return + } + + finish({ + move: resolvedMove, + cp: resolvedInfo.cp, + depth: resolvedInfo.depth, + mateIn: resolvedInfo.mateIn, + }) + return + } + } + + engine.uci(`position fen ${runContext.fen}`) + engine.uci( + searchMove + ? `go depth ${depth} searchmoves ${searchMove}` + : `go depth ${depth}`, + ) + }) + } + private onMessage(msg: string) { // Only process evaluation messages if we're currently evaluating if (!this.isEvaluating) { @@ -267,6 +1484,10 @@ class Engine { this.store[depth].cp_relative_vec[move] = isBlackTurn ? this.store[depth].model_optimal_cp - cp : cp - this.store[depth].model_optimal_cp + if (!this.store[depth].root_move_depth_vec) { + this.store[depth].root_move_depth_vec = {} + } + this.store[depth].root_move_depth_vec[move] = depth if (mateIn !== undefined) { if (!this.store[depth].mate_vec) { @@ -302,6 +1523,7 @@ class Engine { model_optimal_cp: cp, cp_vec: { [move]: cp }, cp_relative_vec: { [move]: 0 }, + root_move_depth_vec: { [move]: depth }, winrate_vec: { [move]: winrate }, winrate_loss_vec: { [move]: 0 }, mate_vec: mateIn !== undefined ? { [move]: mateIn } : undefined, @@ -310,51 +1532,61 @@ class Engine { } if (!this.store[depth].sent && multipv === this.legalMoveCount) { - let bestWinrate = -Infinity - - const winrateVec = this.store[depth].winrate_vec - if (winrateVec) { - for (const m in winrateVec) { - const wr = winrateVec[m] - if (wr > bestWinrate) { - bestWinrate = wr - } - } - - const winrateLossVec = this.store[depth].winrate_loss_vec - if (winrateLossVec) { - for (const m in winrateVec) { - winrateLossVec[m] = winrateVec[m] - bestWinrate - } - } - } - - if (this.store[depth].winrate_vec) { - this.store[depth].winrate_vec = Object.fromEntries( - Object.entries(this.store[depth].winrate_vec || {}).sort( - ([, a], [, b]) => b - a, - ), - ) - } - - if (this.store[depth].winrate_loss_vec) { - this.store[depth].winrate_loss_vec = Object.fromEntries( - Object.entries(this.store[depth].winrate_loss_vec || {}).sort( - ([, a], [, b]) => b - a, - ), - ) - } - - // Check if position is checkmate (no legal moves and king in check) + this.store[depth] = this.finalizeEvaluation(this.store[depth]) const board = new Chess(this.fen) this.store[depth].is_checkmate = board.inCheckmate() - + this.attachDiagnostics(this.store[depth], { + runContext: { + positionId: this.currentPositionId, + fen: this.fen, + legalMoves: [...this.moves], + legalMoveCount: this.legalMoveCount, + maiaCandidateMoves: [], + maiaPolicy: {}, + kSf: 0, + targetDepth: this.currentTargetDepth, + analysisStartedAtMs: this.currentAnalysisStartedAtMs, + diagnosticsToConsole: this.currentDiagnosticsToConsole, + }, + strategy: 'multipv-all', + targetDepth: this.currentTargetDepth, + legalMoveCount: this.legalMoveCount, + phaseTimesMs: undefined, + rootScores: Object.fromEntries( + Object.entries(this.store[depth].cp_vec).map(([rootMove, rootCp]) => [ + rootMove, + { + cp: rootCp, + depth: this.store[depth].root_move_depth_vec?.[rootMove] ?? depth, + mateIn: this.store[depth].mate_vec?.[rootMove], + }, + ]), + ), + movePhases: Object.fromEntries( + Object.keys(this.store[depth].cp_vec).map((rootMove) => [ + rootMove, + 'multipv', + ]), + ) as Record, + moveCounts: { + finalAtTargetDepth: Object.values( + this.store[depth].root_move_depth_vec || {}, + ).filter((d) => d >= this.currentTargetDepth).length, + }, + }) this.store[depth].sent = true + this.maybePublishDiagnostics( + this.store[depth], + depth >= this.currentTargetDepth && this.currentTargetDepth > 0, + ) if (this.evaluationResolver) { this.evaluationResolver(this.store[depth]) this.evaluationResolver = null this.evaluationRejecter = null } + if (depth >= this.currentTargetDepth && this.currentTargetDepth > 0) { + this.stopEvaluation() + } } } diff --git a/src/types/analysis.ts b/src/types/analysis.ts index a9e3c3d8..a14fc940 100644 --- a/src/types/analysis.ts +++ b/src/types/analysis.ts @@ -20,6 +20,31 @@ export interface MaiaEvaluation { policy: { [key: string]: number } } +export interface StockfishMoveDiagnostic { + cp: number + depth: number + mateIn?: number + phase?: 'multipv' | 'screen' | 'deep' | 'ground-truth' + selectedBy?: 'sf-top' | 'maia-95' | 'both' + maiaProb?: number +} + +export interface StockfishDiagnostics { + positionId: string + fen: string + strategy: 'multipv-all' | 'staged-root-probe' | 'searchmoves-all' + targetDepth: number + legalMoveCount: number + totalTimeMs: number + phaseTimesMs?: { [phase: string]: number } + moveCounts?: { + screened?: number + deepened?: number + finalAtTargetDepth?: number + } + moves?: { [move: string]: StockfishMoveDiagnostic } +} + export interface StockfishEvaluation { sent: boolean depth: number @@ -27,6 +52,8 @@ export interface StockfishEvaluation { model_optimal_cp: number cp_vec: { [key: string]: number } cp_relative_vec: { [key: string]: number } + root_move_depth_vec?: { [key: string]: number } + diagnostics?: StockfishDiagnostics winrate_vec?: { [key: string]: number } winrate_loss_vec?: { [key: string]: number } mate_vec?: { [key: string]: number } diff --git a/src/types/engine.ts b/src/types/engine.ts index bf77c853..1a412a67 100644 --- a/src/types/engine.ts +++ b/src/types/engine.ts @@ -9,6 +9,17 @@ export type MaiaStatus = | 'error' export type StockfishStatus = 'loading' | 'ready' | 'error' +export type StockfishMoveMapStrategy = + | 'multipv-all' + | 'staged-root-probe' + | 'searchmoves-all' + +export interface StockfishStreamOptions { + moveMapStrategy?: StockfishMoveMapStrategy + maiaCandidateMoves?: string[] + maiaPolicy?: { [move: string]: number } + kSf?: number +} export interface MaiaEngine { maia?: Maia @@ -26,5 +37,6 @@ export interface StockfishEngine { fen: string, moveCount: number, depth?: number, + options?: StockfishStreamOptions, ) => AsyncIterable | null } diff --git a/src/types/node.ts b/src/types/node.ts index 717b6b3d..a02504e7 100644 --- a/src/types/node.ts +++ b/src/types/node.ts @@ -309,11 +309,46 @@ export class GameNode { stockfishEval: StockfishEvaluation, activeModel?: string, ): void { - if ( - this._analysis.stockfish && - this._analysis.stockfish.depth >= stockfishEval.depth - ) { - return + const existingStockfish = this._analysis.stockfish + if (existingStockfish) { + if (existingStockfish.depth > stockfishEval.depth) { + return + } + + if (existingStockfish.depth === stockfishEval.depth) { + const existingDiagnostics = existingStockfish.diagnostics + const incomingDiagnostics = stockfishEval.diagnostics + const existingStrategy = existingStockfish.diagnostics?.strategy + const incomingStrategy = stockfishEval.diagnostics?.strategy + const existingPositionId = existingStockfish.diagnostics?.positionId + const incomingPositionId = stockfishEval.diagnostics?.positionId + const isSameRun = + !!incomingStrategy && + !!existingStrategy && + incomingStrategy === existingStrategy && + !!incomingPositionId && + !!existingPositionId && + incomingPositionId === existingPositionId + const incomingIsLaterSameRunSnapshot = + isSameRun && + !!incomingDiagnostics && + !!existingDiagnostics && + incomingDiagnostics.totalTimeMs >= existingDiagnostics.totalTimeMs + + const shouldReplaceSameDepth = + (incomingStrategy && + existingStrategy && + incomingStrategy !== existingStrategy) || + (incomingPositionId && + existingPositionId && + incomingPositionId !== existingPositionId) || + (!!stockfishEval.diagnostics && !existingStockfish.diagnostics) || + incomingIsLaterSameRunSnapshot + + if (!shouldReplaceSameDepth) { + return + } + } } this._analysis.stockfish = stockfishEval From 37e2297f74bab65fc0b918462cfa7ecf2497f3cb Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 00:28:14 -0500 Subject: [PATCH 02/12] Fix stockfish formatting for CI --- src/lib/engine/stockfish.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts index 91b159f1..445e1139 100644 --- a/src/lib/engine/stockfish.ts +++ b/src/lib/engine/stockfish.ts @@ -425,7 +425,13 @@ class Engine { moves: RootSearchResult[] }[] = [] let pendingResolver: - | ((value: { depth: number; elapsedMs: number; moves: RootSearchResult[] } | null) => void) + | (( + value: { + depth: number + elapsedMs: number + moves: RootSearchResult[] + } | null, + ) => void) | null = null let finished = false From c6a5cc7db28f7b4e69cf89a26051be0f6c96d76f Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 00:40:00 -0500 Subject: [PATCH 03/12] Fix staged probes by ordering searchmoves before depth --- src/lib/engine/stockfish.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts index 445e1139..8e2fe913 100644 --- a/src/lib/engine/stockfish.ts +++ b/src/lib/engine/stockfish.ts @@ -1412,7 +1412,7 @@ class Engine { engine.uci(`position fen ${runContext.fen}`) engine.uci( searchMove - ? `go depth ${depth} searchmoves ${searchMove}` + ? `go searchmoves ${searchMove} depth ${depth}` : `go depth ${depth}`, ) }) From 02f38bb103b2d8784e80301407c4f526ae158b9f Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 09:28:49 -0500 Subject: [PATCH 04/12] Revert "Fix staged probes by ordering searchmoves before depth" This reverts commit c6a5cc7db28f7b4e69cf89a26051be0f6c96d76f. --- src/lib/engine/stockfish.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts index 8e2fe913..445e1139 100644 --- a/src/lib/engine/stockfish.ts +++ b/src/lib/engine/stockfish.ts @@ -1412,7 +1412,7 @@ class Engine { engine.uci(`position fen ${runContext.fen}`) engine.uci( searchMove - ? `go searchmoves ${searchMove} depth ${depth}` + ? `go depth ${depth} searchmoves ${searchMove}` : `go depth ${depth}`, ) }) From d72fefa80430083f6784e1795c1ce154ab4a9775 Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 09:32:11 -0500 Subject: [PATCH 05/12] Harden staged screening and retry searchmoves probes --- src/lib/engine/stockfish.ts | 77 +++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts index 445e1139..7081a22e 100644 --- a/src/lib/engine/stockfish.ts +++ b/src/lib/engine/stockfish.ts @@ -669,28 +669,40 @@ class Engine { return } - for (const move of runContext.legalMoves) { + let lastScreeningSnapshot: { + depth: number + elapsedMs: number + moves: RootSearchResult[] + } | null = null + + for await (const screeningSnapshot of this.streamMultiPvSnapshots( + runContext, + plan.screeningDepth, + runContext.legalMoveCount, + )) { if (!this.isActiveRun(runContext.positionId)) { return } + lastScreeningSnapshot = screeningSnapshot + } - const t0 = Date.now() - const probe = await this.runRootSearch( - runContext, - plan.screeningDepth, - move, - ) - phaseTimesMs.screening += Date.now() - t0 - if (!probe) { - throw new Error(`Failed to probe root move ${move}`) - } + if (!lastScreeningSnapshot || lastScreeningSnapshot.moves.length === 0) { + throw new Error('Failed to collect staged screening scores') + } - rootScores[move] = { - cp: probe.cp, - depth: Math.max(1, Math.min(plan.screeningDepth, probe.depth)), - mateIn: probe.mateIn, + phaseTimesMs.screening = lastScreeningSnapshot.elapsedMs + const screeningDepthAchieved = Math.max( + 1, + Math.min(plan.screeningDepth, lastScreeningSnapshot.depth), + ) + + for (const score of lastScreeningSnapshot.moves) { + rootScores[score.move] = { + cp: score.cp, + depth: Math.max(1, Math.min(plan.screeningDepth, score.depth)), + mateIn: score.mateIn, } - movePhases[move] = 'screen' + movePhases[score.move] = 'screen' } if (Object.keys(rootScores).length !== runContext.legalMoveCount) { @@ -701,7 +713,7 @@ class Engine { if (plan.screeningDepth > 0 && plan.screeningDepth < targetDepth) { const screeningEval = this.buildEvaluationFromRootScores( - plan.screeningDepth, + screeningDepthAchieved, rootScores, runContext.fen, ) @@ -805,7 +817,7 @@ class Engine { } const t0 = Date.now() - const deepProbe = await this.runRootSearch( + const deepProbe = await this.runRootSearchWithRetry( runContext, targetDepth, move, @@ -918,7 +930,11 @@ class Engine { } const t0 = Date.now() - const probe = await this.runRootSearch(runContext, targetDepth, move) + const probe = await this.runRootSearchWithRetry( + runContext, + targetDepth, + move, + ) phaseTimesMs.groundTruth += Date.now() - t0 if (!probe) { throw new Error(`Failed to analyze root move ${move}`) @@ -1231,10 +1247,29 @@ class Engine { return evaluation } + private async runRootSearchWithRetry( + runContext: AnalysisRunContext, + depth: number, + searchMove?: string, + ): Promise { + const primary = await this.runRootSearch( + runContext, + depth, + searchMove, + false, + ) + if (primary || !searchMove) { + return primary + } + + return this.runRootSearch(runContext, depth, searchMove, true) + } + private async runRootSearch( runContext: AnalysisRunContext, depth: number, searchMove?: string, + searchMovesFirst = false, ): Promise { if ( !this.stockfish || @@ -1412,7 +1447,9 @@ class Engine { engine.uci(`position fen ${runContext.fen}`) engine.uci( searchMove - ? `go depth ${depth} searchmoves ${searchMove}` + ? searchMovesFirst + ? `go searchmoves ${searchMove} depth ${depth}` + : `go depth ${depth} searchmoves ${searchMove}` : `go depth ${depth}`, ) }) From 3af9858526aa71aef2401a8b66294d0d4ac8d460 Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 10:07:49 -0500 Subject: [PATCH 06/12] Increase Stockfish ready wait and reduce startup warning noise --- src/hooks/useAnalysisController/useEngineAnalysis.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useAnalysisController/useEngineAnalysis.ts b/src/hooks/useAnalysisController/useEngineAnalysis.ts index cf64ca59..c5643b24 100644 --- a/src/hooks/useAnalysisController/useEngineAnalysis.ts +++ b/src/hooks/useAnalysisController/useEngineAnalysis.ts @@ -184,9 +184,9 @@ export const useEngineAnalysis = ( // Add retry logic for Stockfish initialization const attemptStockfishAnalysis = async () => { - // Wait up to 3 seconds for Stockfish to be ready + // Wait longer for Stockfish to be ready on first load / slower devices. let retries = 0 - const maxRetries = 30 // 3 seconds with 100ms intervals + const maxRetries = 120 // 12 seconds with 100ms intervals while (retries < maxRetries && !stockfish.isReady() && !cancelled) { await new Promise((resolve) => setTimeout(resolve, 100)) @@ -194,7 +194,7 @@ export const useEngineAnalysis = ( } if (cancelled || !stockfish.isReady()) { - if (!cancelled) { + if (!cancelled && stockfish.status === 'error') { console.warn('Stockfish not ready after waiting, skipping analysis') } return From 1a6c5e4e9efe54571a6fb8865f9a2e1dd1df424a Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 10:21:51 -0500 Subject: [PATCH 07/12] Retry missing node analysis and tighten Maia skip condition --- .../useEngineAnalysis.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAnalysisController/useEngineAnalysis.ts b/src/hooks/useAnalysisController/useEngineAnalysis.ts index c5643b24..81edeef3 100644 --- a/src/hooks/useAnalysisController/useEngineAnalysis.ts +++ b/src/hooks/useAnalysisController/useEngineAnalysis.ts @@ -20,6 +20,7 @@ export const useEngineAnalysis = ( const stockfish = useContext(StockfishEngineContext) const [stockfishDebugRerunToken, setStockfishDebugRerunToken] = useState(0) const lastConsumedStockfishRerunTokenRef = useRef(0) + const [analysisRetryTick, setAnalysisRetryTick] = useState(0) const readRerunTokenFromStorage = () => { if (typeof window === 'undefined') return 0 @@ -50,6 +51,16 @@ export const useEngineAnalysis = ( } }, []) + useEffect(() => { + if (typeof window === 'undefined') return + const intervalId = window.setInterval(() => { + setAnalysisRetryTick((tick) => tick + 1) + }, 2000) + return () => { + window.clearInterval(intervalId) + } + }, []) + async function inferenceMaiaModel(board: Chess): Promise<{ [key: string]: MaiaEvaluation }> { @@ -84,10 +95,11 @@ export const useEngineAnalysis = ( const nodeFen = currentNode.fen const attemptMaiaAnalysis = async () => { + const hasSelectedModelAnalysis = + !!currentNode?.analysis.maia?.[currentMaiaModel] if ( !currentNode || - (currentNode.analysis.maia && - Object.keys(currentNode.analysis.maia).length > 0) || + hasSelectedModelAnalysis || inProgressAnalyses.has(nodeFen) ) return @@ -142,6 +154,8 @@ export const useEngineAnalysis = ( currentNode.addMaiaAnalysis(maiaEvaluations, currentMaiaModel) setAnalysisState((state) => state + 1) } + } catch (error) { + console.warn('Failed to run Maia analysis:', error) } finally { inProgressAnalyses.delete(nodeFen) } @@ -162,6 +176,7 @@ export const useEngineAnalysis = ( inProgressAnalyses, maia, setAnalysisState, + analysisRetryTick, ]) useEffect(() => { @@ -275,5 +290,6 @@ export const useEngineAnalysis = ( targetDepth, stockfishDebugRerunToken, currentNode?.analysis.maia?.[currentMaiaModel]?.policy, + analysisRetryTick, ]) } From b2a0add199f772899fe4e8b45792f324a89844cc Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 10:34:23 -0500 Subject: [PATCH 08/12] fix: stream staged screening incrementally without bootstrap pass --- .../useEngineAnalysis.ts | 15 --- src/lib/engine/stockfish.ts | 97 ++++++++++--------- 2 files changed, 52 insertions(+), 60 deletions(-) diff --git a/src/hooks/useAnalysisController/useEngineAnalysis.ts b/src/hooks/useAnalysisController/useEngineAnalysis.ts index 81edeef3..d36b88af 100644 --- a/src/hooks/useAnalysisController/useEngineAnalysis.ts +++ b/src/hooks/useAnalysisController/useEngineAnalysis.ts @@ -20,7 +20,6 @@ export const useEngineAnalysis = ( const stockfish = useContext(StockfishEngineContext) const [stockfishDebugRerunToken, setStockfishDebugRerunToken] = useState(0) const lastConsumedStockfishRerunTokenRef = useRef(0) - const [analysisRetryTick, setAnalysisRetryTick] = useState(0) const readRerunTokenFromStorage = () => { if (typeof window === 'undefined') return 0 @@ -51,16 +50,6 @@ export const useEngineAnalysis = ( } }, []) - useEffect(() => { - if (typeof window === 'undefined') return - const intervalId = window.setInterval(() => { - setAnalysisRetryTick((tick) => tick + 1) - }, 2000) - return () => { - window.clearInterval(intervalId) - } - }, []) - async function inferenceMaiaModel(board: Chess): Promise<{ [key: string]: MaiaEvaluation }> { @@ -154,8 +143,6 @@ export const useEngineAnalysis = ( currentNode.addMaiaAnalysis(maiaEvaluations, currentMaiaModel) setAnalysisState((state) => state + 1) } - } catch (error) { - console.warn('Failed to run Maia analysis:', error) } finally { inProgressAnalyses.delete(nodeFen) } @@ -176,7 +163,6 @@ export const useEngineAnalysis = ( inProgressAnalyses, maia, setAnalysisState, - analysisRetryTick, ]) useEffect(() => { @@ -290,6 +276,5 @@ export const useEngineAnalysis = ( targetDepth, stockfishDebugRerunToken, currentNode?.analysis.maia?.[currentMaiaModel]?.policy, - analysisRetryTick, ]) } diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts index 7081a22e..994afabe 100644 --- a/src/lib/engine/stockfish.ts +++ b/src/lib/engine/stockfish.ts @@ -683,26 +683,63 @@ class Engine { if (!this.isActiveRun(runContext.positionId)) { return } + lastScreeningSnapshot = screeningSnapshot - } + phaseTimesMs.screening = screeningSnapshot.elapsedMs + const screeningDepthAchieved = Math.max( + 1, + Math.min(plan.screeningDepth, screeningSnapshot.depth), + ) - if (!lastScreeningSnapshot || lastScreeningSnapshot.moves.length === 0) { - throw new Error('Failed to collect staged screening scores') - } + for (const score of screeningSnapshot.moves) { + rootScores[score.move] = { + cp: score.cp, + depth: Math.max( + rootScores[score.move]?.depth || 0, + Math.max(1, Math.min(plan.screeningDepth, score.depth)), + ), + mateIn: score.mateIn, + } + movePhases[score.move] = 'screen' + } - phaseTimesMs.screening = lastScreeningSnapshot.elapsedMs - const screeningDepthAchieved = Math.max( - 1, - Math.min(plan.screeningDepth, lastScreeningSnapshot.depth), - ) + if (Object.keys(rootScores).length !== runContext.legalMoveCount) { + continue + } - for (const score of lastScreeningSnapshot.moves) { - rootScores[score.move] = { - cp: score.cp, - depth: Math.max(1, Math.min(plan.screeningDepth, score.depth)), - mateIn: score.mateIn, + if (screeningDepthAchieved < targetDepth) { + const screeningEval = this.buildEvaluationFromRootScores( + screeningDepthAchieved, + rootScores, + runContext.fen, + ) + this.attachDiagnostics(screeningEval, { + runContext, + strategy: 'staged-root-probe', + targetDepth, + legalMoveCount: runContext.legalMoveCount, + phaseTimesMs, + rootScores, + movePhases, + moveCounts: { + screened: runContext.legalMoveCount, + deepened: 0, + finalAtTargetDepth: Object.values(rootScores).filter( + (score) => score.depth >= targetDepth, + ).length, + }, + }) + this.maybePublishDiagnostics( + screeningEval, + false, + runContext.diagnosticsToConsole, + ) + yield screeningEval } - movePhases[score.move] = 'screen' + } + + if (!lastScreeningSnapshot || lastScreeningSnapshot.moves.length === 0) { + throw new Error('Failed to collect staged screening scores') } if (Object.keys(rootScores).length !== runContext.legalMoveCount) { @@ -711,36 +748,6 @@ class Engine { ) } - if (plan.screeningDepth > 0 && plan.screeningDepth < targetDepth) { - const screeningEval = this.buildEvaluationFromRootScores( - screeningDepthAchieved, - rootScores, - runContext.fen, - ) - this.attachDiagnostics(screeningEval, { - runContext, - strategy: 'staged-root-probe', - targetDepth, - legalMoveCount: runContext.legalMoveCount, - phaseTimesMs, - rootScores, - movePhases, - moveCounts: { - screened: runContext.legalMoveCount, - deepened: 0, - finalAtTargetDepth: Object.values(rootScores).filter( - (score) => score.depth >= targetDepth, - ).length, - }, - }) - this.maybePublishDiagnostics( - screeningEval, - false, - runContext.diagnosticsToConsole, - ) - yield screeningEval - } - const stagedMultiPv = Math.max( 1, Math.min( From c2949d3025f3dc950c4743ee9e14346bb544318b Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Thu, 5 Mar 2026 10:51:55 -0500 Subject: [PATCH 09/12] fix: stabilize analysis move labels and show stockfish depth in header --- src/components/Analysis/Highlight.tsx | 48 +++++++++++++++++---------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/components/Analysis/Highlight.tsx b/src/components/Analysis/Highlight.tsx index 3b2c6b4e..dff2410e 100644 --- a/src/components/Analysis/Highlight.tsx +++ b/src/components/Analysis/Highlight.tsx @@ -357,6 +357,8 @@ export const Highlight: React.FC = ({ const useCompactMobileColumnTitles = isMobile && !simplified const mobileMaiaColumnTitle = `Maia ${currentMaiaModel.slice(-4)}: Human Moves` const mobileStockfishColumnTitle = 'SF 17: Engine Moves' + const stockfishDepth = moveEvaluation?.stockfish?.depth + const stockfishDepthLabel = stockfishDepth ? `d${stockfishDepth}` : null const openMaiaHeaderPicker = () => { const select = maiaHeaderSelectRef.current as | (HTMLSelectElement & { showPicker?: () => void }) @@ -496,10 +498,13 @@ export const Highlight: React.FC = ({ return (