Skip to content

Commit 817ec47

Browse files
Merge pull request #108 from CSSLab/dev
Add new excellent move criteria + move tooltips to Portals
2 parents 5db4706 + 79fd04b commit 817ec47

4 files changed

Lines changed: 248 additions & 23 deletions

File tree

__tests__/types/tree.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { GameNode } from 'src/types/base/tree'
2+
import { StockfishEvaluation, MaiaEvaluation } from 'src/types'
3+
4+
describe('GameNode Move Classification', () => {
5+
describe('Excellent Move Criteria', () => {
6+
it('should classify move as excellent when Maia probability < 10% and winrate is 10% higher than weighted average', () => {
7+
const parentNode = new GameNode(
8+
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
9+
)
10+
11+
// Mock Stockfish evaluation with winrate vectors
12+
const stockfishEval: StockfishEvaluation = {
13+
sent: true,
14+
depth: 15,
15+
model_move: 'e2e4',
16+
model_optimal_cp: 50,
17+
cp_vec: { e2e4: 50, d2d4: 40, g1f3: 30 },
18+
cp_relative_vec: { e2e4: 0, d2d4: -10, g1f3: -20 },
19+
winrate_vec: { e2e4: 0.6, d2d4: 0.58, g1f3: 0.4 },
20+
winrate_loss_vec: { e2e4: 0, d2d4: -0.02, g1f3: -0.2 },
21+
}
22+
23+
// Mock Maia evaluation with policy probabilities
24+
const maiaEval: { [rating: string]: MaiaEvaluation } = {
25+
maia_kdd_1500: {
26+
policy: {
27+
e2e4: 0.5, // 50% probability - most likely move
28+
d2d4: 0.3, // 30% probability
29+
g1f3: 0.05, // 5% probability - less than 10% threshold
30+
},
31+
value: 0.6,
32+
},
33+
}
34+
35+
// Add analysis to parent node
36+
parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
37+
parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
38+
39+
// Calculate weighted average manually for verification:
40+
// weighted_avg = (0.5 * 0.6 + 0.3 * 0.58 + 0.05 * 0.4) / (0.5 + 0.3 + 0.05)
41+
// weighted_avg = (0.3 + 0.174 + 0.02) / 0.85 = 0.494 / 0.85 ≈ 0.581
42+
// g1f3 winrate (0.4) is NOT 10% higher than weighted average (0.581)
43+
// So g1f3 should NOT be excellent despite low Maia probability
44+
45+
// Test move with low Maia probability but not high enough winrate
46+
const classificationG1f3 = GameNode.classifyMove(
47+
parentNode,
48+
'g1f3',
49+
'maia_kdd_1500',
50+
)
51+
expect(classificationG1f3.excellent).toBe(false)
52+
53+
// Now test with a different scenario where a move has both low probability and high winrate
54+
const stockfishEval2: StockfishEvaluation = {
55+
sent: true,
56+
depth: 15,
57+
model_move: 'e2e4',
58+
model_optimal_cp: 50,
59+
cp_vec: { e2e4: 50, d2d4: 40, b1c3: 45 },
60+
cp_relative_vec: { e2e4: 0, d2d4: -10, b1c3: -5 },
61+
winrate_vec: { e2e4: 0.6, d2d4: 0.45, b1c3: 0.7 },
62+
winrate_loss_vec: { e2e4: 0, d2d4: -0.15, b1c3: 0.1 },
63+
}
64+
65+
const maiaEval2: { [rating: string]: MaiaEvaluation } = {
66+
maia_kdd_1500: {
67+
policy: {
68+
e2e4: 0.6, // 60% probability
69+
d2d4: 0.35, // 35% probability
70+
b1c3: 0.05, // 5% probability - less than 10% threshold
71+
},
72+
value: 0.6,
73+
},
74+
}
75+
76+
const parentNode2 = new GameNode(
77+
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
78+
)
79+
parentNode2.addStockfishAnalysis(stockfishEval2, 'maia_kdd_1500')
80+
parentNode2.addMaiaAnalysis(maiaEval2, 'maia_kdd_1500')
81+
82+
// Calculate weighted average: (0.6 * 0.6 + 0.35 * 0.45 + 0.05 * 0.7) / 1.0
83+
// = (0.36 + 0.1575 + 0.035) / 1.0 = 0.5525
84+
// b1c3 winrate (0.7) is about 14.75% higher than weighted average (0.5525)
85+
// So b1c3 should be excellent (low Maia probability AND high relative winrate)
86+
87+
const classificationB1c3 = GameNode.classifyMove(
88+
parentNode2,
89+
'b1c3',
90+
'maia_kdd_1500',
91+
)
92+
expect(classificationB1c3.excellent).toBe(true)
93+
})
94+
95+
it('should not classify move as excellent when Maia probability >= 10%', () => {
96+
const parentNode = new GameNode(
97+
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
98+
)
99+
100+
const stockfishEval: StockfishEvaluation = {
101+
sent: true,
102+
depth: 15,
103+
model_move: 'e2e4',
104+
model_optimal_cp: 50,
105+
cp_vec: { e2e4: 50, d2d4: 40 },
106+
cp_relative_vec: { e2e4: 0, d2d4: -10 },
107+
winrate_vec: { e2e4: 0.6, d2d4: 0.7 },
108+
winrate_loss_vec: { e2e4: 0, d2d4: 0.1 },
109+
}
110+
111+
const maiaEval: { [rating: string]: MaiaEvaluation } = {
112+
maia_kdd_1500: {
113+
policy: {
114+
e2e4: 0.8, // 80% probability - above 10% threshold
115+
d2d4: 0.2, // 20% probability - above 10% threshold
116+
},
117+
value: 0.6,
118+
},
119+
}
120+
121+
parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
122+
parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
123+
124+
// Even though d2d4 has higher winrate than weighted average,
125+
// it should not be excellent because Maia probability > 10%
126+
const classification = GameNode.classifyMove(
127+
parentNode,
128+
'd2d4',
129+
'maia_kdd_1500',
130+
)
131+
expect(classification.excellent).toBe(false)
132+
})
133+
134+
it('should not classify move as excellent when winrate advantage < 10%', () => {
135+
const parentNode = new GameNode(
136+
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
137+
)
138+
139+
const stockfishEval: StockfishEvaluation = {
140+
sent: true,
141+
depth: 15,
142+
model_move: 'e2e4',
143+
model_optimal_cp: 50,
144+
cp_vec: { e2e4: 50, d2d4: 40, a2a3: 20 },
145+
cp_relative_vec: { e2e4: 0, d2d4: -10, a2a3: -30 },
146+
winrate_vec: { e2e4: 0.6, d2d4: 0.55, a2a3: 0.62 },
147+
winrate_loss_vec: { e2e4: 0, d2d4: -0.05, a2a3: 0.02 },
148+
}
149+
150+
const maiaEval: { [rating: string]: MaiaEvaluation } = {
151+
maia_kdd_1500: {
152+
policy: {
153+
e2e4: 0.7, // 70% probability
154+
d2d4: 0.25, // 25% probability
155+
a2a3: 0.05, // 5% probability - below 10% threshold
156+
},
157+
value: 0.6,
158+
},
159+
}
160+
161+
parentNode.addStockfishAnalysis(stockfishEval, 'maia_kdd_1500')
162+
parentNode.addMaiaAnalysis(maiaEval, 'maia_kdd_1500')
163+
164+
// Weighted average: (0.7 * 0.6 + 0.25 * 0.55 + 0.05 * 0.62) / 1.0
165+
// = (0.42 + 0.1375 + 0.031) / 1.0 = 0.5885
166+
// a2a3 winrate (0.62) is only about 3.15% higher than weighted average
167+
// So a2a3 should NOT be excellent (advantage < 10%)
168+
169+
const classification = GameNode.classifyMove(
170+
parentNode,
171+
'a2a3',
172+
'maia_kdd_1500',
173+
)
174+
expect(classification.excellent).toBe(false)
175+
})
176+
})
177+
})

src/components/Analysis/MoveTooltip.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react'
2+
import { createPortal } from 'react-dom'
23
import { ColorSanMapping } from 'src/types'
34

45
interface MoveTooltipProps {
@@ -22,14 +23,14 @@ export const MoveTooltip: React.FC<MoveTooltipProps> = ({
2223
position,
2324
isVisible = true,
2425
}) => {
25-
if (!isVisible || !position) return null
26+
if (!isVisible || !position || typeof window === 'undefined') return null
2627

2728
const san = colorSanMapping[move]?.san ?? move
2829
const color = colorSanMapping[move]?.color ?? '#fff'
2930

30-
return (
31+
const tooltipContent = (
3132
<div
32-
className="pointer-events-none fixed z-50 flex w-auto min-w-[12rem] flex-col overflow-hidden rounded-lg border border-white/30 bg-background-1 backdrop-blur-sm"
33+
className="pointer-events-none fixed z-50 flex w-auto min-w-[12rem] flex-col overflow-hidden rounded-lg border border-white/30 bg-background-1 text-primary backdrop-blur-sm"
3334
style={{
3435
left: position.x + 15,
3536
top: position.y - 10,
@@ -83,4 +84,6 @@ export const MoveTooltip: React.FC<MoveTooltipProps> = ({
8384
</div>
8485
</div>
8586
)
87+
88+
return createPortal(tooltipContent, document.body)
8689
}

src/constants/analysis.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
*
44
* 1. BLUNDER (??): More than 10% Stockfish winrate loss compared to best move
55
* 2. INACCURACY (?!): More than 5% Stockfish winrate loss compared to best move
6-
* 3. EXCELLENT (!!): Less than 2% Stockfish winrate loss AND less than 10% Maia probability
6+
* 3. EXCELLENT (!!): At least 10% higher Stockfish winrate than weighted average AND less than 10% Maia probability
77
*
8-
* Note: All winrate loss comparisons are against the BEST POSSIBLE MOVE in the
9-
* same position, NOT the previous position.
8+
* Note: Blunder and inaccuracy winrate loss comparisons are against the BEST POSSIBLE MOVE.
9+
* Excellent moves are compared against the weighted average winrate (using Maia probabilities as weights).
1010
*/
1111

1212
export const MOVE_CLASSIFICATION_THRESHOLDS = {
1313
BLUNDER_THRESHOLD: 0.1,
1414
INACCURACY_THRESHOLD: 0.05,
15-
EXCELLENT_WINRATE_THRESHOLD: 0.02,
15+
EXCELLENT_WINRATE_ADVANTAGE_THRESHOLD: 0.05,
1616
MAIA_UNLIKELY_THRESHOLD: 0.1,
1717
GOOD_THRESHOLD: -0.05,
1818
} as const

src/types/base/tree.ts

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -302,22 +302,6 @@ export class GameNode {
302302
!blunder &&
303303
absoluteWinrateLoss >=
304304
MOVE_CLASSIFICATION_THRESHOLDS.INACCURACY_THRESHOLD
305-
306-
// Excellent: Less than 2% winrate loss AND less than 10% Maia probability
307-
if (
308-
absoluteWinrateLoss <
309-
MOVE_CLASSIFICATION_THRESHOLDS.EXCELLENT_WINRATE_THRESHOLD
310-
) {
311-
if (maiaEval && activeModel && maiaEval[activeModel]) {
312-
const policy = maiaEval[activeModel].policy
313-
if (policy && move in policy) {
314-
const probability = policy[move]
315-
excellent =
316-
probability <=
317-
MOVE_CLASSIFICATION_THRESHOLDS.MAIA_UNLIKELY_THRESHOLD
318-
}
319-
}
320-
}
321305
}
322306
} else {
323307
// Fallback to centipawn-based classification if winrate not available
@@ -327,6 +311,44 @@ export class GameNode {
327311
}
328312
}
329313

314+
// Excellent move criteria: Less than 10% Maia probability AND
315+
// at least 10% higher winrate than weighted average
316+
if (
317+
maiaEval &&
318+
activeModel &&
319+
maiaEval[activeModel] &&
320+
stockfishEval.winrate_vec
321+
) {
322+
const policy = maiaEval[activeModel].policy
323+
if (policy && move in policy) {
324+
const probability = policy[move]
325+
326+
// Check Maia probability threshold
327+
const lowMaiaProbability =
328+
probability <= MOVE_CLASSIFICATION_THRESHOLDS.MAIA_UNLIKELY_THRESHOLD
329+
330+
if (lowMaiaProbability) {
331+
// Calculate weighted average winrate using Maia probabilities as weights
332+
const weightedAverageWinrate = this.calculateWeightedAverageWinrate(
333+
stockfishEval.winrate_vec,
334+
policy,
335+
)
336+
337+
const currentMoveWinrate = stockfishEval.winrate_vec[move]
338+
if (
339+
currentMoveWinrate !== undefined &&
340+
weightedAverageWinrate !== null
341+
) {
342+
// Check if current move is at least x% higher than weighted average
343+
excellent =
344+
currentMoveWinrate >=
345+
weightedAverageWinrate +
346+
MOVE_CLASSIFICATION_THRESHOLDS.EXCELLENT_WINRATE_ADVANTAGE_THRESHOLD
347+
}
348+
}
349+
}
350+
}
351+
330352
return {
331353
blunder,
332354
inaccuracy,
@@ -335,6 +357,29 @@ export class GameNode {
335357
}
336358
}
337359

360+
// Helper method to calculate weighted average winrate
361+
private calculateWeightedAverageWinrate(
362+
winrateVec: { [move: string]: number },
363+
maiaPolicy: { [move: string]: number },
364+
): number | null {
365+
let totalWeight = 0
366+
let weightedSum = 0
367+
368+
// Calculate weighted sum using Maia probabilities as weights
369+
for (const move in maiaPolicy) {
370+
const weight = maiaPolicy[move]
371+
const winrate = winrateVec[move]
372+
373+
if (weight !== undefined && winrate !== undefined) {
374+
weightedSum += weight * winrate
375+
totalWeight += weight
376+
}
377+
}
378+
379+
// Return weighted average, or null if no valid data
380+
return totalWeight > 0 ? weightedSum / totalWeight : null
381+
}
382+
338383
private classifyMoveByWinrate(
339384
node: GameNode,
340385
move: string,

0 commit comments

Comments
 (0)