Spaces:
Sleeping
Sleeping
| """ | |
| CASCADE-LATTICE Chess - Dash Version | |
| ==================================== | |
| Proper callback-based visualization that doesn't shit itself. | |
| """ | |
| import dash | |
| from dash import dcc, html, callback, Input, Output, State | |
| import plotly.graph_objects as go | |
| import chess | |
| import chess.engine | |
| import numpy as np | |
| import asyncio | |
| import platform | |
| from pathlib import Path | |
| import shutil | |
| from dataclasses import dataclass | |
| from typing import List, Optional | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # STOCKFISH SETUP | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| PROJECT_ROOT = Path(__file__).parent.parent.resolve() | |
| LOCAL_STOCKFISH = PROJECT_ROOT / "stockfish" / "stockfish-windows-x86-64-avx2.exe" | |
| if LOCAL_STOCKFISH.exists(): | |
| STOCKFISH_PATH = str(LOCAL_STOCKFISH) | |
| elif shutil.which("stockfish"): | |
| STOCKFISH_PATH = shutil.which("stockfish") | |
| else: | |
| STOCKFISH_PATH = "/usr/games/stockfish" | |
| print(f"[STOCKFISH] {STOCKFISH_PATH}") | |
| # Fix Windows asyncio for chess engine | |
| if platform.system() == 'Windows': | |
| asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) | |
| # Global engine (initialized once) | |
| ENGINE = None | |
| try: | |
| ENGINE = chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH) | |
| print("[ENGINE] Loaded OK") | |
| except Exception as e: | |
| print(f"[ENGINE] Failed: {e}") | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # CASCADE-LATTICE | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| CASCADE_AVAILABLE = False | |
| HOLD = None | |
| CAUSATION = None | |
| TRACER = None | |
| try: | |
| from cascade import Hold | |
| HOLD = Hold() | |
| CASCADE_AVAILABLE = True | |
| print("[CASCADE] Hold ready") | |
| except ImportError: | |
| print("[CASCADE] Hold not available") | |
| try: | |
| from cascade import CausationGraph | |
| CAUSATION = CausationGraph() | |
| print("[CASCADE] CausationGraph ready") | |
| except: | |
| pass | |
| try: | |
| from cascade import Tracer | |
| if CAUSATION: | |
| TRACER = Tracer(CAUSATION) | |
| print("[CASCADE] Tracer ready") | |
| except: | |
| pass | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # VISUAL THEME | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Board - rich wood tones | |
| BOARD_LIGHT = '#D4A574' # Maple | |
| BOARD_DARK = '#8B5A2B' # Walnut | |
| BOARD_EDGE = '#4A3520' # Dark frame | |
| # Pieces - polished look | |
| WHITE_PIECE = '#FFFEF0' # Ivory | |
| WHITE_SHADOW = '#C0B090' # Ivory shadow | |
| BLACK_PIECE = '#2A2A2A' # Ebony | |
| BLACK_SHADOW = '#151515' # Ebony shadow | |
| # Accents | |
| GOLD = '#FFD700' | |
| CRIMSON = '#DC143C' | |
| CYAN = '#00FFD4' | |
| MAGENTA = '#FF00AA' | |
| BG_COLOR = '#0a0a0f' | |
| BG_GRADIENT = '#12121a' | |
| # Piece geometry - height and multiple size layers for 3D effect | |
| PIECE_CONFIG = { | |
| chess.PAWN: {'h': 0.5, 'base': 8, 'mid': 6, 'top': 4, 'symbol': '♟'}, | |
| chess.KNIGHT: {'h': 0.8, 'base': 10, 'mid': 8, 'top': 6, 'symbol': '♞'}, | |
| chess.BISHOP: {'h': 0.9, 'base': 10, 'mid': 7, 'top': 5, 'symbol': '♝'}, | |
| chess.ROOK: {'h': 0.7, 'base': 10, 'mid': 9, 'top': 8, 'symbol': '♜'}, | |
| chess.QUEEN: {'h': 1.1, 'base': 12, 'mid': 9, 'top': 6, 'symbol': '♛'}, | |
| chess.KING: {'h': 1.2, 'base': 12, 'mid': 8, 'top': 5, 'symbol': '♚'}, | |
| } | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # DATA | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| class MoveCandidate: | |
| move: str | |
| prob: float | |
| value: float | |
| from_sq: int | |
| to_sq: int | |
| is_capture: bool = False | |
| is_check: bool = False | |
| move_type: str = 'quiet' | |
| class CascadeTrace: | |
| """Represents a cascade-lattice inference trace.""" | |
| step: int | |
| operation: str | |
| inputs: List[str] | |
| output: str | |
| duration_ms: float | |
| confidence: float | |
| # Global trace storage | |
| TRACE_LOG: List[dict] = [] | |
| DECISION_TREE: List[dict] = [] | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # 3D VISUALIZATION | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def square_to_3d(square: int): | |
| file = square % 8 | |
| rank = square // 8 | |
| return (file - 3.5, rank - 3.5, 0) | |
| def create_board_traces(): | |
| """Create 3D board with mesh tiles.""" | |
| traces = [] | |
| tile_size = 0.48 | |
| tile_height = 0.08 | |
| for rank in range(8): | |
| for file in range(8): | |
| cx, cy = file - 3.5, rank - 3.5 | |
| is_light = (rank + file) % 2 == 1 | |
| color = BOARD_LIGHT if is_light else BOARD_DARK | |
| # Top surface of tile | |
| traces.append(go.Mesh3d( | |
| x=[cx-tile_size, cx+tile_size, cx+tile_size, cx-tile_size], | |
| y=[cy-tile_size, cy-tile_size, cy+tile_size, cy+tile_size], | |
| z=[tile_height, tile_height, tile_height, tile_height], | |
| i=[0, 0], j=[1, 2], k=[2, 3], | |
| color=color, opacity=0.95, flatshading=True, | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| # Board frame/border | |
| border = 4.3 | |
| frame_z = tile_height / 2 | |
| traces.append(go.Scatter3d( | |
| x=[-border, border, border, -border, -border], | |
| y=[-border, -border, border, border, -border], | |
| z=[frame_z]*5, mode='lines', | |
| line=dict(color=GOLD, width=3), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| # File labels (a-h) | |
| for i, label in enumerate('abcdefgh'): | |
| traces.append(go.Scatter3d( | |
| x=[i-3.5], y=[-4.6], z=[0.1], mode='text', text=[label], | |
| textfont=dict(size=11, color='#666'), showlegend=False | |
| )) | |
| # Rank labels (1-8) | |
| for i in range(8): | |
| traces.append(go.Scatter3d( | |
| x=[-4.6], y=[i-3.5], z=[0.1], mode='text', text=[str(i+1)], | |
| textfont=dict(size=11, color='#666'), showlegend=False | |
| )) | |
| return traces | |
| def create_piece_traces(board: chess.Board): | |
| """Create holographic crystal-style chess pieces using rotated point silhouettes.""" | |
| traces = [] | |
| # Define piece silhouettes as point patterns (normalized 0-1 range) | |
| # These trace the outline/shape of each piece type | |
| PIECE_SILHOUETTES = { | |
| chess.PAWN: [ | |
| (0.5, 0.0), (0.3, 0.1), (0.25, 0.2), (0.3, 0.3), (0.35, 0.4), | |
| (0.4, 0.5), (0.35, 0.6), (0.3, 0.7), (0.4, 0.8), (0.5, 0.9), | |
| (0.6, 0.8), (0.7, 0.7), (0.65, 0.6), (0.6, 0.5), (0.65, 0.4), | |
| (0.7, 0.3), (0.75, 0.2), (0.7, 0.1), (0.5, 0.0) | |
| ], | |
| chess.ROOK: [ | |
| (0.25, 0.0), (0.25, 0.15), (0.35, 0.15), (0.35, 0.25), (0.25, 0.25), | |
| (0.25, 0.85), (0.35, 0.9), (0.35, 1.0), (0.45, 1.0), (0.45, 0.9), | |
| (0.55, 0.9), (0.55, 1.0), (0.65, 1.0), (0.65, 0.9), (0.75, 0.85), | |
| (0.75, 0.25), (0.65, 0.25), (0.65, 0.15), (0.75, 0.15), (0.75, 0.0) | |
| ], | |
| chess.KNIGHT: [ | |
| (0.25, 0.0), (0.25, 0.2), (0.3, 0.35), (0.25, 0.5), (0.3, 0.6), | |
| (0.35, 0.7), (0.3, 0.8), (0.4, 0.9), (0.5, 1.0), (0.6, 0.95), | |
| (0.7, 0.85), (0.75, 0.7), (0.7, 0.55), (0.65, 0.4), (0.7, 0.25), | |
| (0.75, 0.15), (0.75, 0.0) | |
| ], | |
| chess.BISHOP: [ | |
| (0.3, 0.0), (0.25, 0.15), (0.3, 0.3), (0.35, 0.45), (0.4, 0.6), | |
| (0.35, 0.7), (0.4, 0.8), (0.5, 0.95), (0.6, 0.8), (0.65, 0.7), | |
| (0.6, 0.6), (0.65, 0.45), (0.7, 0.3), (0.75, 0.15), (0.7, 0.0) | |
| ], | |
| chess.QUEEN: [ | |
| (0.25, 0.0), (0.2, 0.15), (0.25, 0.3), (0.3, 0.5), (0.25, 0.65), | |
| (0.3, 0.75), (0.2, 0.85), (0.35, 0.9), (0.5, 1.0), (0.65, 0.9), | |
| (0.8, 0.85), (0.7, 0.75), (0.75, 0.65), (0.7, 0.5), (0.75, 0.3), | |
| (0.8, 0.15), (0.75, 0.0) | |
| ], | |
| chess.KING: [ | |
| (0.25, 0.0), (0.2, 0.15), (0.25, 0.35), (0.3, 0.55), (0.35, 0.7), | |
| (0.3, 0.8), (0.45, 0.85), (0.45, 0.92), (0.4, 0.92), (0.4, 0.97), | |
| (0.5, 1.0), (0.6, 0.97), (0.6, 0.92), (0.55, 0.92), (0.55, 0.85), | |
| (0.7, 0.8), (0.65, 0.7), (0.7, 0.55), (0.75, 0.35), (0.8, 0.15), (0.75, 0.0) | |
| ], | |
| } | |
| PIECE_HEIGHT = { | |
| chess.PAWN: 0.4, | |
| chess.KNIGHT: 0.55, | |
| chess.BISHOP: 0.55, | |
| chess.ROOK: 0.5, | |
| chess.QUEEN: 0.65, | |
| chess.KING: 0.7, | |
| } | |
| NUM_ROTATIONS = 6 # How many rotated planes per piece | |
| for sq in chess.SQUARES: | |
| piece = board.piece_at(sq) | |
| if not piece: | |
| continue | |
| cx, cy, _ = square_to_3d(sq) | |
| silhouette = PIECE_SILHOUETTES[piece.piece_type] | |
| height = PIECE_HEIGHT[piece.piece_type] | |
| color = '#E8E8E8' if piece.color == chess.WHITE else '#404040' | |
| # Create rotated copies of the silhouette | |
| for rot in range(NUM_ROTATIONS): | |
| angle = (rot / NUM_ROTATIONS) * np.pi # 0 to 180 degrees | |
| xs, ys, zs = [], [], [] | |
| for px, pz in silhouette: | |
| # Offset from center (-0.5 to +0.5 range) | |
| local_x = (px - 0.5) * 0.4 # Scale to fit in square | |
| local_z = pz * height + 0.1 # Height above board | |
| # Rotate around Y (vertical) axis | |
| rotated_x = local_x * np.cos(angle) | |
| rotated_y = local_x * np.sin(angle) | |
| xs.append(cx + rotated_x) | |
| ys.append(cy + rotated_y) | |
| zs.append(local_z) | |
| traces.append(go.Scatter3d( | |
| x=xs, y=ys, z=zs, | |
| mode='lines', | |
| line=dict(color=color, width=2), | |
| opacity=0.7, | |
| hoverinfo='skip', | |
| showlegend=False | |
| )) | |
| return traces | |
| return traces | |
| def create_arc(from_sq, to_sq, is_white, num_points=40): | |
| """Create smooth arc trajectory with proper curve.""" | |
| x1, y1, _ = square_to_3d(from_sq) | |
| x2, y2, _ = square_to_3d(to_sq) | |
| dist = np.sqrt((x2-x1)**2 + (y2-y1)**2) | |
| # Cap height to stay within z bounds - short moves get small arcs, long moves capped | |
| height = min(1.8, max(0.8, dist * 0.35)) | |
| t = np.linspace(0, 1, num_points) | |
| x = x1 + (x2 - x1) * t | |
| y = y1 + (y2 - y1) * t | |
| # Arc always goes UP (positive z) - offset from board surface | |
| z = 0.3 + height * np.sin(np.pi * t) | |
| return x, y, z | |
| def create_move_arcs(candidates: List[MoveCandidate], is_white: bool): | |
| """Create glowing move arc traces.""" | |
| traces = [] | |
| # Color gradient based on rank | |
| if is_white: | |
| colors = [CYAN, '#00CCAA', '#009988', '#006666', '#004444'] | |
| else: | |
| colors = [MAGENTA, '#CC0088', '#990066', '#660044', '#440022'] | |
| for i, cand in enumerate(candidates): | |
| x, y, z = create_arc(cand.from_sq, cand.to_sq, is_white) | |
| color = colors[min(i, len(colors)-1)] | |
| width = max(4, cand.prob * 15) | |
| opacity = max(0.5, cand.prob * 1.2) | |
| # Outer glow (wide, transparent) | |
| traces.append(go.Scatter3d( | |
| x=x, y=y, z=z, mode='lines', | |
| line=dict(color=color, width=width+6), | |
| opacity=opacity*0.2, hoverinfo='skip', showlegend=False | |
| )) | |
| # Mid glow | |
| traces.append(go.Scatter3d( | |
| x=x, y=y, z=z, mode='lines', | |
| line=dict(color=color, width=width+2), | |
| opacity=opacity*0.4, hoverinfo='skip', showlegend=False | |
| )) | |
| # Core line | |
| traces.append(go.Scatter3d( | |
| x=x, y=y, z=z, mode='lines', | |
| line=dict(color=color, width=width), | |
| opacity=opacity, | |
| name=f"{cand.move} ({cand.prob*100:.0f}%)", | |
| hovertemplate=f"<b>{cand.move}</b><br>Probability: {cand.prob*100:.1f}%<br>Eval: {cand.value:+.2f}<extra></extra>" | |
| )) | |
| # Start point (piece origin) | |
| traces.append(go.Scatter3d( | |
| x=[x[0]], y=[y[0]], z=[z[0]], mode='markers', | |
| marker=dict(size=5, color=color, symbol='circle', opacity=opacity), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| # End point (destination) - larger, prominent | |
| traces.append(go.Scatter3d( | |
| x=[x[-1]], y=[y[-1]], z=[z[-1]], mode='markers', | |
| marker=dict(size=8, color=color, symbol='diamond', | |
| line=dict(color='white', width=1)), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| # Drop line to board (shows landing square) | |
| x2, y2, _ = square_to_3d(cand.to_sq) | |
| traces.append(go.Scatter3d( | |
| x=[x2, x2], y=[y2, y2], z=[z[-1], 0.15], | |
| mode='lines', line=dict(color=color, width=2, dash='dot'), | |
| opacity=opacity*0.5, hoverinfo='skip', showlegend=False | |
| )) | |
| return traces | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # TACTICAL VISUALIZATION | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def get_piece_attacks(board: chess.Board, square: int) -> List[int]: | |
| """Get squares attacked by piece at given square.""" | |
| piece = board.piece_at(square) | |
| if not piece: | |
| return [] | |
| return list(board.attacks(square)) | |
| def get_attackers(board: chess.Board, square: int, color: bool) -> List[int]: | |
| """Get pieces of given color attacking a square.""" | |
| return list(board.attackers(color, square)) | |
| def create_threat_beams(board: chess.Board): | |
| """Create downward beams showing attack vectors - underneath the board.""" | |
| traces = [] | |
| for sq in chess.SQUARES: | |
| piece = board.piece_at(sq) | |
| if not piece: | |
| continue | |
| attacks = get_piece_attacks(board, sq) | |
| if not attacks: | |
| continue | |
| px, py, _ = square_to_3d(sq) | |
| color = 'rgba(255,215,0,0.15)' if piece.color == chess.WHITE else 'rgba(220,20,60,0.15)' | |
| for target_sq in attacks: | |
| tx, ty, _ = square_to_3d(target_sq) | |
| # Beam goes from piece position DOWN through board to show threat zone | |
| traces.append(go.Scatter3d( | |
| x=[px, tx], y=[py, ty], z=[-0.05, -0.2], | |
| mode='lines', line=dict(color=color, width=1), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| return traces | |
| def create_tension_lines(board: chess.Board): | |
| """Create lines between pieces that threaten each other (mutual tension).""" | |
| traces = [] | |
| tension_pairs = set() | |
| for sq in chess.SQUARES: | |
| piece = board.piece_at(sq) | |
| if not piece: | |
| continue | |
| # Check if this piece attacks any enemy pieces | |
| for target_sq in board.attacks(sq): | |
| target_piece = board.piece_at(target_sq) | |
| if target_piece and target_piece.color != piece.color: | |
| # Create unique pair key | |
| pair = tuple(sorted([sq, target_sq])) | |
| if pair not in tension_pairs: | |
| tension_pairs.add(pair) | |
| x1, y1, _ = square_to_3d(sq) | |
| x2, y2, _ = square_to_3d(target_sq) | |
| # Check if it's mutual (both threaten each other) | |
| mutual = target_sq in board.attacks(sq) and sq in board.attacks(target_sq) | |
| if mutual: | |
| color = MAGENTA | |
| width = 3 | |
| opacity = 0.6 | |
| else: | |
| color = CRIMSON if piece.color == chess.BLACK else GOLD | |
| width = 2 | |
| opacity = 0.4 | |
| # Tension line slightly above board | |
| traces.append(go.Scatter3d( | |
| x=[x1, x2], y=[y1, y2], z=[0.8, 0.8], | |
| mode='lines', line=dict(color=color, width=width), | |
| opacity=opacity, hoverinfo='skip', showlegend=False | |
| )) | |
| return traces | |
| def create_hanging_indicators(board: chess.Board): | |
| """Mark pieces that are hanging (attacked but not defended).""" | |
| traces = [] | |
| for sq in chess.SQUARES: | |
| piece = board.piece_at(sq) | |
| if not piece: | |
| continue | |
| # Count attackers and defenders | |
| enemy_color = not piece.color | |
| attackers = len(board.attackers(enemy_color, sq)) | |
| defenders = len(board.attackers(piece.color, sq)) | |
| if attackers > 0 and defenders == 0: | |
| # Hanging! Draw danger indicator | |
| x, y, _ = square_to_3d(sq) | |
| # Pulsing ring under the piece | |
| theta = np.linspace(0, 2*np.pi, 20) | |
| ring_x = x + 0.3 * np.cos(theta) | |
| ring_y = y + 0.3 * np.sin(theta) | |
| ring_z = [0.05] * len(theta) | |
| traces.append(go.Scatter3d( | |
| x=ring_x, y=ring_y, z=ring_z, | |
| mode='lines', line=dict(color=CRIMSON, width=4), | |
| opacity=0.8, hoverinfo='skip', showlegend=False | |
| )) | |
| # Exclamation point above | |
| traces.append(go.Scatter3d( | |
| x=[x], y=[y], z=[1.0], | |
| mode='text', text=['⚠'], | |
| textfont=dict(size=16, color=CRIMSON), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| return traces | |
| def create_king_safety(board: chess.Board): | |
| """Create safety dome around kings showing their safety status.""" | |
| traces = [] | |
| for color in [chess.WHITE, chess.BLACK]: | |
| king_sq = board.king(color) | |
| if king_sq is None: | |
| continue | |
| kx, ky, _ = square_to_3d(king_sq) | |
| # Count attackers around king zone | |
| danger_level = 0 | |
| for sq in chess.SQUARES: | |
| file_dist = abs(chess.square_file(sq) - chess.square_file(king_sq)) | |
| rank_dist = abs(chess.square_rank(sq) - chess.square_rank(king_sq)) | |
| if file_dist <= 1 and rank_dist <= 1: # King zone | |
| enemy_attackers = len(board.attackers(not color, sq)) | |
| danger_level += enemy_attackers | |
| # Dome color based on safety | |
| if board.is_check() and board.turn == color: | |
| dome_color = CRIMSON | |
| opacity = 0.5 | |
| elif danger_level > 5: | |
| dome_color = '#FF6600' # Orange - danger | |
| opacity = 0.3 | |
| elif danger_level > 2: | |
| dome_color = GOLD # Yellow - caution | |
| opacity = 0.2 | |
| else: | |
| dome_color = '#00FF00' # Green - safe | |
| opacity = 0.15 | |
| # Draw dome as circles at different heights | |
| for h in [0.3, 0.5, 0.7]: | |
| radius = 0.6 * (1 - h/1.5) # Smaller at top | |
| theta = np.linspace(0, 2*np.pi, 24) | |
| dome_x = kx + radius * np.cos(theta) | |
| dome_y = ky + radius * np.sin(theta) | |
| dome_z = [h] * len(theta) | |
| traces.append(go.Scatter3d( | |
| x=dome_x, y=dome_y, z=dome_z, | |
| mode='lines', line=dict(color=dome_color, width=2), | |
| opacity=opacity, hoverinfo='skip', showlegend=False | |
| )) | |
| return traces | |
| def create_candidate_origins(board: chess.Board, candidates: List[MoveCandidate]): | |
| """Highlight pieces that are generating the top candidate moves.""" | |
| traces = [] | |
| if not candidates: | |
| return traces | |
| # Collect unique origin squares from candidates | |
| origins = {} | |
| for i, cand in enumerate(candidates): | |
| sq = cand.from_sq | |
| if sq not in origins: | |
| origins[sq] = i # Store best rank | |
| for sq, rank in origins.items(): | |
| x, y, _ = square_to_3d(sq) | |
| # Intensity based on rank (top candidate = brightest) | |
| intensity = 1.0 - (rank * 0.15) | |
| color = f'rgba(0,255,212,{intensity * 0.6})' # Cyan glow | |
| # Glow ring around the piece | |
| theta = np.linspace(0, 2*np.pi, 20) | |
| ring_x = x + 0.35 * np.cos(theta) | |
| ring_y = y + 0.35 * np.sin(theta) | |
| ring_z = [0.08] * len(theta) | |
| traces.append(go.Scatter3d( | |
| x=ring_x, y=ring_y, z=ring_z, | |
| mode='lines', line=dict(color=color, width=3 + (5-rank)), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| return traces | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # STATE-SPACE LATTICE GRAPH | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def create_state_lattice(move_history: List[str], candidates: List[MoveCandidate] = None): | |
| """ | |
| Create a state-space graph visualization above/below the board. | |
| Each move node is positioned ABOVE its destination square. | |
| White moves above board, black moves below. | |
| Edges trace the flow of the game through space. | |
| """ | |
| traces = [] | |
| if not move_history or len(move_history) < 1: | |
| return traces | |
| # Z heights - white moves float above, black sink below | |
| BASE_Z_WHITE = 2.2 | |
| BASE_Z_BLACK = -0.3 | |
| Z_LAYER_STEP = 0.25 # Each subsequent move goes higher/lower | |
| # Track all nodes for edge drawing | |
| all_nodes = [] # List of (x, y, z, move_uci, is_white, move_idx) | |
| for i, move_uci in enumerate(move_history): | |
| is_white = (i % 2 == 0) | |
| move_num = i // 2 + 1 | |
| same_color_idx = i // 2 # How many moves of this color so far | |
| # Parse the UCI move to get destination square | |
| if len(move_uci) >= 4: | |
| to_sq_name = move_uci[2:4] # e.g., "e4" from "e2e4" | |
| try: | |
| to_sq = chess.parse_square(to_sq_name) | |
| # Get 3D position of destination square | |
| x, y, _ = square_to_3d(to_sq) | |
| except: | |
| x, y = 0, 0 | |
| else: | |
| x, y = 0, 0 | |
| # Z position - stacks up/down with each move | |
| if is_white: | |
| z = BASE_Z_WHITE + (same_color_idx * Z_LAYER_STEP) | |
| node_color = '#FFFEF0' | |
| edge_color = 'rgba(255,254,240,0.6)' | |
| glow_color = 'rgba(0,255,212,0.4)' # Cyan glow | |
| else: | |
| z = BASE_Z_BLACK - (same_color_idx * Z_LAYER_STEP) | |
| node_color = '#AAAAAA' | |
| edge_color = 'rgba(150,150,150,0.6)' | |
| glow_color = 'rgba(255,100,150,0.4)' # Pink glow | |
| all_nodes.append((x, y, z, move_uci, is_white, i)) | |
| # Draw vertical "drop line" from node to board | |
| board_z = 0.1 | |
| traces.append(go.Scatter3d( | |
| x=[x, x], y=[y, y], z=[z, board_z if is_white else board_z], | |
| mode='lines', | |
| line=dict(color=glow_color, width=1), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| # Node marker | |
| traces.append(go.Scatter3d( | |
| x=[x], y=[y], z=[z], | |
| mode='markers+text', | |
| marker=dict(size=10, color=node_color, | |
| line=dict(width=2, color=glow_color), | |
| symbol='diamond'), | |
| text=[f"{move_num}.{move_uci}" if is_white else move_uci], | |
| textposition='top center', | |
| textfont=dict(size=9, color=node_color, family='monospace'), | |
| hoverinfo='text', | |
| hovertext=f"{'White' if is_white else 'Black'} {move_num}: {move_uci}", | |
| showlegend=False | |
| )) | |
| # Draw edges connecting sequential moves (alternating colors) | |
| for i in range(len(all_nodes) - 1): | |
| x0, y0, z0, _, _, _ = all_nodes[i] | |
| x1, y1, z1, _, is_white_next, _ = all_nodes[i + 1] | |
| # Color based on who just moved (the edge leads TO the next move) | |
| edge_color = 'rgba(0,255,212,0.4)' if is_white_next else 'rgba(255,100,150,0.4)' | |
| traces.append(go.Scatter3d( | |
| x=[x0, x1], y=[y0, y1], z=[z0, z1], | |
| mode='lines', | |
| line=dict(color=edge_color, width=2), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| # Show candidate moves as potential branches from last position | |
| if candidates and all_nodes: | |
| last_x, last_y, last_z, _, last_white, _ = all_nodes[-1] | |
| is_white_turn = len(move_history) % 2 == 0 # Who moves next | |
| branch_z = (BASE_Z_WHITE + (len(move_history)//2) * Z_LAYER_STEP) if is_white_turn \ | |
| else (BASE_Z_BLACK - (len(move_history)//2) * Z_LAYER_STEP) | |
| for j, cand in enumerate(candidates[:5]): | |
| # Position at candidate's destination | |
| if len(cand.move) >= 4: | |
| try: | |
| cand_to_sq = chess.parse_square(cand.move[2:4]) | |
| cx, cy, _ = square_to_3d(cand_to_sq) | |
| except: | |
| continue | |
| else: | |
| continue | |
| cz = branch_z | |
| # Branch edge from last move to candidate | |
| traces.append(go.Scatter3d( | |
| x=[last_x, cx], y=[last_y, cy], z=[last_z, cz], | |
| mode='lines', | |
| line=dict(color='rgba(255,215,0,0.3)', width=1, dash='dot'), | |
| hoverinfo='skip', showlegend=False | |
| )) | |
| # Candidate node | |
| traces.append(go.Scatter3d( | |
| x=[cx], y=[cy], z=[cz], | |
| mode='markers+text', | |
| marker=dict(size=6, color=GOLD, opacity=0.5, | |
| symbol='diamond'), | |
| text=[cand.move], | |
| textposition='top center', | |
| textfont=dict(size=7, color='rgba(255,215,0,0.6)', family='monospace'), | |
| hoverinfo='text', | |
| hovertext=f"Candidate: {cand.move} ({cand.cp}cp)", | |
| showlegend=False | |
| )) | |
| return traces | |
| # Default camera position | |
| DEFAULT_CAMERA = dict( | |
| eye=dict(x=0.8, y=-1.4, z=0.9), | |
| up=dict(x=0, y=0, z=1), | |
| center=dict(x=0, y=0, z=0) | |
| ) | |
| def create_figure(board: chess.Board, candidates: List[MoveCandidate] = None, camera: dict = None): | |
| """Create the full 3D figure with polished visuals.""" | |
| fig = go.Figure() | |
| # Use provided camera or default | |
| cam = camera if camera else DEFAULT_CAMERA | |
| # Layer 1: Board | |
| for trace in create_board_traces(): | |
| fig.add_trace(trace) | |
| # Layer 2: Pieces (crystal style) | |
| for trace in create_piece_traces(board): | |
| fig.add_trace(trace) | |
| # Layer 3: Move arcs (when HOLD active) | |
| if candidates: | |
| # Highlight candidate origin pieces | |
| for trace in create_candidate_origins(board, candidates): | |
| fig.add_trace(trace) | |
| is_white = board.turn == chess.WHITE | |
| for trace in create_move_arcs(candidates, is_white): | |
| fig.add_trace(trace) | |
| # Turn indicator | |
| turn_text = "⚪ WHITE" if board.turn else "⚫ BLACK" | |
| turn_color = '#EEE' if board.turn else '#888' | |
| fig.add_trace(go.Scatter3d( | |
| x=[0], y=[5.2], z=[0.3], mode='text', text=[turn_text], | |
| textfont=dict(size=14, color=turn_color, family='monospace'), | |
| showlegend=False, hoverinfo='skip' | |
| )) | |
| # Move number | |
| fig.add_trace(go.Scatter3d( | |
| x=[0], y=[-5.2], z=[0.3], mode='text', | |
| text=[f"Move {board.fullmove_number}"], | |
| textfont=dict(size=11, color='#666', family='monospace'), | |
| showlegend=False, hoverinfo='skip' | |
| )) | |
| fig.update_layout( | |
| scene=dict( | |
| xaxis=dict(range=[-5.5, 5.5], showgrid=False, showbackground=False, | |
| showticklabels=False, showline=False, zeroline=False, title=''), | |
| yaxis=dict(range=[-5.5, 5.5], showgrid=False, showbackground=False, | |
| showticklabels=False, showline=False, zeroline=False, title=''), | |
| zaxis=dict(range=[-0.5, 3.0], showgrid=False, showbackground=False, | |
| showticklabels=False, showline=False, zeroline=False, title=''), | |
| aspectmode='manual', | |
| aspectratio=dict(x=1, y=1, z=0.35), | |
| camera=cam, | |
| bgcolor=BG_COLOR | |
| ), | |
| paper_bgcolor=BG_COLOR, | |
| plot_bgcolor=BG_COLOR, | |
| margin=dict(l=0, r=0, t=0, b=0), | |
| showlegend=False, | |
| height=650 | |
| ) | |
| return fig | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # ENGINE + CASCADE HELPERS | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| import time | |
| def get_candidates_with_trace(board: chess.Board, num=5) -> tuple: | |
| """Get move candidates from Stockfish WITH cascade-lattice tracing.""" | |
| global TRACE_LOG, DECISION_TREE | |
| trace_data = [] | |
| decision_data = [] | |
| start_time = time.perf_counter() | |
| if not ENGINE: | |
| return [], trace_data, decision_data | |
| try: | |
| # TRACE: Board state encoding | |
| t0 = time.perf_counter() | |
| fen = board.fen() | |
| trace_data.append({ | |
| 'step': 1, 'op': 'ENCODE', 'detail': f'FEN → tensor', | |
| 'input': fen[:20] + '...', 'output': 'state_vec[768]', | |
| 'duration': round((time.perf_counter() - t0) * 1000, 2), | |
| 'confidence': 1.0 | |
| }) | |
| # TRACE: Engine analysis | |
| t1 = time.perf_counter() | |
| info = ENGINE.analyse(board, chess.engine.Limit(depth=10), multipv=num) | |
| trace_data.append({ | |
| 'step': 2, 'op': 'ANALYZE', 'detail': f'Stockfish depth=10', | |
| 'input': 'state_vec', 'output': f'{len(info)} candidates', | |
| 'duration': round((time.perf_counter() - t1) * 1000, 2), | |
| 'confidence': 0.95 | |
| }) | |
| candidates = [] | |
| total = 0 | |
| # TRACE: Candidate scoring | |
| t2 = time.perf_counter() | |
| for i, pv in enumerate(info): | |
| move = pv['pv'][0] | |
| score = pv.get('score', chess.engine.Cp(0)) | |
| if score.is_mate(): | |
| value = 1.0 if score.mate() > 0 else -1.0 | |
| eval_str = f"M{score.mate()}" | |
| else: | |
| cp = score.relative.score(mate_score=10000) | |
| value = max(-1, min(1, cp / 1000)) | |
| eval_str = f"{cp:+d}cp" | |
| prob = 1.0 / (i + 1) | |
| total += prob | |
| cand = MoveCandidate( | |
| move=move.uci(), prob=prob, value=value, | |
| from_sq=move.from_square, to_sq=move.to_square, | |
| is_capture=board.is_capture(move), | |
| is_check=board.gives_check(move) | |
| ) | |
| candidates.append(cand) | |
| # Decision tree entry | |
| decision_data.append({ | |
| 'move': move.uci(), | |
| 'eval': eval_str, | |
| 'prob': prob, | |
| 'rank': i + 1, | |
| 'capture': board.is_capture(move), | |
| 'check': board.gives_check(move), | |
| 'selected': i == 0 | |
| }) | |
| trace_data.append({ | |
| 'step': 3, 'op': 'SCORE', 'detail': f'Evaluate {len(candidates)} moves', | |
| 'input': 'raw_candidates', 'output': 'scored_candidates', | |
| 'duration': round((time.perf_counter() - t2) * 1000, 2), | |
| 'confidence': 0.88 | |
| }) | |
| # Normalize probabilities | |
| for c in candidates: | |
| c.prob /= total | |
| for d in decision_data: | |
| d['prob'] /= total | |
| # TRACE: Hold decision | |
| t3 = time.perf_counter() | |
| if HOLD and candidates: | |
| # Use cascade-lattice Hold to potentially override | |
| hold_result = None | |
| try: | |
| # Hold.evaluate expects candidates, returns selected | |
| hold_result = HOLD.evaluate([c.move for c in candidates], | |
| weights=[c.prob for c in candidates]) | |
| except: | |
| pass | |
| trace_data.append({ | |
| 'step': 4, 'op': 'HOLD', 'detail': f'cascade.Hold decision gate', | |
| 'input': 'scored_candidates', 'output': candidates[0].move if candidates else 'none', | |
| 'duration': round((time.perf_counter() - t3) * 1000, 2), | |
| 'confidence': candidates[0].prob if candidates else 0 | |
| }) | |
| # TRACE: Final selection | |
| total_time = (time.perf_counter() - start_time) * 1000 | |
| trace_data.append({ | |
| 'step': 5, 'op': 'SELECT', 'detail': f'Final output', | |
| 'input': candidates[0].move if candidates else '-', | |
| 'output': '✓ COMMITTED', | |
| 'duration': round(total_time, 2), | |
| 'confidence': 1.0 | |
| }) | |
| TRACE_LOG = trace_data | |
| DECISION_TREE = decision_data | |
| return candidates, trace_data, decision_data | |
| except Exception as e: | |
| print(f"[ENGINE] Error: {e}") | |
| return [], [], [] | |
| def get_candidates(board: chess.Board, num=5) -> List[MoveCandidate]: | |
| """Simple wrapper for backward compat.""" | |
| candidates, _, _ = get_candidates_with_trace(board, num) | |
| return candidates | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # DASH APP - TWO PANEL LAYOUT | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| app = dash.Dash(__name__, suppress_callback_exceptions=True) | |
| # Styles | |
| PANEL_STYLE = { | |
| 'backgroundColor': '#0d0d12', | |
| 'borderRadius': '8px', | |
| 'padding': '15px', | |
| 'border': '1px solid #1a1a2e' | |
| } | |
| TRACE_ROW_STYLE = { | |
| 'display': 'flex', 'alignItems': 'center', 'padding': '8px 10px', | |
| 'borderBottom': '1px solid #1a1a2e', 'fontFamily': 'monospace', 'fontSize': '12px' | |
| } | |
| BUTTON_BASE = { | |
| 'margin': '5px', 'padding': '12px 24px', 'fontSize': '14px', | |
| 'backgroundColor': '#1a1a2e', 'borderRadius': '4px', | |
| 'cursor': 'pointer', 'fontFamily': 'monospace' | |
| } | |
| app.layout = html.Div([ | |
| # Header | |
| html.Div([ | |
| html.H1("CASCADE // LATTICE", | |
| style={'color': CYAN, 'margin': 0, 'fontFamily': 'Consolas, monospace', | |
| 'fontSize': '2.2em', 'letterSpacing': '0.1em', 'display': 'inline-block'}), | |
| html.Span(" × ", style={'color': '#333', 'fontSize': '1.5em', 'margin': '0 10px'}), | |
| html.Span("INFERENCE VISUALIZATION", | |
| style={'color': '#444', 'fontFamily': 'monospace', 'fontSize': '1.1em'}) | |
| ], style={'textAlign': 'center', 'padding': '20px 0', 'borderBottom': '1px solid #1a1a2e'}), | |
| # Controls with loading indicator | |
| html.Div([ | |
| html.Button("⏭ STEP", id='btn-step', n_clicks=0, | |
| style={**BUTTON_BASE, 'color': '#888', 'border': '1px solid #333'}), | |
| html.Button("⏸ HOLD", id='btn-hold', n_clicks=0, | |
| style={**BUTTON_BASE, 'color': GOLD, 'border': f'1px solid {GOLD}'}), | |
| html.Button("▶▶ AUTO", id='btn-auto', n_clicks=0, | |
| style={**BUTTON_BASE, 'color': CYAN, 'border': f'1px solid {CYAN}'}), | |
| html.Button("↺ RESET", id='btn-reset', n_clicks=0, | |
| style={**BUTTON_BASE, 'color': CRIMSON, 'border': f'1px solid {CRIMSON}'}), | |
| # Loading spinner | |
| dcc.Loading( | |
| id='loading-indicator', | |
| type='circle', | |
| color=GOLD, | |
| children=html.Div(id='loading-output', style={'display': 'inline-block', 'marginLeft': '15px'}) | |
| ), | |
| ], style={'textAlign': 'center', 'padding': '15px', 'display': 'flex', | |
| 'justifyContent': 'center', 'alignItems': 'center', 'gap': '5px'}), | |
| # Status bar | |
| html.Div(id='status', | |
| style={'textAlign': 'center', 'color': '#666', 'padding': '10px', | |
| 'fontFamily': 'monospace', 'fontSize': '13px', 'backgroundColor': '#0a0a0f', | |
| 'borderTop': '1px solid #1a1a2e', 'borderBottom': '1px solid #1a1a2e'}), | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # MAIN THREE-COLUMN LAYOUT | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| html.Div([ | |
| # LEFT COLUMN - Engine & Game Info | |
| html.Div([ | |
| # Engine Info Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': '#FF6B35'}), | |
| html.Span("ENGINE", style={'color': '#888'}) | |
| ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px', | |
| 'borderBottom': '1px solid #FF6B3533', 'paddingBottom': '8px'}), | |
| html.Div([ | |
| html.Div([ | |
| html.Span("Model", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("Stockfish 17", style={'color': '#FF6B35', 'fontWeight': 'bold'}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Type", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("NNUE + Classical", style={'color': '#888'}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Depth", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("10 ply", style={'color': CYAN}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("MultiPV", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("5 lines", style={'color': GOLD}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Status", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("● READY" if ENGINE else "○ OFFLINE", | |
| style={'color': '#0F0' if ENGINE else CRIMSON}) | |
| ]), | |
| ], style={'fontFamily': 'monospace', 'fontSize': '12px'}) | |
| ], style={**PANEL_STYLE, 'marginBottom': '15px'}), | |
| # Cascade-Lattice Info Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': CYAN}), | |
| html.Span("CASCADE-LATTICE", style={'color': '#888'}) | |
| ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px', | |
| 'borderBottom': f'1px solid {CYAN}33', 'paddingBottom': '8px'}), | |
| html.Div([ | |
| html.Div([ | |
| html.Span("Package", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("cascade-lattice", style={'color': CYAN}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Version", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("0.5.6", style={'color': '#888'}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Hold", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("● ACTIVE" if HOLD else "○ OFF", | |
| style={'color': '#0F0' if HOLD else '#555'}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Causation", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("● TRACING" if CAUSATION else "○ OFF", | |
| style={'color': MAGENTA if CAUSATION else '#555'}) | |
| ]), | |
| ], style={'fontFamily': 'monospace', 'fontSize': '12px'}) | |
| ], style={**PANEL_STYLE, 'marginBottom': '15px'}), | |
| # Game State Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': GOLD}), | |
| html.Span("GAME STATE", style={'color': '#888'}) | |
| ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px', | |
| 'borderBottom': f'1px solid {GOLD}33', 'paddingBottom': '8px'}), | |
| html.Div(id='game-state-panel', children=[ | |
| html.Div([ | |
| html.Span("Turn", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("White", id='gs-turn', style={'color': '#FFF'}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Move #", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("1", id='gs-movenum', style={'color': GOLD}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Material", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("0", id='gs-material', style={'color': '#888'}) | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Phase", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}), | |
| html.Span("Opening", id='gs-phase', style={'color': '#888'}) | |
| ]), | |
| ], style={'fontFamily': 'monospace', 'fontSize': '12px'}) | |
| ], style=PANEL_STYLE), | |
| ], style={'flex': '0 0 220px', 'padding': '0 15px 0 0'}), | |
| # MIDDLE COLUMN - 3D Chess Board | |
| html.Div([ | |
| dcc.Graph(id='chess-3d', figure=create_figure(chess.Board()), | |
| config={'displayModeBar': True, 'scrollZoom': True, | |
| 'modeBarButtonsToRemove': ['toImage', 'sendDataToCloud']}, | |
| style={'height': '580px'}), | |
| # Move buttons (when HOLD) | |
| html.Div(id='move-buttons', style={'textAlign': 'center', 'padding': '10px'}) | |
| ], style={'flex': '1', 'minWidth': '450px'}), | |
| # RIGHT COLUMN - Cascade Panel | |
| html.Div([ | |
| # Cascade Trace Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': CYAN}), | |
| html.Span("CAUSATION TRACE", style={'color': '#888'}) | |
| ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px', | |
| 'borderBottom': f'1px solid {CYAN}33', 'paddingBottom': '8px'}), | |
| html.Div(id='cascade-trace', children=[ | |
| html.Div("Waiting for move...", style={'color': '#444', 'fontStyle': 'italic', | |
| 'padding': '20px', 'textAlign': 'center'}) | |
| ]) | |
| ], style={**PANEL_STYLE, 'marginBottom': '15px'}), | |
| # Decision Tree Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': GOLD}), | |
| html.Span("DECISION TREE", style={'color': '#888'}) | |
| ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px', | |
| 'borderBottom': f'1px solid {GOLD}33', 'paddingBottom': '8px'}), | |
| html.Div(id='decision-tree', children=[ | |
| html.Div("No candidates yet", style={'color': '#444', 'fontStyle': 'italic', | |
| 'padding': '20px', 'textAlign': 'center'}) | |
| ]) | |
| ], style={**PANEL_STYLE, 'marginBottom': '15px'}), | |
| # Metrics Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': MAGENTA}), | |
| html.Span("METRICS", style={'color': '#888'}) | |
| ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px', | |
| 'borderBottom': f'1px solid {MAGENTA}33', 'paddingBottom': '8px'}), | |
| html.Div(id='metrics-panel', children=[ | |
| html.Div([ | |
| html.Span("Total Latency: ", style={'color': '#555'}), | |
| html.Span("--ms", id='metric-latency', style={'color': CYAN}) | |
| ], style={'marginBottom': '5px'}), | |
| html.Div([ | |
| html.Span("Candidates: ", style={'color': '#555'}), | |
| html.Span("0", id='metric-candidates', style={'color': GOLD}) | |
| ], style={'marginBottom': '5px'}), | |
| html.Div([ | |
| html.Span("Confidence: ", style={'color': '#555'}), | |
| html.Span("--%", id='metric-confidence', style={'color': MAGENTA}) | |
| ]), | |
| ], style={'fontFamily': 'monospace', 'fontSize': '13px'}) | |
| ], style=PANEL_STYLE), | |
| ], style={'flex': '1', 'minWidth': '350px', 'maxWidth': '450px', 'padding': '0 15px'}), | |
| ], style={'display': 'flex', 'padding': '20px', 'gap': '10px', 'alignItems': 'flex-start'}), | |
| # Hidden stores | |
| dcc.Store(id='board-fen', data=chess.STARTING_FEN), | |
| dcc.Store(id='candidates-store', data=[]), | |
| dcc.Store(id='trace-store', data=[]), | |
| dcc.Store(id='decision-store', data=[]), | |
| dcc.Store(id='is-held', data=False), | |
| dcc.Store(id='auto-play', data=False), | |
| dcc.Store(id='move-history', data=[]), | |
| dcc.Store(id='camera-store', data=None), # Stores user's camera position | |
| # Auto-play interval | |
| dcc.Interval(id='auto-interval', interval=1200, disabled=True), | |
| ], style={'backgroundColor': BG_COLOR, 'minHeight': '100vh', 'padding': '0'}) | |
| def handle_controls(step, hold, reset, auto, interval, fen, candidates, is_held, history, auto_play): | |
| ctx = dash.callback_context | |
| if not ctx.triggered: | |
| return fen, candidates, [], [], is_held, history, auto_play | |
| trigger = ctx.triggered[0]['prop_id'].split('.')[0] | |
| board = chess.Board(fen) | |
| if trigger == 'btn-reset': | |
| return chess.STARTING_FEN, [], [], [], False, [], False | |
| if trigger == 'btn-auto': | |
| return fen, [], [], [], False, history, not auto_play # Toggle auto | |
| if trigger == 'btn-hold': | |
| if not is_held: | |
| cands, trace, decision = get_candidates_with_trace(board) | |
| return fen, [c.__dict__ for c in cands], trace, decision, True, history, False | |
| else: | |
| return fen, [], [], [], False, history, auto_play | |
| if trigger in ['btn-step', 'auto-interval']: | |
| if board.is_game_over(): | |
| return fen, [], [], [], False, history, False | |
| cands, trace, decision = get_candidates_with_trace(board) | |
| if cands: | |
| move = chess.Move.from_uci(cands[0].move) | |
| board.push(move) | |
| history = history + [cands[0].move] | |
| return board.fen(), [], trace, decision, False, history, auto_play | |
| return fen, candidates, [], [], is_held, history, auto_play | |
| def update_figure(fen, candidates_data, current_fig): | |
| board = chess.Board(fen) | |
| candidates = [MoveCandidate(**c) for c in candidates_data] if candidates_data else None | |
| # Extract camera from current figure if it exists | |
| camera = None | |
| if current_fig and 'layout' in current_fig: | |
| scene = current_fig['layout'].get('scene', {}) | |
| if 'camera' in scene: | |
| camera = scene['camera'] | |
| return create_figure(board, candidates, camera), "" | |
| def update_hold_button(is_held): | |
| if is_held: | |
| return "◉ HOLDING", {**BUTTON_BASE, 'color': '#000', 'backgroundColor': GOLD, | |
| 'border': f'2px solid {GOLD}', 'fontWeight': 'bold'} | |
| else: | |
| return "⏸ HOLD", {**BUTTON_BASE, 'color': GOLD, 'border': f'1px solid {GOLD}'} | |
| def update_status(fen, is_held, auto_play, history): | |
| board = chess.Board(fen) | |
| if board.is_game_over(): | |
| result = board.result() | |
| return f"GAME OVER: {result}" | |
| turn = "WHITE" if board.turn else "BLACK" | |
| mode = "◉ HOLD ACTIVE - Select a move" if is_held else ("▶▶ AUTO" if auto_play else "MANUAL") | |
| return f"Move {board.fullmove_number} | {turn} | {mode}" | |
| def toggle_auto(auto_play): | |
| return not auto_play | |
| def update_game_state(fen): | |
| board = chess.Board(fen) | |
| # Turn | |
| turn_text = "White" if board.turn else "Black" | |
| turn_style = {'color': '#FFF'} if board.turn else {'color': '#888'} | |
| # Move number | |
| move_num = str(board.fullmove_number) | |
| # Material count (simple piece values) | |
| piece_values = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3, | |
| chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0} | |
| white_material = sum(piece_values.get(p.piece_type, 0) | |
| for p in board.piece_map().values() if p.color == chess.WHITE) | |
| black_material = sum(piece_values.get(p.piece_type, 0) | |
| for p in board.piece_map().values() if p.color == chess.BLACK) | |
| diff = white_material - black_material | |
| if diff > 0: | |
| mat_text = f"+{diff} ⚪" | |
| mat_style = {'color': '#0F0'} | |
| elif diff < 0: | |
| mat_text = f"{diff} ⚫" | |
| mat_style = {'color': CRIMSON} | |
| else: | |
| mat_text = "Equal" | |
| mat_style = {'color': '#888'} | |
| # Game phase (rough estimate) | |
| total_pieces = len(board.piece_map()) | |
| if total_pieces >= 28: | |
| phase = "Opening" | |
| elif total_pieces >= 14: | |
| phase = "Middlegame" | |
| else: | |
| phase = "Endgame" | |
| if board.is_check(): | |
| phase = "⚠ CHECK" | |
| if board.is_game_over(): | |
| phase = "Game Over" | |
| return turn_text, turn_style, move_num, mat_text, mat_style, phase | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # CASCADE PANEL CALLBACKS | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def render_trace(trace_data): | |
| if not trace_data: | |
| return html.Div("Waiting for move...", | |
| style={'color': '#444', 'fontStyle': 'italic', 'padding': '20px', 'textAlign': 'center'}) | |
| rows = [] | |
| for t in trace_data: | |
| # Color code by operation type | |
| op_colors = {'ENCODE': CYAN, 'ANALYZE': '#888', 'SCORE': GOLD, 'HOLD': MAGENTA, 'SELECT': '#0F0'} | |
| op_color = op_colors.get(t['op'], '#666') | |
| # Confidence bar | |
| conf_pct = t['confidence'] * 100 | |
| row = html.Div([ | |
| # Step number | |
| html.Span(f"{t['step']}", style={'color': '#444', 'width': '20px', 'marginRight': '10px'}), | |
| # Operation badge | |
| html.Span(t['op'], style={ | |
| 'backgroundColor': f'{op_color}22', 'color': op_color, | |
| 'padding': '2px 8px', 'borderRadius': '3px', 'fontSize': '10px', | |
| 'fontWeight': 'bold', 'width': '60px', 'textAlign': 'center', 'marginRight': '10px' | |
| }), | |
| # Detail | |
| html.Span(t['detail'], style={'color': '#888', 'flex': '1', 'fontSize': '11px'}), | |
| # Duration | |
| html.Span(f"{t['duration']}ms", style={'color': '#555', 'width': '60px', 'textAlign': 'right'}), | |
| ], style={**TRACE_ROW_STYLE}) | |
| rows.append(row) | |
| # Total latency | |
| total = sum(t['duration'] for t in trace_data) | |
| rows.append(html.Div([ | |
| html.Span("", style={'width': '90px'}), | |
| html.Span("TOTAL", style={'color': CYAN, 'fontWeight': 'bold', 'flex': '1'}), | |
| html.Span(f"{total:.1f}ms", style={'color': CYAN, 'fontWeight': 'bold', 'width': '60px', 'textAlign': 'right'}) | |
| ], style={**TRACE_ROW_STYLE, 'borderBottom': 'none', 'backgroundColor': '#0a0a0f'})) | |
| return rows | |
| def render_decision_tree(decision_data): | |
| if not decision_data: | |
| return html.Div("No candidates yet", | |
| style={'color': '#444', 'fontStyle': 'italic', 'padding': '20px', 'textAlign': 'center'}) | |
| rows = [] | |
| for d in decision_data: | |
| is_selected = d.get('selected', False) | |
| bg_color = f'{CYAN}15' if is_selected else 'transparent' | |
| border_left = f'3px solid {CYAN}' if is_selected else '3px solid transparent' | |
| # Probability bar | |
| prob_pct = d['prob'] * 100 | |
| row = html.Div([ | |
| # Rank | |
| html.Span(f"#{d['rank']}", style={ | |
| 'color': CYAN if is_selected else '#555', | |
| 'width': '30px', 'fontWeight': 'bold' if is_selected else 'normal' | |
| }), | |
| # Move | |
| html.Span(d['move'], style={ | |
| 'color': '#FFF' if is_selected else '#888', | |
| 'fontWeight': 'bold', 'width': '55px', 'fontFamily': 'monospace' | |
| }), | |
| # Eval | |
| html.Span(d['eval'], style={ | |
| 'color': GOLD if 'M' in str(d['eval']) else ('#0F0' if d['eval'][0] == '+' else CRIMSON), | |
| 'width': '55px', 'textAlign': 'right' | |
| }), | |
| # Probability bar | |
| html.Div([ | |
| html.Div(style={ | |
| 'width': f'{prob_pct}%', 'height': '8px', | |
| 'backgroundColor': CYAN if is_selected else '#333', | |
| 'borderRadius': '2px' | |
| }) | |
| ], style={'flex': '1', 'backgroundColor': '#1a1a2e', 'borderRadius': '2px', 'marginLeft': '10px'}), | |
| # Percentage | |
| html.Span(f"{prob_pct:.0f}%", style={'color': '#666', 'width': '40px', 'textAlign': 'right', 'marginLeft': '8px'}), | |
| # Flags | |
| html.Span( | |
| ("⚔" if d.get('capture') else "") + ("♚" if d.get('check') else ""), | |
| style={'color': CRIMSON, 'width': '25px', 'textAlign': 'right'} | |
| ) | |
| ], style={ | |
| 'display': 'flex', 'alignItems': 'center', 'padding': '8px 10px', | |
| 'backgroundColor': bg_color, 'borderLeft': border_left, | |
| 'marginBottom': '4px', 'borderRadius': '3px', | |
| 'fontFamily': 'monospace', 'fontSize': '12px' | |
| }) | |
| rows.append(row) | |
| return rows | |
| def update_metrics(trace_data, decision_data): | |
| if not trace_data: | |
| return "--ms", "0", "--%" | |
| total_latency = sum(t['duration'] for t in trace_data) | |
| num_candidates = len(decision_data) if decision_data else 0 | |
| confidence = decision_data[0]['prob'] * 100 if decision_data else 0 | |
| return f"{total_latency:.1f}ms", str(num_candidates), f"{confidence:.0f}%" | |
| def show_move_buttons(candidates_data, is_held): | |
| if not is_held or not candidates_data: | |
| return [] | |
| # Get whose turn it is from the current board state (we'll need this for styling) | |
| buttons = [] | |
| for i, c in enumerate(candidates_data): | |
| prob_pct = c['prob'] * 100 | |
| is_top = i == 0 | |
| btn_style = { | |
| 'margin': '5px', 'padding': '10px 20px', 'fontSize': '13px', | |
| 'fontFamily': 'monospace', 'borderRadius': '4px', 'cursor': 'pointer', | |
| 'backgroundColor': '#1a1a2e' if not is_top else f'{CYAN}22', | |
| 'color': CYAN if is_top else '#888', | |
| 'border': f'1px solid {CYAN}' if is_top else '1px solid #333' | |
| } | |
| btn = html.Button( | |
| f"{c['move']} ({prob_pct:.0f}%)", | |
| id={'type': 'move-btn', 'index': i}, | |
| style=btn_style | |
| ) | |
| buttons.append(btn) | |
| return buttons | |
| # Move selection callback | |
| def select_move(clicks, fen, candidates_data, trace_data, decision_data, history): | |
| ctx = dash.callback_context | |
| # Only proceed if an actual button was clicked | |
| if not ctx.triggered: | |
| raise dash.exceptions.PreventUpdate | |
| # Check if any click actually happened (not just initialization) | |
| if not clicks or not any(c for c in clicks if c): | |
| raise dash.exceptions.PreventUpdate | |
| # Find which button was clicked | |
| triggered_id = ctx.triggered[0]['prop_id'] | |
| if triggered_id == '.': | |
| raise dash.exceptions.PreventUpdate | |
| import json | |
| try: | |
| idx = json.loads(triggered_id.split('.')[0])['index'] | |
| except: | |
| raise dash.exceptions.PreventUpdate | |
| board = chess.Board(fen) | |
| if candidates_data and idx < len(candidates_data): | |
| move_uci = candidates_data[idx]['move'] | |
| move = chess.Move.from_uci(move_uci) | |
| board.push(move) | |
| history = history + [move_uci] | |
| # Return with trace/decision preserved for display | |
| return board.fen(), [], trace_data, decision_data, False, history | |
| if __name__ == '__main__': | |
| print("\n" + "="*50) | |
| print("CASCADE-LATTICE Chess") | |
| print("="*50) | |
| print("Open: http://127.0.0.1:8050") | |
| print("="*50 + "\n") | |
| # Note: debug=False to avoid Python 3.13 socket issues on Windows | |
| app.run(debug=False, port=8050) | |