""" 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 # ═══════════════════════════════════════════════════════════════════════════════ @dataclass 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' @dataclass 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"{cand.move}
Probability: {cand.prob*100:.1f}%
Eval: {cand.value:+.2f}" )) # 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'}) @callback( Output('board-fen', 'data'), Output('candidates-store', 'data'), Output('trace-store', 'data'), Output('decision-store', 'data'), Output('is-held', 'data'), Output('move-history', 'data'), Output('auto-play', 'data'), Input('btn-step', 'n_clicks'), Input('btn-hold', 'n_clicks'), Input('btn-reset', 'n_clicks'), Input('btn-auto', 'n_clicks'), Input('auto-interval', 'n_intervals'), State('board-fen', 'data'), State('candidates-store', 'data'), State('is-held', 'data'), State('move-history', 'data'), State('auto-play', 'data'), prevent_initial_call=True ) 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 @callback( Output('chess-3d', 'figure'), Output('loading-output', 'children'), Input('board-fen', 'data'), Input('candidates-store', 'data'), State('chess-3d', 'figure') # Read current figure to get its camera ) 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), "" @callback( Output('btn-hold', 'children'), Output('btn-hold', 'style'), Input('is-held', 'data') ) 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}'} @callback( Output('status', 'children'), Input('board-fen', 'data'), Input('is-held', 'data'), Input('auto-play', 'data'), Input('move-history', 'data') ) 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}" @callback( Output('auto-interval', 'disabled'), Input('auto-play', 'data') ) def toggle_auto(auto_play): return not auto_play @callback( Output('gs-turn', 'children'), Output('gs-turn', 'style'), Output('gs-movenum', 'children'), Output('gs-material', 'children'), Output('gs-material', 'style'), Output('gs-phase', 'children'), Input('board-fen', 'data') ) 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 # ═══════════════════════════════════════════════════════════════════════════════ @callback( Output('cascade-trace', 'children'), Input('trace-store', 'data') ) 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 @callback( Output('decision-tree', 'children'), Input('decision-store', 'data') ) 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 @callback( Output('metric-latency', 'children'), Output('metric-candidates', 'children'), Output('metric-confidence', 'children'), Input('trace-store', 'data'), Input('decision-store', 'data') ) 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}%" @callback( Output('move-buttons', 'children'), Input('candidates-store', 'data'), Input('is-held', 'data') ) 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 @callback( Output('board-fen', 'data', allow_duplicate=True), Output('candidates-store', 'data', allow_duplicate=True), Output('trace-store', 'data', allow_duplicate=True), Output('decision-store', 'data', allow_duplicate=True), Output('is-held', 'data', allow_duplicate=True), Output('move-history', 'data', allow_duplicate=True), Input({'type': 'move-btn', 'index': dash.ALL}, 'n_clicks'), State('board-fen', 'data'), State('candidates-store', 'data'), State('trace-store', 'data'), State('decision-store', 'data'), State('move-history', 'data'), prevent_initial_call=True ) 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)