Spaces:
Sleeping
Sleeping
| """ | |
| CASCADE-LATTICE Chess - Three.js Version | |
| ======================================== | |
| Three.js for 3D (camera control!) + Dash for UI | |
| """ | |
| import dash | |
| from dash import dcc, html, callback, Input, Output, State, clientside_callback | |
| from dash.exceptions import PreventUpdate | |
| import dash_cytoscape as cyto | |
| import plotly.graph_objects as go | |
| # Load dagre layout for hierarchical graphs | |
| cyto.load_extra_layouts() | |
| import json | |
| 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() | |
| # Try SSE4.1 first (more compatible), then AVX2 | |
| LOCAL_STOCKFISH_SSE41 = PROJECT_ROOT / "stockfish" / "stockfish" / "stockfish-windows-x86-64-sse41-popcnt.exe" | |
| LOCAL_STOCKFISH_AVX2 = PROJECT_ROOT / "stockfish" / "stockfish-windows-x86-64-avx2.exe" | |
| if LOCAL_STOCKFISH_SSE41.exists(): | |
| STOCKFISH_PATH = str(LOCAL_STOCKFISH_SSE41) | |
| elif LOCAL_STOCKFISH_AVX2.exists(): | |
| STOCKFISH_PATH = str(LOCAL_STOCKFISH_AVX2) | |
| 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 with thread lock (Dash callbacks are concurrent) | |
| import threading | |
| ENGINE_LOCK = threading.Lock() | |
| POSITION_LOCK = threading.Lock() # Lock for RECORDED_POSITIONS check-and-add | |
| ENGINE = None | |
| def get_engine(): | |
| """Get or restart the engine if it died.""" | |
| global ENGINE | |
| with ENGINE_LOCK: | |
| if ENGINE is None: | |
| try: | |
| ENGINE = chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH) | |
| print("[ENGINE] Started") | |
| except Exception as e: | |
| print(f"[ENGINE] Failed to start: {e}") | |
| return None | |
| return ENGINE | |
| def restart_engine(): | |
| """Force restart the engine.""" | |
| global ENGINE | |
| with ENGINE_LOCK: | |
| if ENGINE: | |
| try: | |
| ENGINE.quit() | |
| except: | |
| pass | |
| ENGINE = None | |
| try: | |
| ENGINE = chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH) | |
| print("[ENGINE] Restarted") | |
| except Exception as e: | |
| print(f"[ENGINE] Restart failed: {e}") | |
| ENGINE = None | |
| return ENGINE | |
| # Initial load | |
| 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 | |
| CausalEvent = None | |
| TRACER = None | |
| LAST_RESOLUTION = None | |
| RECORDED_POSITIONS = set() # Track FENs we've recorded yield_points for | |
| try: | |
| from cascade import Hold, HoldResolution | |
| HOLD = Hold() | |
| HOLD.auto_accept = False # We handle resolution manually | |
| HOLD.timeout = 300 # 5 min timeout | |
| CASCADE_AVAILABLE = True | |
| print("[CASCADE] Hold ready") | |
| except ImportError: | |
| print("[CASCADE] Hold not available") | |
| try: | |
| from cascade import CausationGraph | |
| from cascade.core.event import Event as CausalEvent | |
| CAUSATION = CausationGraph() | |
| print("[CASCADE] CausationGraph ready") | |
| except Exception as e: | |
| print(f"[CASCADE] CausationGraph init failed: {e}") | |
| try: | |
| from cascade import Tracer | |
| if CAUSATION is not None: | |
| TRACER = Tracer(CAUSATION) | |
| print("[CASCADE] Tracer ready") | |
| except Exception as e: | |
| print(f"[CASCADE] Tracer init failed: {e}") | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # 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 - SOLID COLORS (not neon/pastel) | |
| GOLD = '#D4A020' # Deep gold (was #D4A020) | |
| CRIMSON = '#B01030' # Deep crimson (was #DC143C) | |
| CYAN = '#2090B0' # Steel blue (was #2090B0 neon cyan) | |
| MAGENTA = '#A03070' # Deep magenta (was #A03070 neon pink) | |
| GREEN = '#308040' # Forest green | |
| ORANGE = '#D06020' # Burnt orange | |
| WHITE_ACCENT = '#E8E0D0' # Warm white | |
| BLACK_ACCENT = '#303030' # Charcoal | |
| 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 | |
| import numpy as np | |
| def extract_features(board: chess.Board) -> dict: | |
| """Extract chess features for cascade-lattice.""" | |
| # Material count | |
| material = 0 | |
| for piece_type in [chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN]: | |
| material += len(board.pieces(piece_type, chess.WHITE)) - len(board.pieces(piece_type, chess.BLACK)) | |
| # Center control (d4, d5, e4, e5) | |
| center_squares = [chess.D4, chess.D5, chess.E4, chess.E5] | |
| white_center = sum(1 for sq in center_squares if board.is_attacked_by(chess.WHITE, sq)) | |
| black_center = sum(1 for sq in center_squares if board.is_attacked_by(chess.BLACK, sq)) | |
| # King safety (attackers near king) | |
| wk = board.king(chess.WHITE) | |
| bk = board.king(chess.BLACK) | |
| white_king_danger = len(board.attackers(chess.BLACK, wk)) if wk else 0 | |
| black_king_danger = len(board.attackers(chess.WHITE, bk)) if bk else 0 | |
| # Mobility - save and restore turn properly with try/finally for safety | |
| original_turn = board.turn | |
| try: | |
| board.turn = chess.WHITE | |
| white_mobility = len(list(board.legal_moves)) | |
| board.turn = chess.BLACK | |
| black_mobility = len(list(board.legal_moves)) | |
| finally: | |
| board.turn = original_turn # Always restore even if exception | |
| return { | |
| 'material': material, | |
| 'center_control': (white_center - black_center) / 4.0, | |
| 'white_king_danger': white_king_danger, | |
| 'black_king_danger': black_king_danger, | |
| 'white_mobility': white_mobility, | |
| 'black_mobility': black_mobility, | |
| 'phase': 'opening' if board.fullmove_number < 10 else ('middlegame' if board.fullmove_number < 30 else 'endgame') | |
| } | |
| def get_candidates_with_trace(board: chess.Board, num=5) -> tuple: | |
| """Get move candidates from Stockfish WITH cascade-lattice integration.""" | |
| global TRACE_LOG, DECISION_TREE, ENGINE | |
| trace_data = [] | |
| decision_data = [] | |
| hold_data = {} # Rich data from cascade Hold | |
| start_time = time.perf_counter() | |
| # Use lock for all engine operations | |
| with ENGINE_LOCK: | |
| if not ENGINE: | |
| ENGINE = restart_engine() | |
| if not ENGINE: | |
| return [], trace_data, decision_data, hold_data | |
| try: | |
| # TRACE: Board state encoding | |
| t0 = time.perf_counter() | |
| fen = board.fen() | |
| fen_turn = 'WHITE' if ' w ' in fen else 'BLACK' | |
| print(f"[GET_CANDIDATES] Called for {fen_turn} position: {fen[:50]}...") | |
| features = extract_features(board) | |
| 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() | |
| turn_before = "WHITE" if board.turn else "BLACK" | |
| info = ENGINE.analyse(board, chess.engine.Limit(depth=12), multipv=num) | |
| trace_data.append({ | |
| 'step': 2, 'op': 'ANALYZE', 'detail': f'Stockfish depth=12', | |
| 'input': 'state_vec', 'output': f'{len(info)} candidates', | |
| 'duration': round((time.perf_counter() - t1) * 1000, 2), | |
| 'confidence': 0.95 | |
| }) | |
| candidates = [] | |
| action_labels = [] | |
| raw_cp_scores = [] # Raw centipawn scores from engine | |
| # TRACE: Candidate scoring - observe engine output directly | |
| 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(): | |
| cp = 10000 if score.mate() > 0 else -10000 | |
| eval_str = f"M{score.mate()}" | |
| else: | |
| cp = score.relative.score(mate_score=10000) | |
| eval_str = f"{cp:+d}cp" | |
| raw_cp_scores.append(cp) | |
| action_labels.append(move.uci()) | |
| # Move type classification | |
| move_type = 'quiet' | |
| if board.is_capture(move): | |
| move_type = 'capture' | |
| elif board.gives_check(move): | |
| move_type = 'check' | |
| elif board.is_castling(move): | |
| move_type = 'castle' | |
| cand = MoveCandidate( | |
| move=move.uci(), prob=0, value=cp, # Store raw cp as value | |
| from_sq=move.from_square, to_sq=move.to_square, | |
| is_capture=board.is_capture(move), | |
| is_check=board.gives_check(move), | |
| move_type=move_type | |
| ) | |
| candidates.append(cand) | |
| # Observe engine's raw output - normalize cp scores for arc visualization only | |
| # This is just for visual scaling, not fake probabilities | |
| cp_arr = np.array(raw_cp_scores) | |
| cp_min, cp_max = cp_arr.min(), cp_arr.max() | |
| cp_range = max(cp_max - cp_min, 1) # Avoid div by zero | |
| for i, c in enumerate(candidates): | |
| # Scale for visualization: best move = 1.0, others proportionally less | |
| c.prob = float((raw_cp_scores[i] - cp_min) / cp_range) if cp_range > 0 else 1.0 | |
| decision_data.append({ | |
| 'move': c.move, | |
| 'eval': f"{raw_cp_scores[i]:+d}cp", # Show actual centipawn | |
| 'prob': c.prob, # Visual scale factor | |
| 'cp': raw_cp_scores[i], # Raw centipawn for display | |
| 'rank': i + 1, | |
| 'capture': c.is_capture, | |
| 'check': c.is_check, | |
| 'move_type': c.move_type, | |
| 'selected': i == 0 | |
| }) | |
| trace_data.append({ | |
| 'step': 3, 'op': 'OBSERVE', 'detail': f'Engine evaluation (raw cp)', | |
| 'input': f'{len(candidates)} moves', 'output': f'best={raw_cp_scores[0]:+d}cp', | |
| 'duration': round((time.perf_counter() - t2) * 1000, 2), | |
| 'confidence': 0.88 | |
| }) | |
| # CASCADE HOLD INTEGRATION | |
| t3 = time.perf_counter() | |
| if HOLD and candidates: | |
| # Build imagination (predicted responses) | |
| imagination = {} | |
| for i, c in enumerate(candidates[:3]): # Top 3 | |
| test_board = board.copy() | |
| test_board.push(chess.Move.from_uci(c.move)) | |
| if not test_board.is_game_over(): | |
| try: | |
| response_info = ENGINE.analyse(test_board, chess.engine.Limit(depth=8), multipv=1) | |
| if response_info: | |
| resp_move = response_info[0]['pv'][0].uci() | |
| resp_score = response_info[0].get('score', chess.engine.Cp(0)) | |
| if resp_score.is_mate(): | |
| resp_val = -1.0 if resp_score.mate() > 0 else 1.0 | |
| else: | |
| cp = resp_score.relative.score(mate_score=10000) | |
| resp_val = -max(-1, min(1, cp / 1000)) # Flip for opponent | |
| imagination[i] = { | |
| 'predicted_response': resp_move, | |
| 'value_after_response': resp_val, | |
| 'continuation': f"{c.move} → {resp_move}" | |
| } | |
| except: | |
| pass | |
| # Build reasoning | |
| reasoning = [] | |
| if candidates[0].is_capture: | |
| reasoning.append("Top move captures material") | |
| if candidates[0].is_check: | |
| reasoning.append("Top move gives check") | |
| if features['material'] > 2: | |
| reasoning.append("White has material advantage") | |
| elif features['material'] < -2: | |
| reasoning.append("Black has material advantage") | |
| if features['center_control'] > 0.5: | |
| reasoning.append("Strong center control") | |
| if features[f"{'white' if board.turn else 'black'}_king_danger"] > 0: | |
| reasoning.append("King under attack - defensive needed") | |
| if len(candidates) > 1 and abs(candidates[0].value - candidates[1].value) < 0.1: | |
| reasoning.append("Multiple strong options available") | |
| # Store hold data for UI - use raw centipawn, not fake probabilities | |
| hold_data = { | |
| 'action_probs': [c.prob for c in candidates], # Visual scale | |
| 'action_labels': action_labels, | |
| 'value': float(candidates[0].value), # Best cp score | |
| 'observation': {'fen': fen, 'turn': 'white' if board.turn else 'black'}, | |
| 'features': features, | |
| 'reasoning': reasoning, | |
| 'imagination': imagination, | |
| 'ai_choice': 0, | |
| 'ai_confidence': float(candidates[0].prob), # Visual scale of best move | |
| 'merkle': None | |
| } | |
| # ═══════════════════════════════════════════════════════════════ | |
| # CASCADE-LATTICE: Create actual Hold yield point | |
| # ═══════════════════════════════════════════════════════════════ | |
| if HOLD and CASCADE_AVAILABLE: | |
| try: | |
| # Convert probs to numpy array for yield_point | |
| action_probs_np = np.array([c.prob for c in candidates]) | |
| # Call yield_point in NON-BLOCKING mode | |
| # We handle the UI/resolution ourselves via Dash | |
| resolution = HOLD.yield_point( | |
| action_probs=action_probs_np, | |
| value=float(candidates[0].value), | |
| observation={'fen': fen, 'turn': 'white' if board.turn else 'black'}, | |
| brain_id='stockfish-17', | |
| action_labels=action_labels, | |
| features=features, | |
| imagination=imagination, | |
| reasoning=reasoning, | |
| blocking=False # Don't block - Dash handles UI | |
| ) | |
| # Get merkle from the current hold point | |
| if HOLD.current_hold: | |
| hold_data['merkle'] = HOLD.current_hold.merkle_root | |
| print(f"[CASCADE] yield_point created: {hold_data['merkle'][:16]}...") | |
| elif resolution: | |
| hold_data['merkle'] = resolution.merkle_root | |
| print(f"[CASCADE] yield_point (non-blocking): {hold_data['merkle'][:16]}...") | |
| # ═══════════════════════════════════════════════════════════════ | |
| # CAUSATION GRAPH: Record FULL decision matrix | |
| # Only record if we haven't already recorded this position | |
| # Use lock to prevent race condition in check-then-add | |
| # ═══════════════════════════════════════════════════════════════ | |
| global RECORDED_POSITIONS | |
| if CAUSATION is not None and CausalEvent is not None and hold_data.get('merkle'): | |
| with POSITION_LOCK: | |
| # Check if we've already recorded this position (atomic with add) | |
| if fen in RECORDED_POSITIONS: | |
| print(f"[CASCADE] Skipping duplicate yield_point for position (already recorded)") | |
| else: | |
| RECORDED_POSITIONS.add(fen) # Mark IMMEDIATELY to prevent race | |
| import time as time_mod | |
| # Capture ALL candidates with their evaluations | |
| all_candidates = [] | |
| for i, c in enumerate(candidates): | |
| # Parse from/to squares from UCI move notation | |
| move_uci = c.move | |
| from_sq_name = move_uci[:2] if len(move_uci) >= 4 else None | |
| to_sq_name = move_uci[2:4] if len(move_uci) >= 4 else None | |
| # Convert to numeric indices (0-63) for Three.js camera | |
| try: | |
| from_sq_idx = chess.parse_square(from_sq_name) if from_sq_name else 0 | |
| to_sq_idx = chess.parse_square(to_sq_name) if to_sq_name else 0 | |
| except: | |
| from_sq_idx = 0 | |
| to_sq_idx = 0 | |
| # Try to get piece from the board | |
| try: | |
| piece_at = board.piece_at(from_sq_idx) if from_sq_name else None | |
| piece_symbol = piece_at.symbol().upper() if piece_at else '?' | |
| except: | |
| piece_symbol = '?' | |
| all_candidates.append({ | |
| 'move': c.move, | |
| 'score': c.value, | |
| 'prob': c.prob, | |
| 'rank': i + 1, | |
| 'is_capture': c.is_capture, | |
| 'is_check': c.is_check, | |
| 'move_type': c.move_type, | |
| 'from_sq': from_sq_idx, # Numeric index 0-63 | |
| 'to_sq': to_sq_idx, # Numeric index 0-63 | |
| 'piece': piece_symbol | |
| }) | |
| # Get turn from FEN directly for consistency (not board.turn which could be modified) | |
| fen_turn = 'white' if ' w ' in fen else 'black' | |
| event = CausalEvent( | |
| timestamp=time_mod.time(), | |
| component="inference", | |
| event_type="yield_point", | |
| data={ | |
| 'fen': fen, | |
| 'turn': fen_turn, | |
| 'top_move': candidates[0].move if candidates else None, | |
| 'top_score': candidates[0].value if candidates else None, | |
| 'num_candidates': len(candidates), | |
| 'merkle': hold_data['merkle'], | |
| 'brain_id': 'stockfish-17', | |
| # FULL DECISION MATRIX | |
| 'all_candidates': all_candidates, | |
| 'imagination': imagination, | |
| 'reasoning': reasoning, | |
| # FULL STATE FOR TIME TRAVEL | |
| 'trace_data': trace_data, # Full trace for this position | |
| 'decision_data': decision_data, # Full decision tree | |
| 'hold_data': hold_data, # Full wealth data | |
| }, | |
| event_id=f"inference_{hold_data['merkle'][:8]}" | |
| ) | |
| CAUSATION.add_event(event) | |
| print(f"[CASCADE] CausationGraph: recorded {fen_turn} inference with {len(all_candidates)} candidates") | |
| except Exception as e: | |
| print(f"[CASCADE] yield_point error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| merkle_short = hold_data.get('merkle', 'N/A')[:12] + '...' if hold_data.get('merkle') else 'pending' | |
| trace_data.append({ | |
| 'step': 4, 'op': 'HOLD', 'detail': f'cascade.Hold yield_point()', | |
| 'input': 'wealth + imagination', 'output': f'merkle: {merkle_short}', | |
| 'duration': round((time.perf_counter() - t3) * 1000, 2), | |
| 'confidence': candidates[0].prob if candidates else 0 | |
| }) | |
| # TRACE: Ready for inspection | |
| total_time = (time.perf_counter() - start_time) * 1000 | |
| trace_data.append({ | |
| 'step': 5, 'op': 'YIELD', 'detail': f'Awaiting human decision', | |
| 'input': f'{len(candidates)} candidates', | |
| 'output': '⏸ HELD', | |
| 'duration': round(total_time, 2), | |
| 'confidence': 1.0 | |
| }) | |
| TRACE_LOG = trace_data | |
| DECISION_TREE = decision_data | |
| return candidates, trace_data, decision_data, hold_data | |
| except chess.engine.EngineTerminatedError: | |
| print("[ENGINE] Crashed - restarting...") | |
| ENGINE = restart_engine() | |
| return [], [], [], {} | |
| except Exception as e: | |
| print(f"[ENGINE] Error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| 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) | |
| server = app.server # Flask server for custom routes | |
| # Set viewport meta tag for mobile responsiveness | |
| app.index_string = ''' | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| {%metas%} | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>CASCADE // LATTICE - Chess Inference Visualization</title> | |
| {%favicon%} | |
| {%css%} | |
| <style> | |
| /* Mobile-responsive CSS */ | |
| @media (max-width: 1100px) { | |
| /* Tablet: stack panels above/below board */ | |
| .main-layout { flex-direction: column !important; align-items: center !important; } | |
| .main-layout > div { flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; } | |
| /* Left/right panels become horizontal rows */ | |
| .left-panels, .right-panels { | |
| display: flex !important; | |
| flex-direction: row !important; | |
| gap: 8px !important; | |
| max-width: 100% !important; | |
| margin-bottom: 10px !important; | |
| } | |
| .left-panels > div, .right-panels > div { | |
| flex: 1 !important; | |
| min-height: 180px !important; | |
| margin-bottom: 0 !important; | |
| } | |
| /* Chess board full width */ | |
| .board-column { flex: none !important; width: 100% !important; max-width: 100% !important; order: -1 !important; } | |
| .board-column iframe { height: 420px !important; } | |
| } | |
| @media (max-width: 900px) { | |
| /* Small tablet / large phone */ | |
| .left-panels, .right-panels { | |
| flex-direction: column !important; | |
| } | |
| .left-panels > div, .right-panels > div { | |
| min-height: 150px !important; | |
| } | |
| .board-column iframe { height: 380px !important; } | |
| /* Causation graph section */ | |
| .causation-section { margin: 0 10px 10px 10px !important; } | |
| .causation-content { flex-direction: column !important; } | |
| .causation-content > div { min-width: 100% !important; margin-right: 0 !important; margin-bottom: 10px !important; flex: none !important; } | |
| .causation-content .graph-container { min-width: 100% !important; } | |
| /* Controls wrap on mobile */ | |
| .controls-bar { flex-wrap: wrap !important; padding: 8px !important; } | |
| .controls-bar button { padding: 10px 16px !important; margin: 4px !important; font-size: 12px !important; } | |
| .human-input-group { margin-left: 0 !important; margin-top: 8px !important; padding-left: 0 !important; border-left: none !important; width: 100% !important; justify-content: center !important; } | |
| /* Header badges wrap */ | |
| .badge-row { gap: 5px !important; } | |
| .badge-row > div { padding: 6px 10px !important; font-size: 10px !important; } | |
| /* Title smaller on mobile */ | |
| .main-title { font-size: 1.3em !important; } | |
| .title-row .subtitle { display: none !important; } | |
| /* Timeline controls compact */ | |
| .timeline-header { flex-direction: column !important; gap: 8px !important; } | |
| .timeline-controls button { padding: 6px 10px !important; } | |
| } | |
| @media (max-width: 600px) { | |
| /* Phone screens */ | |
| .board-column iframe { height: 300px !important; } | |
| .main-title { font-size: 1.1em !important; letter-spacing: 0.05em !important; } | |
| .badge-row > div { padding: 5px 8px !important; } | |
| .badge-row { justify-content: center !important; } | |
| .controls-bar button { padding: 8px 12px !important; font-size: 11px !important; min-width: 55px !important; } | |
| /* Stack all panels vertically */ | |
| .left-panels, .right-panels { | |
| flex-direction: column !important; | |
| } | |
| .left-panels > div, .right-panels > div { | |
| min-height: 120px !important; | |
| } | |
| /* Smaller panel text */ | |
| .left-panels, .right-panels { font-size: 10px !important; } | |
| } | |
| @media (max-width: 400px) { | |
| /* Very small phones */ | |
| .board-column iframe { height: 260px !important; } | |
| .main-title { font-size: 0.95em !important; } | |
| .badge-row > div { display: none !important; } | |
| .badge-row > div:first-child { display: flex !important; } /* Only show ENGINE badge */ | |
| } | |
| /* Cinematic replay pulse animation */ | |
| @keyframes pulse { | |
| 0% { opacity: 1; box-shadow: 0 0 5px #FF4444; } | |
| 50% { opacity: 0.7; box-shadow: 0 0 20px #FF4444; } | |
| 100% { opacity: 1; box-shadow: 0 0 5px #FF4444; } | |
| } | |
| /* Time travel glow effect */ | |
| @keyframes timetravel-glow { | |
| 0% { box-shadow: 0 0 5px #2090B0; } | |
| 50% { box-shadow: 0 0 25px #2090B0, 0 0 50px #2090B044; } | |
| 100% { box-shadow: 0 0 5px #2090B0; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| {%app_entry%} | |
| <footer> | |
| {%config%} | |
| {%scripts%} | |
| {%renderer%} | |
| </footer> | |
| </body> | |
| </html> | |
| ''' | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # FLASK API: Expose CausationGraph data | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| from flask import jsonify | |
| def api_causation(): | |
| """Return the causation graph data as JSON.""" | |
| if not CAUSATION: | |
| return jsonify({'error': 'CausationGraph not initialized', 'events': [], 'stats': {}}) | |
| try: | |
| stats = CAUSATION.get_stats() | |
| recent = CAUSATION.get_recent_events(50) | |
| events = [] | |
| for e in recent: | |
| events.append({ | |
| 'event_id': getattr(e, 'event_id', None), | |
| 'component': getattr(e, 'component', None), | |
| 'event_type': getattr(e, 'event_type', None), | |
| 'timestamp': getattr(e, 'timestamp', None), | |
| 'data': getattr(e, 'data', {}) | |
| }) | |
| return jsonify({ | |
| 'stats': stats, | |
| 'events': events, | |
| 'total': len(recent) | |
| }) | |
| except Exception as ex: | |
| return jsonify({'error': str(ex), 'events': [], 'stats': {}}) | |
| def api_hold(): | |
| """Return the HOLD system status.""" | |
| if not HOLD: | |
| return jsonify({'error': 'Hold not initialized'}) | |
| try: | |
| stats = HOLD.stats() | |
| return jsonify({ | |
| 'stats': stats, | |
| 'auto_accept': HOLD.auto_accept, | |
| 'timeout': HOLD.timeout, | |
| 'last_resolution': LAST_RESOLUTION | |
| }) | |
| except Exception as ex: | |
| return jsonify({'error': str(ex)}) | |
| # 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', | |
| 'minWidth': '70px' # Touch-friendly minimum | |
| } | |
| app.layout = html.Div([ | |
| # Header with status badges (formerly left column panels) | |
| html.Div([ | |
| html.Div([ | |
| html.H1("CASCADE // LATTICE", | |
| className='main-title', | |
| style={'color': CYAN, 'margin': 0, 'fontFamily': 'Consolas, monospace', | |
| 'fontSize': '2em', 'letterSpacing': '0.1em', 'display': 'inline-block'}), | |
| html.Span(" × ", style={'color': '#333', 'fontSize': '1.3em', 'margin': '0 10px'}), | |
| html.Span("INFERENCE VISUALIZATION", | |
| className='subtitle', | |
| style={'color': '#444', 'fontFamily': 'monospace', 'fontSize': '1em'}) | |
| ], className='title-row', style={'marginBottom': '10px'}), | |
| # Status badges row (compact versions of left column panels) | |
| html.Div([ | |
| # ENGINE badge | |
| html.Div([ | |
| html.Span("◈ ", style={'color': '#FF6B35', 'fontSize': '12px'}), | |
| html.Span("ENGINE ", style={'color': '#666', 'fontSize': '11px'}), | |
| html.Span("Stockfish 17", style={'color': '#FF6B35', 'fontWeight': 'bold', 'fontSize': '11px'}), | |
| html.Span(" • ", style={'color': '#333'}), | |
| html.Span("10 ply", style={'color': CYAN, 'fontSize': '10px'}), | |
| html.Span(" • ", style={'color': '#333'}), | |
| html.Span("● ", style={'color': '#0F0' if ENGINE else CRIMSON, 'fontSize': '10px'}), | |
| ], style={'backgroundColor': '#0d0d12', 'padding': '8px 15px', 'borderRadius': '20px', | |
| 'border': '1px solid #FF6B3533', 'fontFamily': 'monospace', 'marginRight': '10px'}), | |
| # CASCADE-LATTICE badge | |
| html.Div([ | |
| html.Span("◈ ", style={'color': CYAN, 'fontSize': '12px'}), | |
| html.Span("CASCADE ", style={'color': '#666', 'fontSize': '11px'}), | |
| html.Span("v0.5.6", style={'color': CYAN, 'fontSize': '10px'}), | |
| html.Span(" • Hold ", style={'color': '#555', 'fontSize': '10px'}), | |
| html.Span("●" if HOLD else "○", style={'color': '#0F0' if HOLD else '#555', 'fontSize': '10px'}), | |
| html.Span(" • Trace ", style={'color': '#555', 'fontSize': '10px'}), | |
| html.Span("●" if CAUSATION is not None else "○", style={'color': MAGENTA if CAUSATION is not None else '#555', 'fontSize': '10px'}), | |
| ], style={'backgroundColor': '#0d0d12', 'padding': '8px 15px', 'borderRadius': '20px', | |
| 'border': f'1px solid {CYAN}33', 'fontFamily': 'monospace', 'marginRight': '10px'}), | |
| # GAME STATE badge | |
| html.Div([ | |
| html.Span("◈ ", style={'color': GOLD, 'fontSize': '12px'}), | |
| html.Span("GAME ", style={'color': '#666', 'fontSize': '11px'}), | |
| html.Span(id='header-turn', children="White", style={'color': '#FFF', 'fontSize': '11px', 'fontWeight': 'bold'}), | |
| html.Span(" • Move ", style={'color': '#555', 'fontSize': '10px'}), | |
| html.Span(id='header-movenum', children="1", style={'color': GOLD, 'fontSize': '11px'}), | |
| html.Span(" • ", style={'color': '#333'}), | |
| html.Span(id='header-phase', children="Opening", style={'color': '#888', 'fontSize': '10px'}), | |
| ], style={'backgroundColor': '#0d0d12', 'padding': '8px 15px', 'borderRadius': '20px', | |
| 'border': f'1px solid {GOLD}33', 'fontFamily': 'monospace'}), | |
| ], className='badge-row', style={'display': 'flex', 'justifyContent': 'center', 'flexWrap': 'wrap', 'gap': '8px'}) | |
| ], style={'textAlign': 'center', 'padding': '15px 20px', '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}'}), | |
| # Human Override Input | |
| html.Div([ | |
| dcc.Input( | |
| id='human-move-input', | |
| type='text', | |
| placeholder='e2e4', | |
| style={ | |
| 'width': '80px', 'padding': '10px 12px', 'fontSize': '13px', | |
| 'fontFamily': 'monospace', 'backgroundColor': '#1a1a2e', | |
| 'border': f'1px solid #30804055', 'borderRadius': '4px', | |
| 'color': '#308040', 'textAlign': 'center' | |
| } | |
| ), | |
| html.Button("+ ADD", id='btn-add-human-move', n_clicks=0, | |
| style={**BUTTON_BASE, 'color': '#308040', 'border': f'1px solid #308040', | |
| 'padding': '10px 16px', 'marginLeft': '5px'}), | |
| ], className='human-input-group', style={'display': 'inline-flex', 'alignItems': 'center', 'marginLeft': '20px', | |
| 'borderLeft': '1px solid #333', 'paddingLeft': '20px'}), | |
| # Loading spinner | |
| dcc.Loading( | |
| id='loading-indicator', | |
| type='circle', | |
| color=GOLD, | |
| children=html.Div(id='loading-output', style={'display': 'inline-block', 'marginLeft': '15px'}) | |
| ), | |
| ], className='controls-bar', style={'textAlign': 'center', 'padding': '10px', 'display': 'flex', | |
| 'justifyContent': 'center', 'alignItems': 'center', 'gap': '5px', | |
| 'backgroundColor': '#08080c', 'flexWrap': 'wrap'}), | |
| # Status bar | |
| html.Div(id='status', | |
| style={'textAlign': 'center', 'color': '#666', 'padding': '8px', | |
| 'fontFamily': 'monospace', 'fontSize': '12px', 'backgroundColor': '#0a0a0f', | |
| 'borderBottom': '1px solid #1a1a2e'}), | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| # MAIN LAYOUT: Panels flanking central board | |
| # [LEFT PANELS] [BOARD] [RIGHT PANELS] | |
| # ═══════════════════════════════════════════════════════════════════════════ | |
| html.Div([ | |
| # LEFT COLUMN - 2 stacked panels | |
| html.Div([ | |
| # INFORMATIONAL WEALTH Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': '#308040', 'fontSize': '12px'}), | |
| html.Span("INFORMATIONAL WEALTH", style={'color': '#888', 'fontSize': '11px', 'cursor': 'help'}, | |
| title="Deep analysis data from cascade-lattice: features, reasoning, and predictions") | |
| ], style={'fontFamily': 'monospace', 'marginBottom': '8px', | |
| 'borderBottom': f'1px solid #30804033', 'paddingBottom': '6px'}), | |
| html.Div(id='merkle-display', style={'marginBottom': '8px'}), | |
| html.Div(id='wealth-panel', children=[ | |
| html.Div("Click HOLD to inspect", style={'color': '#444', 'fontStyle': 'italic', | |
| 'padding': '15px', 'textAlign': 'center', 'fontSize': '11px'}) | |
| ]) | |
| ], style={**PANEL_STYLE, 'marginBottom': '8px', 'padding': '12px', 'minHeight': '240px'}), | |
| # DECISION TREE Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': GOLD, 'fontSize': '12px'}), | |
| html.Span("DECISION TREE", style={'color': '#888', 'fontSize': '11px', 'cursor': 'help'}, | |
| title="Ranked list of candidate moves with evaluations (cp = centipawns, 100cp ≈ 1 pawn)") | |
| ], style={'fontFamily': 'monospace', 'marginBottom': '8px', | |
| 'borderBottom': f'1px solid {GOLD}33', 'paddingBottom': '6px'}), | |
| html.Div(id='decision-tree', children=[ | |
| html.Div("No candidates yet", style={'color': '#444', 'fontStyle': 'italic', | |
| 'padding': '15px', 'textAlign': 'center', 'fontSize': '11px'}) | |
| ]) | |
| ], style={**PANEL_STYLE, 'padding': '12px', 'minHeight': '240px'}), | |
| ], className='left-panels', style={'flex': '1', 'minWidth': '220px', 'maxWidth': '320px'}), | |
| # CENTER - 3D Chess Board (Three.js iframe) | |
| html.Div([ | |
| html.Iframe(id='chess-3d-iframe', | |
| src='/assets/chess3d.html', | |
| style={'width': '100%', 'height': '480px', 'border': 'none', | |
| 'borderRadius': '8px'}), | |
| # Hidden div for state to send to iframe | |
| html.Div(id='scene-state', style={'display': 'none'}), | |
| # Candidate selection area (when HOLD) | |
| html.Div([ | |
| html.Div(id='move-buttons', style={'textAlign': 'center', 'padding': '5px'}), | |
| html.Div([ | |
| html.Button("✓ COMMIT MOVE", id='btn-commit', | |
| style={'display': 'none', 'margin': '8px auto', 'padding': '10px 25px', | |
| 'fontSize': '13px', 'fontFamily': 'monospace', 'fontWeight': 'bold', | |
| 'borderRadius': '6px', 'cursor': 'pointer', | |
| 'backgroundColor': '#30804022', 'color': '#308040', | |
| 'border': '2px solid #308040'}) | |
| ], style={'textAlign': 'center'}), | |
| html.Div(id='selected-move-info', style={'padding': '5px', 'textAlign': 'center', | |
| 'fontFamily': 'monospace', 'fontSize': '11px', | |
| 'color': '#888'}) | |
| ], style={'padding': '5px'}) | |
| ], className='board-column', style={'flex': '0 0 480px', 'maxWidth': '520px'}), | |
| # RIGHT COLUMN - 2 stacked panels | |
| html.Div([ | |
| # CAUSATION TRACE Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': CYAN, 'fontSize': '12px'}), | |
| html.Span("CAUSATION TRACE", style={'color': '#888', 'fontSize': '11px', 'cursor': 'help'}, | |
| title="Step-by-step trace of the AI's decision-making process with timing data") | |
| ], style={'fontFamily': 'monospace', 'marginBottom': '8px', | |
| 'borderBottom': f'1px solid {CYAN}33', 'paddingBottom': '6px'}), | |
| html.Div(id='cascade-trace', children=[ | |
| html.Div("Waiting for move...", style={'color': '#444', 'fontStyle': 'italic', | |
| 'padding': '15px', 'textAlign': 'center', 'fontSize': '11px'}) | |
| ]) | |
| ], style={**PANEL_STYLE, 'marginBottom': '8px', 'padding': '12px', 'minHeight': '240px'}), | |
| # METRICS Panel | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': MAGENTA, 'fontSize': '12px'}), | |
| html.Span("METRICS", style={'color': '#888', 'fontSize': '11px', 'cursor': 'help'}, | |
| title="Real-time performance metrics for the AI analysis") | |
| ], style={'fontFamily': 'monospace', 'marginBottom': '8px', | |
| 'borderBottom': f'1px solid {MAGENTA}33', 'paddingBottom': '6px'}), | |
| html.Div(id='metrics-panel', children=[ | |
| html.Div([ | |
| html.Span("Latency: ", style={'color': '#555', 'fontSize': '11px', 'cursor': 'help'}, | |
| title="Time taken to analyze the position and generate candidates"), | |
| html.Span("--ms", id='metric-latency', style={'color': CYAN, 'fontSize': '11px'}) | |
| ], style={'marginBottom': '4px'}), | |
| html.Div([ | |
| html.Span("Candidates: ", style={'color': '#555', 'fontSize': '11px', 'cursor': 'help'}, | |
| title="Number of moves being considered by the AI"), | |
| html.Span("0", id='metric-candidates', style={'color': GOLD, 'fontSize': '11px'}) | |
| ], style={'marginBottom': '4px'}), | |
| html.Div([ | |
| html.Span("Confidence: ", style={'color': '#555', 'fontSize': '11px', 'cursor': 'help'}, | |
| title="AI's confidence in the top move (100% = very sure)"), | |
| html.Span("--%", id='metric-confidence', style={'color': MAGENTA, 'fontSize': '11px'}) | |
| ]), | |
| ], style={'fontFamily': 'monospace'}) | |
| ], style={**PANEL_STYLE, 'padding': '12px', 'minHeight': '240px'}), | |
| ], className='right-panels', style={'flex': '1', 'minWidth': '220px', 'maxWidth': '320px'}), | |
| ], className='main-layout', style={'display': 'flex', 'padding': '10px 15px 5px 15px', 'gap': '12px', | |
| 'alignItems': 'flex-start', 'justifyContent': 'center', 'flexWrap': 'wrap'}), | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # CAUSATION GRAPH - Side-by-side: Graph + Event Viewer | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| html.Div([ | |
| # Header row with title and timeline controls | |
| html.Div([ | |
| html.Div([ | |
| html.Span("◈ ", style={'color': '#FF6600', 'fontSize': '16px'}), | |
| html.Span("ADVERSARIAL DECISION GRAPH", style={'color': '#888', 'fontSize': '14px'}), | |
| html.Span(" — ", style={'color': '#333'}), | |
| html.Span(id='causation-stats', style={'color': '#FF6600', 'fontSize': '11px'}) | |
| ], style={'flex': '1'}), | |
| # Timeline controls in header | |
| html.Div([ | |
| html.Button("⏮", id='timeline-first-btn', n_clicks=0, title="First event", | |
| style={'backgroundColor': '#1a1a2e', 'color': '#888', 'border': '1px solid #333', | |
| 'padding': '4px 8px', 'cursor': 'pointer', 'borderRadius': '3px', 'fontSize': '12px'}), | |
| html.Button("◀", id='timeline-prev-btn', n_clicks=0, title="Previous event", | |
| style={'backgroundColor': '#1a1a2e', 'color': '#4080A0', 'border': '1px solid #4080A044', | |
| 'padding': '4px 10px', 'margin': '0 5px', 'cursor': 'pointer', 'borderRadius': '3px', 'fontSize': '12px'}), | |
| html.Span(id='timeline-position', children="0 / 0", | |
| style={'color': '#FF6600', 'fontSize': '12px', 'fontWeight': 'bold', 'minWidth': '60px', 'textAlign': 'center'}), | |
| html.Button("▶", id='timeline-next-btn', n_clicks=0, title="Next event", | |
| style={'backgroundColor': '#1a1a2e', 'color': '#4080A0', 'border': '1px solid #4080A044', | |
| 'padding': '4px 10px', 'margin': '0 5px', 'cursor': 'pointer', 'borderRadius': '3px', 'fontSize': '12px'}), | |
| html.Button("⏭", id='timeline-last-btn', n_clicks=0, title="Last event", | |
| style={'backgroundColor': '#1a1a2e', 'color': '#888', 'border': '1px solid #333', | |
| 'padding': '4px 8px', 'cursor': 'pointer', 'borderRadius': '3px', 'fontSize': '12px'}), | |
| # CINEMATIC REPLAY BUTTON | |
| html.Span("", style={'width': '15px'}), # Spacer | |
| html.Button("🎬 REPLAY", id='cinematic-replay-btn', n_clicks=0, title="Cinematic replay of the game", | |
| style={'backgroundColor': '#FF660022', 'color': '#FF6600', 'border': '1px solid #FF6600', | |
| 'padding': '4px 12px', 'cursor': 'pointer', 'borderRadius': '3px', 'fontSize': '11px', | |
| 'fontWeight': 'bold', 'marginLeft': '10px'}), | |
| ], className='timeline-controls', style={'display': 'flex', 'alignItems': 'center'}), | |
| ], className='timeline-header', style={'display': 'flex', 'justifyContent': 'space-between', 'alignItems': 'center', | |
| 'fontFamily': 'monospace', 'marginBottom': '8px', 'borderBottom': '1px solid #FF660033', 'paddingBottom': '8px', 'flexWrap': 'wrap', 'gap': '8px'}), | |
| # Side-by-side: Graph (left) + Event Viewer (right) | |
| html.Div([ | |
| # LEFT: Cytoscape graph with adversarial layout | |
| html.Div([ | |
| # Lane labels | |
| html.Div([ | |
| html.Span("⚪ WHITE", style={'color': '#4080A0', 'fontSize': '10px', 'fontFamily': 'monospace'}), | |
| ], style={'position': 'absolute', 'top': '5px', 'left': '10px', 'zIndex': '10'}), | |
| html.Div([ | |
| html.Span("⚫ BLACK", style={'color': '#904060', 'fontSize': '10px', 'fontFamily': 'monospace'}), | |
| ], style={'position': 'absolute', 'bottom': '5px', 'left': '10px', 'zIndex': '10'}), | |
| cyto.Cytoscape( | |
| id='causation-cytoscape', | |
| elements=[], | |
| style={'width': '100%', 'height': '300px', 'backgroundColor': '#030306'}, | |
| layout={ | |
| 'name': 'preset', # We'll set positions manually for adversarial layout | |
| 'animate': True, | |
| 'animationDuration': 200 | |
| }, | |
| stylesheet=[ | |
| # Base node style | |
| { | |
| 'selector': 'node', | |
| 'style': { | |
| 'label': 'data(label)', | |
| 'text-valign': 'center', | |
| 'text-halign': 'center', | |
| 'font-size': '9px', | |
| 'font-family': 'monospace', | |
| 'color': '#ffffff', | |
| 'text-outline-color': '#000', | |
| 'text-outline-width': 1, | |
| 'text-wrap': 'wrap', | |
| 'text-max-width': '55px', | |
| 'width': 50, | |
| 'height': 45 | |
| } | |
| }, | |
| # DECISION POINT - White (top lane) | |
| { | |
| 'selector': '.white-decision', | |
| 'style': { | |
| 'shape': 'round-rectangle', | |
| 'background-color': '#001a2e', | |
| 'border-color': '#4080A0', | |
| 'border-width': 2, | |
| 'width': 55, | |
| 'height': 45 | |
| } | |
| }, | |
| # DECISION POINT - Black (bottom lane) | |
| { | |
| 'selector': '.black-decision', | |
| 'style': { | |
| 'shape': 'round-rectangle', | |
| 'background-color': '#1a0018', | |
| 'border-color': '#904060', | |
| 'border-width': 2, | |
| 'width': 55, | |
| 'height': 45 | |
| } | |
| }, | |
| # Chosen move highlight | |
| { | |
| 'selector': '.chosen-move', | |
| 'style': { | |
| 'border-color': '#308040', | |
| 'border-width': 3 | |
| } | |
| }, | |
| # Current/selected node | |
| { | |
| 'selector': '.current-event', | |
| 'style': { | |
| 'border-color': '#D4A020', | |
| 'border-width': 4, | |
| 'background-color': '#2a2a00', | |
| 'width': 65, | |
| 'height': 55, | |
| 'font-size': '11px', | |
| 'font-weight': 'bold' | |
| } | |
| }, | |
| # Candidate nodes (smaller, branching off) | |
| { | |
| 'selector': '.candidate-node', | |
| 'style': { | |
| 'shape': 'ellipse', | |
| 'width': 35, | |
| 'height': 30, | |
| 'font-size': '8px', | |
| 'opacity': 0.8, | |
| 'background-color': '#0a0a12', | |
| 'border-color': '#444466', | |
| 'border-width': 1 | |
| } | |
| }, | |
| { | |
| 'selector': '.candidate-top3', | |
| 'style': { | |
| 'border-color': '#FF8800', | |
| 'opacity': 0.9 | |
| } | |
| }, | |
| # Timeline edges (main flow between decisions) | |
| { | |
| 'selector': '.flow-edge', | |
| 'style': { | |
| 'width': 3, | |
| 'line-color': '#FF6600', | |
| 'target-arrow-color': '#FF6600', | |
| 'target-arrow-shape': 'triangle', | |
| 'curve-style': 'bezier', | |
| 'arrow-scale': 1.2 | |
| } | |
| }, | |
| # Cross-lane edges (white to black transition) | |
| { | |
| 'selector': '.cross-edge', | |
| 'style': { | |
| 'width': 2, | |
| 'line-color': '#666666', | |
| 'line-style': 'dashed', | |
| 'target-arrow-shape': 'triangle', | |
| 'target-arrow-color': '#666666', | |
| 'curve-style': 'unbundled-bezier', | |
| 'control-point-distances': [40], | |
| 'control-point-weights': [0.5] | |
| } | |
| }, | |
| # Candidate edges | |
| { | |
| 'selector': '.candidate-edge', | |
| 'style': { | |
| 'width': 1, | |
| 'line-color': '#333344', | |
| 'target-arrow-shape': 'none', | |
| 'curve-style': 'bezier', | |
| 'opacity': 0.5 | |
| } | |
| }, | |
| # Selected state | |
| { | |
| 'selector': ':selected', | |
| 'style': { | |
| 'border-width': 4, | |
| 'border-color': '#D4A020' | |
| } | |
| } | |
| ], | |
| responsive=True, | |
| zoomingEnabled=True, | |
| panningEnabled=True, | |
| boxSelectionEnabled=False | |
| ), | |
| # Timeline slider below graph | |
| html.Div([ | |
| dcc.Slider( | |
| id='timeline-slider', | |
| min=0, max=1, step=1, value=0, | |
| marks={}, | |
| tooltip={'placement': 'bottom', 'always_visible': False}, | |
| updatemode='drag' | |
| ), | |
| ], style={'padding': '5px 10px', 'backgroundColor': '#050508'}) | |
| ], className='graph-container', style={'flex': '1', 'position': 'relative', 'backgroundColor': '#030306', 'borderRadius': '6px', | |
| 'border': '1px solid #1a1a2e', 'minWidth': '280px'}), | |
| # Hidden div to keep timeline-event-metadata output (required by callbacks but not displayed) | |
| html.Div(id='timeline-event-metadata', style={'display': 'none'}) | |
| ], className='causation-content', style={'display': 'flex', 'alignItems': 'stretch', 'flexWrap': 'wrap', 'gap': '10px'}), | |
| # Hidden node info (for click sync) | |
| html.Div(id='causation-node-info', style={'display': 'none'}) | |
| ], className='causation-section', style={**PANEL_STYLE, 'margin': '0 15px 15px 15px', 'padding': '12px'}), | |
| # Timeline state store | |
| dcc.Store(id='timeline-index', data=0), | |
| dcc.Store(id='timeline-events-cache', data=[]), | |
| dcc.Store(id='graph-node-map', data={}), # Maps event index to node ID for sync | |
| dcc.Store(id='selected-graph-node', data=None), # Currently selected node in graph (for highlighting) | |
| # 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='hold-data-store', data={}), # Rich cascade hold data | |
| dcc.Store(id='is-held', data=False), | |
| dcc.Store(id='auto-play', data=False), | |
| dcc.Store(id='move-history', data=[]), | |
| dcc.Store(id='selected-candidate', data=0), # Index of selected candidate during HOLD | |
| dcc.Store(id='hold-refresh-trigger', data=0), # Increments to force panel refresh | |
| dcc.Store(id='human-move-candidate', data=None), # Human override move to add to candidates | |
| # BLACK THINKING phase - shows candidate arrows before black moves | |
| dcc.Store(id='black-thinking', data=False), | |
| dcc.Store(id='black-candidates', data=[]), # Candidates shown during thinking | |
| dcc.Store(id='black-chosen-move', data=None), # Move to execute after delay | |
| # AUTO VISUAL mode - two-phase system: show candidates, then commit | |
| dcc.Store(id='auto-thinking', data=False), # True = showing candidates, False = ready to commit | |
| dcc.Store(id='auto-pending-move', data=None), # Move to commit after showing candidates | |
| # Auto-play interval - faster for two-phase system | |
| dcc.Interval(id='auto-interval', interval=800, disabled=True), | |
| dcc.Interval(id='auto-visual-interval', interval=1500, disabled=True), # Delay to show candidates | |
| # Black thinking delay interval (1.5 seconds) | |
| dcc.Interval(id='black-delay-interval', interval=1500, disabled=True), | |
| # CINEMATIC REPLAY system | |
| dcc.Store(id='cinematic-active', data=False), # Is cinematic replay running | |
| dcc.Store(id='cinematic-index', data=0), # Current position in replay | |
| dcc.Store(id='cinematic-events', data=[]), # Events to replay | |
| dcc.Store(id='cinematic-camera-cmd', data=None), # Camera command to send to Three.js | |
| dcc.Interval(id='cinematic-interval', interval=2000, disabled=True), # 2s between moves for cinematic effect | |
| # Page load detection - resets state on browser refresh | |
| dcc.Location(id='url', refresh=False), | |
| dcc.Store(id='page-initialized', data=False, storage_type='session'), | |
| ], style={'backgroundColor': BG_COLOR, 'minHeight': '100vh', 'padding': '0'}) | |
| # Reset CAUSATION on page load/refresh | |
| def reset_on_page_load(pathname, already_initialized): | |
| """Reset CausationGraph on every page load/refresh.""" | |
| global CAUSATION, LAST_RESOLUTION, RECORDED_POSITIONS | |
| try: | |
| from cascade import CausationGraph | |
| CAUSATION = CausationGraph() | |
| LAST_RESOLUTION = None | |
| RECORDED_POSITIONS = set() # Clear recorded positions | |
| print("[PAGE LOAD] CausationGraph cleared") | |
| except Exception as e: | |
| print(f"[PAGE LOAD] CausationGraph reset failed: {e}") | |
| return True | |
| def handle_controls(step, hold, reset, auto, interval, visual_interval, fen, candidates, is_held, history, auto_play, selected_idx, refresh_trigger, auto_thinking, pending_move): | |
| global CAUSATION, LAST_RESOLUTION, RECORDED_POSITIONS | |
| ctx = dash.callback_context | |
| if not ctx.triggered: | |
| return fen, candidates, [], [], {}, is_held, history, auto_play, False, [], None, selected_idx, refresh_trigger, False, None | |
| trigger = ctx.triggered[0]['prop_id'].split('.')[0] | |
| board = chess.Board(fen) | |
| if trigger == 'btn-reset': | |
| # Reset the causation graph to clear stale data | |
| try: | |
| from cascade import CausationGraph | |
| CAUSATION = CausationGraph() | |
| LAST_RESOLUTION = None | |
| RECORDED_POSITIONS = set() # Clear recorded positions | |
| print("[RESET] CausationGraph cleared") | |
| except Exception as e: | |
| print(f"[RESET] CausationGraph reset failed: {e}") | |
| return chess.STARTING_FEN, [], [], [], {}, False, [], False, False, [], None, 0, 0, False, None | |
| if trigger == 'btn-auto': | |
| # Toggle auto-play, but don't interfere with hold state - preserve stores | |
| new_auto = not auto_play | |
| if new_auto: | |
| # Starting auto - generate initial candidates to show | |
| cands, trace, decision, hold_data = get_candidates_with_trace(board) | |
| return fen, [c.__dict__ for c in cands], trace, decision, hold_data, is_held, history, True, False, [], None, selected_idx, (refresh_trigger or 0) + 1, True, cands[0].move if cands else None | |
| else: | |
| # Stopping auto - clear visual state | |
| return fen, [], dash.no_update, dash.no_update, dash.no_update, is_held, history, False, False, [], None, selected_idx, refresh_trigger, False, None | |
| if trigger == 'btn-hold': | |
| if not is_held: | |
| # Enter hold mode - stop auto-play immediately, reset selection to 0 | |
| # INCREMENT refresh_trigger to force all panel callbacks to re-run | |
| cands, trace, decision, hold_data = get_candidates_with_trace(board) | |
| new_refresh = (refresh_trigger or 0) + 1 | |
| print(f"[HOLD] Engaged - refresh_trigger={new_refresh}, trace={len(trace)}, decision={len(decision)}, hold_data keys={list(hold_data.keys())}") | |
| return fen, [c.__dict__ for c in cands], trace, decision, hold_data, True, history, False, False, [], None, 0, new_refresh, False, None | |
| else: | |
| # Exit hold mode | |
| return fen, [], [], [], {}, False, history, auto_play, False, [], None, 0, refresh_trigger, False, None | |
| if trigger == 'auto-visual-interval': | |
| # Phase 2 of visual auto: Commit the pending move after showing candidates | |
| if auto_play and auto_thinking and pending_move and not is_held: | |
| move = chess.Move.from_uci(pending_move) | |
| board.push(move) | |
| history = history + [pending_move] | |
| print(f"[AUTO-VISUAL] Committed move: {pending_move}") | |
| if board.is_game_over(): | |
| return board.fen(), [], [], [], {}, False, history, False, False, [], None, 0, refresh_trigger, False, None | |
| # Clear candidates and prepare for next thinking phase | |
| return board.fen(), [], dash.no_update, dash.no_update, dash.no_update, False, history, auto_play, False, [], None, 0, refresh_trigger, False, None | |
| return fen, candidates, dash.no_update, dash.no_update, dash.no_update, is_held, history, auto_play, False, [], None, selected_idx, refresh_trigger, auto_thinking, pending_move | |
| if trigger in ['btn-step', 'auto-interval']: | |
| if is_held: | |
| # STEP in HOLD mode = commit the selected move and stay in HOLD for next position | |
| if trigger == 'btn-step' and candidates: | |
| # Execute the selected candidate move | |
| move_uci = candidates[selected_idx]['move'] if selected_idx < len(candidates) else candidates[0]['move'] | |
| move = chess.Move.from_uci(move_uci) | |
| board.push(move) | |
| history = history + [move_uci] | |
| print(f"[STEP-HOLD] Committed move: {move_uci}") | |
| # Check if game is over | |
| if board.is_game_over(): | |
| return board.fen(), [], [], [], {}, False, history, False, False, [], None, 0, refresh_trigger, False, None | |
| # Generate candidates for next position - stay in HOLD mode | |
| next_cands, next_trace, next_decision, next_hold_data = get_candidates_with_trace(board) | |
| new_refresh = (refresh_trigger or 0) + 1 | |
| print(f"[STEP-HOLD] Generated {len(next_cands)} candidates for next position") | |
| return board.fen(), [c.__dict__ for c in next_cands], next_trace, next_decision, next_hold_data, True, history, False, False, [], None, 0, new_refresh, False, None | |
| else: | |
| # auto-interval in HOLD mode - just preserve state, don't do anything | |
| return fen, candidates, dash.no_update, dash.no_update, dash.no_update, is_held, history, False, False, [], None, selected_idx, refresh_trigger, False, None | |
| if board.is_game_over(): | |
| return fen, [], [], [], {}, False, history, False, False, [], None, 0, refresh_trigger, False, None | |
| # AUTO MODE with Visual - Phase 1: Generate and show candidates | |
| if trigger == 'auto-interval' and auto_play and not auto_thinking: | |
| cands, trace, decision, hold_data = get_candidates_with_trace(board) | |
| if cands: | |
| # Show candidates (triggers arrow visualization), set pending move | |
| print(f"[AUTO-VISUAL] Thinking phase - showing {len(cands)} candidates") | |
| return fen, [c.__dict__ for c in cands], trace, decision, hold_data, False, history, auto_play, False, [], None, 0, (refresh_trigger or 0) + 1, True, cands[0].move | |
| else: | |
| # No candidates = game over | |
| return fen, [], [], [], {}, False, history, False, False, [], None, 0, refresh_trigger, False, None | |
| # STEP in manual mode - make one move immediately | |
| if trigger == 'btn-step': | |
| cands, trace, decision, hold_data = get_candidates_with_trace(board) | |
| if cands: | |
| move = chess.Move.from_uci(cands[0].move) | |
| board.push(move) | |
| history = history + [cands[0].move] | |
| new_fen = board.fen() | |
| return new_fen, [], trace, decision, {}, False, history, auto_play, False, [], None, 0, refresh_trigger, False, None | |
| return fen, candidates, dash.no_update, dash.no_update, dash.no_update, is_held, history, auto_play, False, [], None, selected_idx, refresh_trigger, auto_thinking, pending_move | |
| # Callback to handle human move override - add player's move to candidates | |
| def handle_human_move(n_clicks, move_input, fen): | |
| """Validate and add a human-specified move to the candidates.""" | |
| base_style = { | |
| 'width': '90px', 'padding': '10px 12px', 'fontSize': '13px', | |
| 'fontFamily': 'monospace', 'backgroundColor': '#1a1a2e', | |
| 'borderRadius': '4px', 'textAlign': 'center' | |
| } | |
| if not move_input or not move_input.strip(): | |
| return None, '', {**base_style, 'border': f'1px solid #30804055', 'color': '#308040'} | |
| board = chess.Board(fen) | |
| move_str = move_input.strip() | |
| # Try to parse the move in various formats | |
| move = None | |
| try: | |
| # Try UCI format first (e2e4) | |
| move = chess.Move.from_uci(move_str.lower()) | |
| except: | |
| try: | |
| # Try SAN format (Nf3, e4, etc) | |
| move = board.parse_san(move_str) | |
| except: | |
| pass | |
| if move is None or move not in board.legal_moves: | |
| # Invalid move - flash red | |
| print(f"[HUMAN] Invalid move: {move_str}") | |
| return None, move_str, {**base_style, 'border': '2px solid #FF4444', 'color': '#FF4444'} | |
| # Valid move - analyze it | |
| print(f"[HUMAN] Valid move: {move.uci()}") | |
| # Get engine evaluation for this specific move | |
| cp_score = 0 | |
| with ENGINE_LOCK: | |
| if ENGINE: | |
| try: | |
| # Make the move and evaluate | |
| board.push(move) | |
| info = ENGINE.analyse(board, chess.engine.Limit(depth=10)) | |
| score = info.get('score', chess.engine.Cp(0)) | |
| if score.is_mate(): | |
| cp_score = 10000 if score.relative.mate() > 0 else -10000 | |
| else: | |
| cp_score = -score.relative.score(mate_score=10000) # Negate because we made the move | |
| board.pop() | |
| except Exception as e: | |
| print(f"[HUMAN] Analysis error: {e}") | |
| # Create a candidate entry for this move | |
| human_candidate = { | |
| 'move': move.uci(), | |
| 'from_sq': move.from_square, | |
| 'to_sq': move.to_square, | |
| 'cp': cp_score, | |
| 'prob': 0.15, # Give it a visible but modest probability | |
| 'is_capture': board.is_capture(move), | |
| 'is_human': True, # Mark as human override | |
| 'eval_str': f"{cp_score/100:+.2f}" if abs(cp_score) < 9000 else ("M+" if cp_score > 0 else "M-") | |
| } | |
| return human_candidate, '', {**base_style, 'border': '2px solid #308040', 'color': '#308040'} | |
| # Callback to merge human candidate into candidates store when HOLD is active | |
| def merge_human_candidate(human_candidate, candidates, is_held): | |
| """Merge human candidate into the existing candidates list.""" | |
| if not human_candidate: | |
| raise dash.exceptions.PreventUpdate | |
| if not is_held: | |
| # If not in HOLD mode, we can't add to candidates visualization | |
| # But we'll still store it for when HOLD is activated | |
| raise dash.exceptions.PreventUpdate | |
| # Remove any existing human candidate | |
| filtered = [c for c in (candidates or []) if not c.get('is_human', False)] | |
| # Add the new human candidate - insert it so it's visible but not first | |
| # Put it after top 2 candidates so user can see their option alongside AI's best | |
| if len(filtered) >= 2: | |
| filtered.insert(2, human_candidate) | |
| else: | |
| filtered.append(human_candidate) | |
| # Renormalize probabilities to include human move | |
| total = sum(c['prob'] for c in filtered) | |
| if total > 0: | |
| for c in filtered: | |
| c['prob'] = c['prob'] / total | |
| print(f"[HUMAN] Added human candidate {human_candidate['move']} to {len(filtered)} candidates") | |
| return filtered | |
| def update_scene_state(fen, candidates_data, selected_idx): | |
| """Package state as JSON for the Three.js iframe to consume.""" | |
| candidates = [] | |
| # Regular candidates (white) from hold mode | |
| if candidates_data: | |
| for i, c in enumerate(candidates_data): | |
| candidates.append({ | |
| 'from_sq': c['from_sq'], | |
| 'to_sq': c['to_sq'], | |
| 'prob': c['prob'], | |
| 'is_capture': c.get('is_capture', False), | |
| 'is_black': False, | |
| 'is_human': c.get('is_human', False), | |
| 'is_selected': (i == selected_idx) # Mark the selected candidate | |
| }) | |
| state = json.dumps({'type': 'update', 'fen': fen, 'candidates': candidates, 'selectedIndex': selected_idx}) | |
| return state, "" | |
| # Clientside callback to post message to iframe | |
| app.clientside_callback( | |
| """ | |
| function(state) { | |
| if (!state) return window.dash_clientside.no_update; | |
| try { | |
| const iframe = document.getElementById('chess-3d-iframe'); | |
| if (iframe && iframe.contentWindow) { | |
| iframe.contentWindow.postMessage(JSON.parse(state), '*'); | |
| } | |
| } catch(e) { console.log('postMessage error:', e); } | |
| return window.dash_clientside.no_update; | |
| } | |
| """, | |
| Output('scene-state', 'style'), | |
| Input('scene-state', 'children'), | |
| prevent_initial_call=True | |
| ) | |
| # Clientside callback to send CINEMATIC CAMERA commands to iframe | |
| app.clientside_callback( | |
| """ | |
| function(cameraCmd) { | |
| if (!cameraCmd) return window.dash_clientside.no_update; | |
| try { | |
| const iframe = document.getElementById('chess-3d-iframe'); | |
| if (iframe && iframe.contentWindow) { | |
| iframe.contentWindow.postMessage(cameraCmd, '*'); | |
| console.log('[CINEMATIC] Camera command sent:', cameraCmd.type); | |
| } | |
| } catch(e) { console.log('Camera postMessage error:', e); } | |
| return window.dash_clientside.no_update; | |
| } | |
| """, | |
| Output('cinematic-camera-cmd', 'style'), | |
| Input('cinematic-camera-cmd', 'data'), | |
| prevent_initial_call=True | |
| ) | |
| def update_hold_button(is_held, cinematic_active): | |
| # During cinematic replay, show special state | |
| if cinematic_active: | |
| return "🎬 REPLAYING", {**BUTTON_BASE, 'color': '#000', 'backgroundColor': '#FF6600', | |
| 'border': '2px solid #FF6600', 'fontWeight': 'bold'} | |
| elif 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, auto_thinking, history, cinematic_active, cinematic_idx, cinematic_events): | |
| board = chess.Board(fen) | |
| if board.is_game_over(): | |
| result = board.result() | |
| return f"GAME OVER: {result}" | |
| turn = "WHITE" if board.turn else "BLACK" | |
| # CINEMATIC MODE takes priority | |
| if cinematic_active and cinematic_events: | |
| total_frames = len(cinematic_events) | |
| frame_num = min(cinematic_idx + 1, total_frames) | |
| return f"🎬 REPLAY {frame_num}/{total_frames} | {turn} | ▶▶ PLAYING..." | |
| if is_held: | |
| mode = "◉ HOLD ACTIVE - Select a move" | |
| elif auto_play and auto_thinking: | |
| mode = "▶▶ AUTO 🤔 Thinking..." | |
| elif auto_play: | |
| mode = "▶▶ AUTO" | |
| else: | |
| mode = "MANUAL" | |
| return f"Move {board.fullmove_number} | {turn} | {mode}" | |
| def toggle_auto(auto_play, auto_thinking): | |
| # Disable auto-interval when not in auto mode OR when in thinking phase (waiting to commit) | |
| return (not auto_play) or auto_thinking | |
| def toggle_visual_interval(auto_thinking): | |
| # Enable visual interval only during thinking phase (to trigger commit after delay) | |
| return not auto_thinking | |
| def update_game_state(fen): | |
| board = chess.Board(fen) | |
| # Turn | |
| turn_text = "White" if board.turn else "Black" | |
| turn_style = {'color': '#FFF', 'fontSize': '11px', 'fontWeight': 'bold'} if board.turn else {'color': '#888', 'fontSize': '11px', 'fontWeight': 'bold'} | |
| # Move number | |
| move_num = str(board.fullmove_number) | |
| # Game phase (rough estimate) | |
| total_pieces = len(board.piece_map()) | |
| if total_pieces >= 28: | |
| phase = "Opening" | |
| phase_style = {'color': '#888', 'fontSize': '10px'} | |
| elif total_pieces >= 14: | |
| phase = "Middlegame" | |
| phase_style = {'color': '#888', 'fontSize': '10px'} | |
| else: | |
| phase = "Endgame" | |
| phase_style = {'color': '#FF8800', 'fontSize': '10px'} | |
| if board.is_check(): | |
| phase = "⚠ CHECK" | |
| phase_style = {'color': '#FF4444', 'fontSize': '10px', 'fontWeight': 'bold'} | |
| if board.is_game_over(): | |
| phase = "Game Over" | |
| phase_style = {'color': '#308040', 'fontSize': '10px', 'fontWeight': 'bold'} | |
| return turn_text, turn_style, move_num, phase, phase_style | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # CASCADE PANEL CALLBACKS | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def render_trace(_refresh, trace_data, selected_idx): | |
| print(f"[TRACE] trace_data={len(trace_data) if trace_data else 'None'}, refresh={_refresh}") | |
| if not trace_data: | |
| return html.Div("Waiting for move...", | |
| style={'color': '#444', 'fontStyle': 'italic', 'padding': '20px', 'textAlign': 'center'}) | |
| # Operation descriptions for tooltips | |
| op_descriptions = { | |
| 'ENCODE': 'Converting board state into neural-friendly tensor representation', | |
| 'ANALYZE': 'Evaluating position using Stockfish and heuristics', | |
| 'SCORE': 'Computing move probabilities via cascade-lattice scoring', | |
| 'HOLD': 'Pausing for human inspection (HOLD mode)', | |
| 'SELECT': 'AI selecting the best move from candidates', | |
| 'YIELD': 'Returning the chosen move to the game' | |
| } | |
| rows = [] | |
| for t in trace_data: | |
| # Color code by operation type | |
| op_colors = {'ENCODE': CYAN, 'ANALYZE': '#888', 'SCORE': GOLD, 'HOLD': MAGENTA, 'SELECT': '#0F0', 'YIELD': '#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'}, | |
| title=f"Processing step {t['step']} in the analysis pipeline"), | |
| # 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', | |
| 'cursor': 'help' | |
| }, title=op_descriptions.get(t['op'], 'Unknown operation')), | |
| # Detail | |
| html.Span(t['detail'], style={'color': '#888', 'flex': '1', 'fontSize': '11px'}, | |
| title=f"Details: {t['detail']}"), | |
| # Duration | |
| html.Span(f"{t['duration']}ms", style={'color': '#555', 'width': '60px', 'textAlign': 'right'}, | |
| title=f"This step took {t['duration']}ms to execute"), | |
| ], 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'}, | |
| title=f"Total analysis time: {total:.1f}ms") | |
| ], style={**TRACE_ROW_STYLE, 'borderBottom': 'none', 'backgroundColor': '#0a0a0f'})) | |
| return rows | |
| def render_decision_tree(_refresh, decision_data, selected_idx): | |
| print(f"[DECISION] decision_data={len(decision_data) if decision_data else 'None'}, refresh={_refresh}") | |
| if not decision_data: | |
| return html.Div("No candidates yet", | |
| style={'color': '#444', 'fontStyle': 'italic', 'padding': '20px', 'textAlign': 'center'}) | |
| rows = [] | |
| for i, d in enumerate(decision_data): | |
| # Use selected_idx from store, not the stale d['selected'] | |
| is_selected = (i == selected_idx) | |
| bg_color = f'{CYAN}15' if is_selected else 'transparent' | |
| border_left = f'3px solid {CYAN}' if is_selected else '3px solid transparent' | |
| # Use prob as visual scale (0-1), cp for display | |
| visual_pct = d['prob'] * 100 | |
| cp_score = d.get('cp', 0) | |
| # Create descriptive tooltip | |
| move_tooltip = f"Move {d['move']}: " | |
| if cp_score > 100: | |
| move_tooltip += f"Strong advantage (+{cp_score} centipawns)" | |
| elif cp_score > 0: | |
| move_tooltip += f"Slight advantage (+{cp_score} centipawns)" | |
| elif cp_score < -100: | |
| move_tooltip += f"Significant disadvantage ({cp_score} centipawns)" | |
| elif cp_score < 0: | |
| move_tooltip += f"Slight disadvantage ({cp_score} centipawns)" | |
| else: | |
| move_tooltip += "Equal position" | |
| if d.get('capture'): | |
| move_tooltip += " | Captures a piece" | |
| if d.get('check'): | |
| move_tooltip += " | Gives check" | |
| 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' | |
| }, title=f"AI ranks this as move #{d['rank']} out of {len(decision_data)} candidates"), | |
| # Move | |
| html.Span(d['move'], style={ | |
| 'color': '#FFF' if is_selected else '#888', | |
| 'fontWeight': 'bold', 'width': '55px', 'fontFamily': 'monospace' | |
| }, title=move_tooltip), | |
| # Eval (centipawn) | |
| html.Span(f"{cp_score:+d}cp", style={ | |
| 'color': GOLD if cp_score > 100 else ('#0F0' if cp_score >= 0 else CRIMSON), | |
| 'width': '65px', 'textAlign': 'right' | |
| }, title=f"Centipawn evaluation: {cp_score:+d} (100cp ≈ 1 pawn advantage)"), | |
| # Visual bar (scaled to best move) | |
| html.Div([ | |
| html.Div(style={ | |
| 'width': f'{visual_pct}%', 'height': '8px', | |
| 'backgroundColor': CYAN if is_selected else '#333', | |
| 'borderRadius': '2px' | |
| }) | |
| ], style={'flex': '1', 'backgroundColor': '#1a1a2e', 'borderRadius': '2px', 'marginLeft': '10px'}, | |
| title=f"Relative strength: {visual_pct:.1f}% of best move"), | |
| # Flags | |
| html.Span( | |
| ("⚔" if d.get('capture') else "") + ("♚" if d.get('check') else ""), | |
| style={'color': CRIMSON, 'width': '25px', 'textAlign': 'right', 'marginLeft': '8px'}, | |
| title=("Capture move" if d.get('capture') else "") + | |
| (" | " if d.get('capture') and d.get('check') else "") + | |
| ("Check!" if d.get('check') else "") if (d.get('capture') or d.get('check')) else "" | |
| ) | |
| ], style={ | |
| 'display': 'flex', 'alignItems': 'center', 'padding': '8px 10px', | |
| 'backgroundColor': bg_color, 'borderLeft': border_left, | |
| 'marginBottom': '4px', 'borderRadius': '3px', | |
| 'fontFamily': 'monospace', 'fontSize': '12px', | |
| 'cursor': 'pointer' | |
| }, title=f"Click to select move {d['move']} ({cp_score:+d}cp)") | |
| 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, selected_idx): | |
| if not is_held or not candidates_data: | |
| return [] | |
| buttons = [] | |
| for i, c in enumerate(candidates_data): | |
| cp_score = c.get('value', 0) # Raw centipawn stored in value | |
| is_selected = i == selected_idx | |
| is_human = c.get('is_human', False) | |
| # Human moves get bright green style | |
| if is_human: | |
| if is_selected: | |
| btn_style = { | |
| 'margin': '5px', 'padding': '10px 20px', 'fontSize': '13px', | |
| 'fontFamily': 'monospace', 'borderRadius': '4px', 'cursor': 'pointer', | |
| 'backgroundColor': '#308040', | |
| 'color': '#000', | |
| 'border': '3px solid #308040', | |
| 'fontWeight': 'bold', | |
| 'boxShadow': '0 0 15px #308040' | |
| } | |
| else: | |
| btn_style = { | |
| 'margin': '5px', 'padding': '10px 20px', 'fontSize': '13px', | |
| 'fontFamily': 'monospace', 'borderRadius': '4px', 'cursor': 'pointer', | |
| 'backgroundColor': '#30804033', | |
| 'color': '#308040', | |
| 'border': '2px dashed #308040', | |
| 'fontWeight': 'bold' | |
| } | |
| move_label = f"👤 {c['move']}" | |
| # AI moves | |
| elif is_selected: | |
| btn_style = { | |
| 'margin': '5px', 'padding': '10px 20px', 'fontSize': '13px', | |
| 'fontFamily': 'monospace', 'borderRadius': '4px', 'cursor': 'pointer', | |
| 'backgroundColor': '#30804033', | |
| 'color': '#308040', | |
| 'border': '2px solid #308040', | |
| 'fontWeight': 'bold', | |
| 'boxShadow': '0 0 10px #30804066' | |
| } | |
| move_label = f"{c['move']} ({cp_score:+d}cp)" | |
| else: | |
| btn_style = { | |
| 'margin': '5px', 'padding': '10px 20px', 'fontSize': '13px', | |
| 'fontFamily': 'monospace', 'borderRadius': '4px', 'cursor': 'pointer', | |
| 'backgroundColor': '#1a1a2e', | |
| 'color': '#888', | |
| 'border': '1px solid #333' | |
| } | |
| move_label = f"{c['move']} ({cp_score:+d}cp)" | |
| btn = html.Button( | |
| move_label, | |
| id={'type': 'move-btn', 'index': i}, | |
| style=btn_style | |
| ) | |
| buttons.append(btn) | |
| return buttons | |
| # Move selection callback - just selects, doesn't execute | |
| def select_candidate(clicks): | |
| ctx = dash.callback_context | |
| if not ctx.triggered: | |
| raise dash.exceptions.PreventUpdate | |
| if not clicks or not any(c for c in clicks if c): | |
| raise dash.exceptions.PreventUpdate | |
| triggered_id = ctx.triggered[0]['prop_id'] | |
| if triggered_id == '.': | |
| raise dash.exceptions.PreventUpdate | |
| import json as json_mod | |
| try: | |
| idx = json_mod.loads(triggered_id.split('.')[0])['index'] | |
| return idx | |
| except: | |
| raise dash.exceptions.PreventUpdate | |
| # COMMIT callback - executes the selected move AND stays in HOLD mode for opponent | |
| def commit_move(n_clicks, fen, candidates_data, selected_idx, history, hold_data, refresh_trigger): | |
| global ENGINE, LAST_RESOLUTION | |
| print(f"[COMMIT] Called: n_clicks={n_clicks}, selected_idx={selected_idx}, candidates={len(candidates_data) if candidates_data else 0}") | |
| if not n_clicks or not candidates_data: | |
| raise dash.exceptions.PreventUpdate | |
| board = chess.Board(fen) | |
| print(f"[COMMIT] Board before: {board.fen()}") | |
| if selected_idx < len(candidates_data): | |
| # ═══════════════════════════════════════════════════════════════ | |
| # CASCADE-LATTICE: Track the decision resolution | |
| # ═══════════════════════════════════════════════════════════════ | |
| was_override = (selected_idx != 0) | |
| merkle = hold_data.get('merkle') if hold_data else None | |
| if HOLD and CASCADE_AVAILABLE: | |
| try: | |
| # Track resolution with merkle from the hold point | |
| LAST_RESOLUTION = { | |
| 'action': selected_idx, | |
| 'was_override': was_override, | |
| 'source': 'human' if was_override else 'accept', | |
| 'merkle': merkle, | |
| 'duration': 0 | |
| } | |
| if was_override: | |
| print(f"[CASCADE] Decision: OVERRIDE action={selected_idx} (human chose differently from AI)") | |
| else: | |
| print(f"[CASCADE] Decision: ACCEPT action={selected_idx} (human confirmed AI choice)") | |
| if merkle: | |
| print(f"[CASCADE] Merkle: {merkle[:24]}...") | |
| except Exception as e: | |
| print(f"[CASCADE] Resolution error: {e}") | |
| # Execute player's selected move | |
| move_uci = candidates_data[selected_idx]['move'] | |
| move = chess.Move.from_uci(move_uci) | |
| board.push(move) | |
| history = history + [move_uci] | |
| print(f"[COMMIT] Player played: {move_uci}") | |
| # ═══════════════════════════════════════════════════════════════ | |
| # CASCADE-LATTICE: Track causation | |
| # ═══════════════════════════════════════════════════════════════ | |
| if CAUSATION is not None and CausalEvent is not None: | |
| try: | |
| import time as time_mod | |
| event = CausalEvent( | |
| timestamp=time_mod.time(), | |
| component="player", | |
| event_type="chess_decision", | |
| data={ | |
| 'fen_before': fen, | |
| 'fen_after': board.fen(), | |
| 'move': move_uci, | |
| 'was_override': LAST_RESOLUTION.get('was_override', False) if LAST_RESOLUTION else False, | |
| 'merkle': LAST_RESOLUTION.get('merkle') if LAST_RESOLUTION else None | |
| }, | |
| event_id=f"move_{len(history)}" | |
| ) | |
| CAUSATION.add_event(event) | |
| print(f"[CASCADE] CausationGraph: added event move_{len(history)}") | |
| except Exception as e: | |
| print(f"[CASCADE] Causation error: {e}") | |
| print(f"[COMMIT] Board after move: {board.fen()}") | |
| # Check if game is over | |
| if board.is_game_over(): | |
| print(f"[COMMIT] Game over!") | |
| return board.fen(), [], False, history, 0, [], [], {}, refresh_trigger | |
| # ═══════════════════════════════════════════════════════════════ | |
| # HOLD MODE CONTINUES: Generate candidates for the next player | |
| # ═══════════════════════════════════════════════════════════════ | |
| print(f"[COMMIT] Generating candidates for {'BLACK' if not board.turn else 'WHITE'}...") | |
| next_cands, next_trace, next_decision, next_hold_data = get_candidates_with_trace(board) | |
| if next_cands: | |
| print(f"[COMMIT] Generated {len(next_cands)} candidates for opponent") | |
| new_refresh = (refresh_trigger or 0) + 1 | |
| return ( | |
| board.fen(), | |
| [c.__dict__ for c in next_cands], # New candidates | |
| True, # Stay in HOLD mode | |
| history, | |
| 0, # Reset selection to top choice | |
| next_trace, | |
| next_decision, | |
| next_hold_data, | |
| new_refresh # Increment to force panel refresh | |
| ) | |
| else: | |
| # No candidates (shouldn't happen unless game over) | |
| return board.fen(), [], False, history, 0, [], [], {}, refresh_trigger | |
| print(f"[COMMIT] Final FEN: {board.fen()}") | |
| return board.fen(), [], False, history, 0, [], [], {}, refresh_trigger | |
| # Show/hide commit button based on HOLD state | |
| def toggle_commit_button(is_held): | |
| print(f"[TOGGLE_COMMIT] is_held={is_held}") | |
| base_style = { | |
| 'margin': '10px auto', 'padding': '12px 30px', | |
| 'fontSize': '14px', 'fontFamily': 'monospace', 'fontWeight': 'bold', | |
| 'borderRadius': '6px', 'cursor': 'pointer', | |
| 'backgroundColor': '#30804022', 'color': '#308040', | |
| 'border': '2px solid #308040' | |
| } | |
| if is_held: | |
| return {**base_style, 'display': 'block'} | |
| else: | |
| return {**base_style, 'display': 'none'} | |
| # Display selected move info | |
| def show_selected_info(selected_idx, candidates_data, is_held): | |
| if not is_held or not candidates_data: | |
| return "" | |
| if selected_idx >= len(candidates_data): | |
| selected_idx = 0 | |
| c = candidates_data[selected_idx] | |
| cp_score = int(c.get('value', 0)) # Raw centipawn | |
| move_type = c.get('move_type', 'quiet') | |
| # Build info display | |
| return html.Div([ | |
| html.Div([ | |
| html.Span("SELECTED: ", style={'color': '#666'}), | |
| html.Span(f"{c['move']}", style={'color': CYAN, 'fontSize': '16px', 'fontWeight': 'bold'}), | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span(f"Engine Eval: ", style={'color': '#555'}), | |
| html.Span(f"{cp_score:+d}cp", style={'color': '#0F0' if cp_score > 0 else '#F44' if cp_score < 0 else '#888'}), | |
| html.Span(f" | Type: ", style={'color': '#555'}), | |
| html.Span(move_type.upper(), style={'color': MAGENTA if c.get('is_capture') else '#888'}), | |
| ]), | |
| ]) | |
| # INFORMATIONAL WEALTH callback | |
| def update_wealth_panel(_refresh, hold_data, is_held, selected_idx): | |
| print(f"[WEALTH] is_held={is_held}, hold_data keys={list(hold_data.keys()) if hold_data else 'None'}, refresh={_refresh}") | |
| # Check for actual hold state | |
| if not is_held: | |
| return html.Div("Click HOLD to inspect", | |
| style={'color': '#444', 'fontStyle': 'italic', 'padding': '20px', 'textAlign': 'center'}) | |
| if not hold_data or not isinstance(hold_data, dict) or len(hold_data) == 0: | |
| return html.Div("Loading data...", | |
| style={'color': '#666', 'fontStyle': 'italic', 'padding': '20px', 'textAlign': 'center'}) | |
| elements = [] | |
| # Feature descriptions for tooltips | |
| feature_tooltips = { | |
| 'material': 'Total piece value difference (positive = white advantage)', | |
| 'center_control': 'Control over central squares d4, d5, e4, e5', | |
| 'king_safety': 'How well protected the king is from attack', | |
| 'development': 'Number of pieces developed from starting squares', | |
| 'mobility': 'Total number of legal moves available', | |
| 'pawn_structure': 'Quality of pawn chains and weaknesses', | |
| 'piece_activity': 'How actively placed the pieces are', | |
| 'threats': 'Number of attacking threats being created', | |
| 'pressure': 'Positional pressure on opponent', | |
| 'coordination': 'How well pieces work together' | |
| } | |
| # Features section | |
| features = hold_data.get('features', {}) | |
| if features: | |
| feature_items = [] | |
| for k, v in features.items(): | |
| tooltip = feature_tooltips.get(k.lower().replace(' ', '_'), f'Position feature: {k}') | |
| feature_items.append( | |
| html.Div([ | |
| html.Span(f"{k}: ", style={'color': '#666', 'width': '120px', 'display': 'inline-block', 'cursor': 'help'}, | |
| title=tooltip), | |
| html.Span(f"{v:.2f}" if isinstance(v, (int, float)) else str(v), | |
| style={'color': '#FFF' if isinstance(v, str) else | |
| ('#0F0' if v > 0 else '#F44' if v < 0 else '#888')}, | |
| title=f"Value: {v:.2f}" if isinstance(v, (int, float)) else str(v)) | |
| ], style={'marginBottom': '4px', 'fontSize': '12px'}) | |
| ) | |
| elements.append(html.Div([ | |
| html.Div("⚡ FEATURES", style={'color': '#308040', 'fontWeight': 'bold', 'marginBottom': '8px', 'cursor': 'help'}, | |
| title="Numerical features extracted from the position by cascade-lattice"), | |
| *feature_items | |
| ], style={'marginBottom': '15px'})) | |
| # Reasoning section | |
| reasoning = hold_data.get('reasoning', []) | |
| if reasoning: | |
| elements.append(html.Div([ | |
| html.Div("🧠 REASONING", style={'color': GOLD, 'fontWeight': 'bold', 'marginBottom': '8px', 'cursor': 'help'}, | |
| title="Human-readable explanations of the AI's strategic thinking"), | |
| *[html.Div(f"• {r}", style={'color': '#AAA', 'fontSize': '12px', 'marginBottom': '4px'}, | |
| title=f"Strategic consideration: {r}") | |
| for r in reasoning] | |
| ], style={'marginBottom': '15px'})) | |
| # Imagination section (predicted responses) | |
| imagination = hold_data.get('imagination', {}) | |
| if imagination: | |
| action_labels = hold_data.get('action_labels', []) | |
| imagination_items = [] | |
| for idx, data in imagination.items(): | |
| move_name = action_labels[int(idx)] if int(idx) < len(action_labels) else f'Move {idx}' | |
| predicted = data.get('predicted_response', '?') | |
| value_after = data.get('value_after_response', 0) | |
| tooltip = f"If we play {move_name}, opponent likely responds {predicted}, leaving position at {value_after:+.2f}" | |
| imagination_items.append( | |
| html.Div([ | |
| html.Span(f"{move_name}: ", | |
| style={'color': CYAN if int(idx) == selected_idx else '#666'}), | |
| html.Span(f"→ {predicted} ", style={'color': '#AAA'}), | |
| html.Span(f"({value_after:+.2f})", | |
| style={'color': '#0F0' if value_after > 0 else '#F44'}) | |
| ], style={'marginBottom': '4px', 'fontSize': '12px', 'cursor': 'help'}, | |
| title=tooltip) | |
| ) | |
| elements.append(html.Div([ | |
| html.Div("🔮 IMAGINATION", style={'color': MAGENTA, 'fontWeight': 'bold', 'marginBottom': '8px', 'cursor': 'help'}, | |
| title="AI's prediction of opponent's response to each candidate move"), | |
| *imagination_items | |
| ], style={'marginBottom': '15px'})) | |
| # AI confidence | |
| ai_conf = hold_data.get('ai_confidence', 0) | |
| ai_choice = hold_data.get('ai_choice', 0) | |
| action_labels = hold_data.get('action_labels', []) | |
| ai_move = action_labels[ai_choice] if ai_choice < len(action_labels) else '?' | |
| elements.append(html.Div([ | |
| html.Div("🤖 AI RECOMMENDATION", style={'color': '#888', 'fontWeight': 'bold', 'marginBottom': '8px', 'cursor': 'help'}, | |
| title="The move the AI would play and how confident it is"), | |
| html.Div([ | |
| html.Span(f"{ai_move} ", style={'color': CYAN, 'fontSize': '14px', 'fontWeight': 'bold'}, | |
| title=f"AI's top choice: {ai_move}"), | |
| html.Span(f"({ai_conf*100:.1f}% confidence)", style={'color': '#666', 'fontSize': '12px'}, | |
| title=f"AI is {ai_conf*100:.1f}% confident this is the best move") | |
| ]) | |
| ])) | |
| if not elements: | |
| return html.Div("No data available", style={'color': '#444', 'padding': '20px', 'textAlign': 'center'}) | |
| return html.Div(elements, style={'fontFamily': 'monospace'}) | |
| # MERKLE AUDIT TRAIL callback | |
| def update_merkle_display(hold_data, is_held): | |
| """Display the cryptographic merkle hash from cascade-lattice.""" | |
| if not is_held or not hold_data: | |
| return "" | |
| merkle = hold_data.get('merkle') | |
| if not merkle: | |
| return html.Div([ | |
| html.Span("🔐 MERKLE: ", style={'color': '#555', 'fontSize': '11px'}), | |
| html.Span("generating...", style={'color': '#666', 'fontStyle': 'italic', 'fontSize': '11px'}) | |
| ], style={'fontFamily': 'monospace', 'padding': '8px', 'backgroundColor': '#0a0a12', | |
| 'borderRadius': '4px', 'border': '1px solid #1a1a2e'}) | |
| # Show truncated merkle with copy-able full version | |
| return html.Div([ | |
| html.Div([ | |
| html.Span("🔐 ", style={'fontSize': '14px'}), | |
| html.Span("MERKLE AUDIT HASH", style={'color': '#308040', 'fontWeight': 'bold', 'fontSize': '11px'}) | |
| ], style={'marginBottom': '6px'}), | |
| html.Div([ | |
| html.Code(merkle, style={ | |
| 'color': CYAN, 'fontSize': '9px', 'wordBreak': 'break-all', | |
| 'backgroundColor': '#0a0a12', 'padding': '4px 8px', 'borderRadius': '3px', | |
| 'display': 'block', 'border': '1px solid #30804033' | |
| }) | |
| ]), | |
| html.Div([ | |
| html.Span("Cryptographic proof of decision state", | |
| style={'color': '#444', 'fontSize': '10px', 'fontStyle': 'italic'}) | |
| ], style={'marginTop': '4px'}) | |
| ], style={'fontFamily': 'monospace', 'padding': '10px', 'backgroundColor': '#0a0a15', | |
| 'borderRadius': '6px', 'border': '1px solid #30804033'}) | |
| # CAUSATION GRAPH callback - adversarial layout with bidirectional sync | |
| def update_causation_graph(fen, history): | |
| """Build Cytoscape elements with ADVERSARIAL layout (White top, Black bottom). | |
| Visualization pattern: | |
| - White decisions appear in the TOP lane | |
| - Black decisions appear in the BOTTOM lane | |
| - Cross-lane edges show the alternating adversarial flow | |
| - Full decision matrix with all candidates branching off each decision | |
| """ | |
| if CAUSATION is None: | |
| return [], "(not available)", [], 0, {}, {} | |
| try: | |
| recent_events = CAUSATION.get_recent_events(100) | |
| if not recent_events: | |
| return [], "(waiting for events...)", [], 0, {}, {} | |
| elements = [] | |
| total_branches = 0 | |
| node_map = {} # Maps event index to node ID for sync | |
| # Filter to decision/yield events | |
| decision_events = [evt for evt in recent_events | |
| if getattr(evt, 'event_type', '') == 'yield_point'] | |
| # Cache all events for timeline | |
| events_cache = [] | |
| for evt in decision_events: | |
| events_cache.append({ | |
| 'event_id': getattr(evt, 'event_id', '?'), | |
| 'timestamp': str(getattr(evt, 'timestamp', '')), | |
| 'component': getattr(evt, 'component', '?'), | |
| 'event_type': getattr(evt, 'event_type', '?'), | |
| 'data': getattr(evt, 'data', {}) | |
| }) | |
| # Show ALL decision points (no truncation) | |
| display_events = decision_events # Was: decision_events[-12:] | |
| # Layout parameters for adversarial pattern - ADJUST spacing for more events | |
| num_events = len(display_events) | |
| X_SPACING = max(80, min(120, 1000 // max(1, num_events))) # Dynamic spacing | |
| Y_WHITE = 60 # Y position for white lane (top) | |
| Y_BLACK = 300 # Y position for black lane (bottom) | |
| CANDIDATE_SPREAD = 30 # Vertical spread for candidates | |
| prev_node_id = None | |
| prev_is_white = None | |
| for evt_idx, evt in enumerate(display_events): | |
| event_id = getattr(evt, 'event_id', f'evt_{evt_idx}') | |
| data = getattr(evt, 'data', {}) | |
| turn = data.get('turn', 'white') | |
| is_white = turn == 'white' | |
| all_candidates = data.get('all_candidates', []) | |
| imagination = data.get('imagination', {}) | |
| reasoning = data.get('reasoning', []) | |
| trace_data = data.get('trace_data', []) | |
| decision_data = data.get('decision_data', []) | |
| hold_data = data.get('hold_data', {}) | |
| # Calculate position in adversarial layout | |
| x_pos = 80 + evt_idx * X_SPACING | |
| y_pos = Y_WHITE if is_white else Y_BLACK | |
| # Map this event index to node ID for sync | |
| global_idx = len(events_cache) - len(display_events) + evt_idx | |
| node_map[global_idx] = f"decision_{event_id}" | |
| # Create DECISION POINT node with FULL TIME TRAVEL DATA | |
| decision_node_id = f"decision_{event_id}" | |
| symbol = "♔" if is_white else "♚" | |
| elements.append({ | |
| 'data': { | |
| 'id': decision_node_id, | |
| 'label': f"{symbol}\n#{evt_idx+1}", | |
| 'event_idx': global_idx, | |
| 'event_data': json.dumps({ | |
| 'turn': turn, | |
| 'fen': data.get('fen', ''), | |
| 'all_candidates': all_candidates, | |
| 'imagination': imagination, | |
| 'reasoning': reasoning, | |
| 'trace_data': trace_data, | |
| 'decision_data': decision_data, | |
| 'hold_data': hold_data, | |
| 'merkle': data.get('merkle', '') | |
| }) | |
| }, | |
| 'classes': 'white-decision' if is_white else 'black-decision', | |
| 'position': {'x': x_pos, 'y': y_pos} | |
| }) | |
| # Connect to previous node | |
| if prev_node_id: | |
| # Cross-lane edge if color switched, otherwise flow edge | |
| edge_class = 'cross-edge' if prev_is_white != is_white else 'flow-edge' | |
| elements.append({ | |
| 'data': {'source': prev_node_id, 'target': decision_node_id}, | |
| 'classes': edge_class | |
| }) | |
| # Create CANDIDATE nodes branching off decision | |
| for i, cand in enumerate(all_candidates[:5]): # Show top 5 candidates | |
| cand_id = f"{event_id}_cand_{i}" | |
| move = cand.get('move', '?') | |
| score = cand.get('score', 0) | |
| is_capture = cand.get('is_capture', False) | |
| # Position candidates spreading vertically from decision | |
| if is_white: | |
| cand_y = y_pos - 50 - (i * CANDIDATE_SPREAD) # Spread upward for white | |
| else: | |
| cand_y = y_pos + 50 + (i * CANDIDATE_SPREAD) # Spread downward for black | |
| cand_class = 'candidate-node' | |
| if i == 0: | |
| cand_class += ' chosen-move' | |
| elif i < 3: | |
| cand_class += ' candidate-top3' | |
| move_label = move | |
| if is_capture: | |
| move_label += '×' | |
| elements.append({ | |
| 'data': { | |
| 'id': cand_id, | |
| 'label': f"{move_label}\n{score:+.0f}", | |
| 'event_data': json.dumps(cand) | |
| }, | |
| 'classes': cand_class, | |
| 'position': {'x': x_pos + 30, 'y': cand_y} | |
| }) | |
| elements.append({ | |
| 'data': {'source': decision_node_id, 'target': cand_id}, | |
| 'classes': 'candidate-edge' | |
| }) | |
| total_branches += 1 | |
| prev_node_id = decision_node_id | |
| prev_is_white = is_white | |
| # Create slider marks | |
| marks = {} | |
| for i, evt in enumerate(events_cache): | |
| if i % max(1, len(events_cache) // 6) == 0: # Show ~6 marks | |
| turn = evt.get('data', {}).get('turn', '?') | |
| marks[i] = {'label': f"{turn[0].upper()}{i+1}", | |
| 'style': {'color': '#4080A0' if turn == 'white' else '#904060', 'fontSize': '9px'}} | |
| max_idx = max(0, len(events_cache) - 1) | |
| stats = f"⚔ {len(display_events)} decisions 🌿 {total_branches} branches ⬆White ⬇Black" | |
| return elements, stats, events_cache, max_idx, marks, node_map | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return [], f"Error: {str(e)}", [], 0, {}, {} | |
| # Node click handler - sync with timeline slider AND show candidate details | |
| def time_travel_to_node(node_data, node_map, events_cache, refresh_trigger): | |
| """TIME TRAVEL: Clicking a graph node jumps the ENTIRE system to that historical state.""" | |
| if not node_data: | |
| raise PreventUpdate | |
| node_id = node_data.get('id', '') | |
| # Check if this is a CANDIDATE node (not a decision point) | |
| if '_cand_' in node_id: | |
| # This is a candidate node - show its metadata in the inspector, don't time travel | |
| try: | |
| event_data_str = node_data.get('event_data', '{}') | |
| event_data = json.loads(event_data_str) if isinstance(event_data_str, str) else event_data_str | |
| metadata_content = build_candidate_metadata(event_data, node_id) | |
| # Return no_update for all state - just show metadata | |
| return (dash.no_update, dash.no_update, metadata_content, node_id, | |
| dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update) | |
| except Exception as e: | |
| return (dash.no_update, dash.no_update, html.Div(f"Error: {str(e)}", style={'color': '#FF4444'}), node_id, | |
| dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update) | |
| # This is a DECISION POINT node - TIME TRAVEL to that historical state | |
| if not events_cache: | |
| raise PreventUpdate | |
| # Get the event index from the clicked node | |
| event_idx = node_data.get('event_idx') | |
| if event_idx is None: | |
| # Try to find node in reverse map | |
| if node_map: | |
| for idx, mapped_id in node_map.items(): | |
| if mapped_id == node_id: | |
| event_idx = int(idx) | |
| break | |
| if event_idx is None: | |
| raise PreventUpdate | |
| # Get the historical event data | |
| try: | |
| event_data_str = node_data.get('event_data', '{}') | |
| event_data = json.loads(event_data_str) if isinstance(event_data_str, str) else event_data_str | |
| # Extract time travel state | |
| historical_fen = event_data.get('fen', '') | |
| all_candidates = event_data.get('all_candidates', []) | |
| if not historical_fen: | |
| raise PreventUpdate | |
| # Try to get full trace/decision/hold data from the cached event | |
| # (We stored these in the CausalEvent) | |
| trace_data = event_data.get('trace_data', []) | |
| decision_data = event_data.get('decision_data', []) | |
| hold_data = event_data.get('hold_data', {}) | |
| # If we don't have full data, reconstruct from all_candidates | |
| if not decision_data and all_candidates: | |
| decision_data = [] | |
| best_score = max(c.get('score', 0) for c in all_candidates) if all_candidates else 0 | |
| for i, cand in enumerate(all_candidates): | |
| score = cand.get('score', 0) | |
| prob = (score - min(c.get('score', 0) for c in all_candidates)) / max(1, best_score - min(c.get('score', 0) for c in all_candidates)) if best_score != min(c.get('score', 0) for c in all_candidates) else 1.0 | |
| decision_data.append({ | |
| 'rank': i + 1, | |
| 'move': cand.get('move', '?'), | |
| 'cp': int(cand.get('score', 0)), | |
| 'prob': prob if i == 0 else prob * 0.8, | |
| 'capture': cand.get('is_capture', False), | |
| 'check': cand.get('is_check', False), | |
| 'selected': i == 0 | |
| }) | |
| # If we don't have hold_data, construct minimal version | |
| if not hold_data: | |
| hold_data = { | |
| 'features': {}, | |
| 'reasoning': event_data.get('reasoning', []), | |
| 'imagination': event_data.get('imagination', {}), | |
| 'action_labels': [c.get('move', '?') for c in all_candidates], | |
| 'ai_choice': 0, | |
| 'ai_confidence': all_candidates[0].get('prob', 0.5) if all_candidates else 0.5, | |
| 'merkle': event_data.get('merkle', 'historical') | |
| } | |
| # Build time travel metadata display | |
| turn = event_data.get('turn', 'white') | |
| num_cands = len(all_candidates) | |
| top_move = all_candidates[0].get('move', '?') if all_candidates else '?' | |
| top_score = all_candidates[0].get('score', 0) if all_candidates else 0 | |
| metadata_content = html.Div([ | |
| html.Div([ | |
| html.Span("⏪ TIME TRAVEL ACTIVE", style={'color': '#2090B0', 'fontWeight': 'bold', 'fontSize': '12px'}), | |
| ], style={'marginBottom': '10px', 'borderBottom': '2px solid #2090B044', 'paddingBottom': '8px'}), | |
| html.Div([ | |
| html.Span("Position: ", style={'color': '#666'}), | |
| html.Span(f"{'White' if turn == 'white' else 'Black'} to move", | |
| style={'color': '#FFF' if turn == 'white' else '#888', 'fontWeight': 'bold'}), | |
| ], style={'marginBottom': '6px'}), | |
| html.Div([ | |
| html.Span("Decision Point: ", style={'color': '#666'}), | |
| html.Span(f"#{event_idx + 1}", style={'color': '#D4A020', 'fontWeight': 'bold'}), | |
| ], style={'marginBottom': '6px'}), | |
| html.Div([ | |
| html.Span("Candidates: ", style={'color': '#666'}), | |
| html.Span(f"{num_cands} moves analyzed", style={'color': '#308040'}), | |
| ], style={'marginBottom': '6px'}), | |
| html.Div([ | |
| html.Span("Chosen: ", style={'color': '#666'}), | |
| html.Span(f"{top_move} ", style={'color': '#2090B0', 'fontWeight': 'bold', 'fontSize': '14px'}), | |
| html.Span(f"({top_score:+.0f})", style={'color': '#D4A020' if top_score > 0 else '#FF4444'}), | |
| ], style={'marginBottom': '10px'}), | |
| html.Div([ | |
| html.Span("💡 All HOLD panels now show this historical state", | |
| style={'color': '#888', 'fontSize': '10px', 'fontStyle': 'italic'}) | |
| ]) | |
| ], style={'fontFamily': 'monospace', 'fontSize': '11px'}) | |
| # Increment refresh trigger to force panel updates | |
| new_refresh = (refresh_trigger or 0) + 1 | |
| print(f"[TIME TRAVEL] Jumped to event #{event_idx}: {historical_fen[:30]}... | {num_cands} candidates") | |
| # Return FULL TIME TRAVEL - board, candidates, trace, decision, hold_data, is_held=True | |
| return (event_idx, event_idx, metadata_content, node_id, | |
| historical_fen, all_candidates, trace_data, decision_data, hold_data, True, new_refresh) | |
| except Exception as e: | |
| print(f"[TIME TRAVEL] Error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return (dash.no_update, dash.no_update, html.Div(f"Time travel error: {str(e)}", style={'color': '#FF4444'}), node_id, | |
| dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # CINEMATIC REPLAY SYSTEM - NFL replay quality causation playback | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def toggle_cinematic_replay(n_clicks, is_active, events_cache): | |
| """Toggle cinematic replay on/off.""" | |
| if not n_clicks: | |
| raise PreventUpdate | |
| if is_active: | |
| # Stop replay - send camera stop command | |
| camera_stop = {'type': 'cinematic_stop'} | |
| return False, 0, [], "🎬 REPLAY", { | |
| 'backgroundColor': '#FF660022', 'color': '#FF6600', 'border': '1px solid #FF6600', | |
| 'padding': '4px 12px', 'cursor': 'pointer', 'borderRadius': '3px', 'fontSize': '11px', | |
| 'fontWeight': 'bold', 'marginLeft': '10px' | |
| }, camera_stop | |
| else: | |
| # Start replay from beginning (oldest event first) | |
| if not events_cache: | |
| raise PreventUpdate | |
| # CRITICAL: Reverse the events so we play from OLDEST to NEWEST (chronological order) | |
| # events_cache is stored with most recent first, we need oldest first for replay | |
| chronological_events = list(reversed(events_cache)) | |
| print(f"[CINEMATIC] Starting replay with {len(chronological_events)} events (oldest to newest)") | |
| # Send camera start command | |
| camera_start = {'type': 'cinematic_start'} | |
| return True, 0, chronological_events, "⏹ STOP", { | |
| 'backgroundColor': '#FF4444', 'color': '#FFF', 'border': '1px solid #FF4444', | |
| 'padding': '4px 12px', 'cursor': 'pointer', 'borderRadius': '3px', 'fontSize': '11px', | |
| 'fontWeight': 'bold', 'marginLeft': '10px', 'animation': 'pulse 1s infinite' | |
| }, camera_start | |
| def toggle_cinematic_interval(is_active): | |
| """Enable/disable the cinematic interval based on active state.""" | |
| return not is_active | |
| def cinematic_step(n_intervals, is_active, current_idx, events, refresh_trigger): | |
| """Advance one step in cinematic replay - movie-theater quality playback with camera sequencer.""" | |
| if not is_active or not events: | |
| raise PreventUpdate | |
| # Check if we've reached the end | |
| if current_idx >= len(events): | |
| # Replay finished - stop and reset, send camera stop | |
| camera_stop = {'type': 'cinematic_stop'} | |
| return (0, dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, | |
| dash.no_update, dash.no_update, dash.no_update, dash.no_update, | |
| html.Div([ | |
| html.Div("🎬 REPLAY COMPLETE", style={'color': '#308040', 'fontWeight': 'bold', 'fontSize': '14px', 'marginBottom': '10px'}), | |
| html.Div("The causation chain has been fully replayed.", style={'color': '#888', 'fontSize': '11px'}) | |
| ], style={'textAlign': 'center', 'padding': '20px'}), | |
| False, "🎬 REPLAY", { | |
| 'backgroundColor': '#FF660022', 'color': '#FF6600', 'border': '1px solid #FF6600', | |
| 'padding': '4px 12px', 'cursor': 'pointer', 'borderRadius': '3px', 'fontSize': '11px', | |
| 'fontWeight': 'bold', 'marginLeft': '10px' | |
| }, camera_stop) | |
| # Get current event | |
| try: | |
| evt = events[current_idx] | |
| data = evt.get('data', evt) if isinstance(evt, dict) else getattr(evt, 'data', {}) | |
| # Extract state for this frame | |
| historical_fen = data.get('fen', '') | |
| all_candidates = data.get('all_candidates', []) | |
| trace_data = data.get('trace_data', []) | |
| decision_data = data.get('decision_data', []) | |
| hold_data = data.get('hold_data', {}) | |
| turn = data.get('turn', 'white') | |
| if not historical_fen: | |
| # Skip this frame | |
| return (current_idx + 1, dash.no_update, dash.no_update, dash.no_update, dash.no_update, | |
| dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, | |
| dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update) | |
| # Reconstruct decision_data if needed | |
| if not decision_data and all_candidates: | |
| decision_data = [] | |
| for i, cand in enumerate(all_candidates): | |
| decision_data.append({ | |
| 'rank': i + 1, | |
| 'move': cand.get('move', '?'), | |
| 'cp': int(cand.get('score', 0)), | |
| 'prob': cand.get('prob', 0.5), | |
| 'capture': cand.get('is_capture', False), | |
| 'check': cand.get('is_check', False), | |
| 'selected': i == 0 | |
| }) | |
| # Reconstruct hold_data if needed | |
| if not hold_data: | |
| hold_data = { | |
| 'features': {}, | |
| 'reasoning': data.get('reasoning', []), | |
| 'imagination': data.get('imagination', {}), | |
| 'action_labels': [c.get('move', '?') for c in all_candidates], | |
| 'ai_choice': 0, | |
| 'ai_confidence': all_candidates[0].get('prob', 0.5) if all_candidates else 0.5, | |
| 'merkle': data.get('merkle', 'replay') | |
| } | |
| # Build cinematic metadata display | |
| top_move = all_candidates[0].get('move', '?') if all_candidates else '?' | |
| top_score = all_candidates[0].get('score', 0) if all_candidates else 0 | |
| top_from_sq = all_candidates[0].get('from_sq', 0) if all_candidates else 0 | |
| top_to_sq = all_candidates[0].get('to_sq', 0) if all_candidates else 0 | |
| top_is_capture = all_candidates[0].get('is_capture', False) if all_candidates else False | |
| top_is_check = all_candidates[0].get('is_check', False) if all_candidates else False | |
| # BUILD REPLAY COMMAND - no camera movement, just overlay update | |
| camera_cmd = { | |
| 'type': 'cinematic_move', | |
| 'move_name': top_move, # Human-readable move like "e2e4" | |
| 'move_num': current_idx + 1, | |
| 'total_moves': len(events), | |
| 'is_capture': top_is_capture, | |
| 'is_check': top_is_check, | |
| 'turn': turn | |
| } | |
| metadata_content = html.Div([ | |
| html.Div([ | |
| html.Span("🎬 CINEMATIC REPLAY", style={'color': '#FF6600', 'fontWeight': 'bold', 'fontSize': '14px'}), | |
| ], style={'marginBottom': '8px', 'borderBottom': '2px solid #FF660044', 'paddingBottom': '6px'}), | |
| html.Div([ | |
| html.Span("Frame: ", style={'color': '#666'}), | |
| html.Span(f"{current_idx + 1} / {len(events)}", style={'color': '#D4A020', 'fontWeight': 'bold', 'fontSize': '16px'}), | |
| ], style={'marginBottom': '8px'}), | |
| html.Div([ | |
| html.Span("⬤ ", style={'color': '#FFF' if turn == 'white' else '#333'}), | |
| html.Span(f"{'White' if turn == 'white' else 'Black'} to move", | |
| style={'color': '#FFF' if turn == 'white' else '#888', 'fontWeight': 'bold'}), | |
| ], style={'marginBottom': '6px'}), | |
| html.Div([ | |
| html.Span("Move: ", style={'color': '#666'}), | |
| html.Span(f"{top_move} ", style={'color': '#2090B0', 'fontWeight': 'bold', 'fontSize': '18px'}), | |
| html.Span(f"({top_score:+.0f})", style={'color': '#D4A020' if top_score > 0 else '#B01030'}), | |
| ], style={'marginBottom': '10px'}), | |
| # Progress bar | |
| html.Div([ | |
| html.Div(style={ | |
| 'width': f'{((current_idx + 1) / len(events)) * 100}%', | |
| 'height': '4px', | |
| 'backgroundColor': '#FF6600', | |
| 'borderRadius': '2px', | |
| 'transition': 'width 0.3s ease' | |
| }) | |
| ], style={'backgroundColor': '#1a1a2e', 'borderRadius': '2px', 'overflow': 'hidden'}) | |
| ], style={'fontFamily': 'monospace', 'fontSize': '11px'}) | |
| new_refresh = (refresh_trigger or 0) + 1 | |
| next_idx = current_idx + 1 | |
| print(f"[REPLAY] Frame {current_idx + 1}/{len(events)}: {top_move}") | |
| return (next_idx, historical_fen, all_candidates, trace_data, decision_data, hold_data, | |
| True, new_refresh, current_idx, current_idx, metadata_content, | |
| dash.no_update, dash.no_update, dash.no_update, camera_cmd) | |
| except Exception as e: | |
| print(f"[CINEMATIC] Error at frame {current_idx}: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| # Skip to next frame | |
| return (current_idx + 1, dash.no_update, dash.no_update, dash.no_update, dash.no_update, | |
| dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, | |
| dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update) | |
| def build_candidate_metadata(cand_data, node_id): | |
| """Build rich metadata display for a candidate (alternative move) node.""" | |
| move = cand_data.get('move', '?') | |
| score = cand_data.get('score', 0) | |
| prob = cand_data.get('prob', 0) | |
| is_capture = cand_data.get('is_capture', False) | |
| is_check = cand_data.get('is_check', False) | |
| move_type = cand_data.get('move_type', 'normal') | |
| # Get from/to squares - either from stored data or parse from move | |
| from_sq = cand_data.get('from_sq', '') | |
| to_sq = cand_data.get('to_sq', '') | |
| piece = cand_data.get('piece', '') | |
| # Parse from UCI move if not stored | |
| if not from_sq and len(move) >= 4: | |
| from_sq = move[:2] | |
| to_sq = move[2:4] | |
| # Default displays | |
| from_sq = from_sq if from_sq else '?' | |
| to_sq = to_sq if to_sq else '?' | |
| piece = piece if piece else '?' | |
| # Determine if this was the chosen move (rank 0 in the id typically) | |
| is_chosen = '_cand_0' in node_id | |
| sections = [] | |
| # Header | |
| status_color = '#308040' if is_chosen else '#FF8800' | |
| status_text = '★ CHOSEN MOVE' if is_chosen else '◇ ALTERNATIVE CANDIDATE' | |
| sections.append(html.Div([ | |
| html.Div([ | |
| html.Span("♟ ", style={'fontSize': '16px'}), | |
| html.Span(f"{move}", style={'color': status_color, 'fontWeight': 'bold', 'fontSize': '18px'}), | |
| html.Span(f" {status_text}", style={'color': status_color, 'fontSize': '10px', 'marginLeft': '10px'}), | |
| ]), | |
| ], style={'marginBottom': '10px', 'borderBottom': f'2px solid {status_color}44', 'paddingBottom': '8px'})) | |
| # Core evaluation metrics | |
| sections.append(html.Div([ | |
| html.Div("📊 EVALUATION METRICS", style={'color': '#888', 'fontSize': '9px', 'fontWeight': 'bold', 'marginBottom': '6px'}), | |
| html.Table([ | |
| html.Tr([ | |
| html.Td("Centipawn Score:", style={'color': '#555', 'fontSize': '10px', 'paddingRight': '15px'}), | |
| html.Td(html.Span(f"{score:+.0f} cp", style={'color': '#4080A0', 'fontWeight': 'bold', 'fontSize': '12px'})) | |
| ]), | |
| html.Tr([ | |
| html.Td("Selection Probability:", style={'color': '#555', 'fontSize': '10px', 'paddingRight': '15px'}), | |
| html.Td([ | |
| html.Span(f"{prob:.3f}", style={'color': '#FF8800', 'fontWeight': 'bold'}), | |
| html.Span(f" ({prob*100:.1f}%)", style={'color': '#666', 'fontSize': '9px'}) | |
| ]) | |
| ]), | |
| html.Tr([ | |
| html.Td("Move Type:", style={'color': '#555', 'fontSize': '10px', 'paddingRight': '15px'}), | |
| html.Td(html.Span(move_type.upper(), style={'color': '#888', 'fontSize': '10px'})) | |
| ]), | |
| ], style={'borderCollapse': 'collapse', 'width': '100%'}) | |
| ], style={'backgroundColor': '#0a0a12', 'padding': '10px', 'borderRadius': '4px', 'marginBottom': '10px', | |
| 'border': '1px solid #1a1a2e'})) | |
| # Move details | |
| sections.append(html.Div([ | |
| html.Div("🎯 MOVE DETAILS", style={'color': '#888', 'fontSize': '9px', 'fontWeight': 'bold', 'marginBottom': '6px'}), | |
| html.Table([ | |
| html.Tr([ | |
| html.Td("From Square:", style={'color': '#555', 'fontSize': '10px', 'paddingRight': '15px'}), | |
| html.Td(html.Code(from_sq, style={'color': '#4080A0', 'backgroundColor': '#0a0a15', 'padding': '2px 6px', 'borderRadius': '3px'})) | |
| ]), | |
| html.Tr([ | |
| html.Td("To Square:", style={'color': '#555', 'fontSize': '10px', 'paddingRight': '15px'}), | |
| html.Td(html.Code(to_sq, style={'color': '#4080A0', 'backgroundColor': '#0a0a15', 'padding': '2px 6px', 'borderRadius': '3px'})) | |
| ]), | |
| html.Tr([ | |
| html.Td("Piece:", style={'color': '#555', 'fontSize': '10px', 'paddingRight': '15px'}), | |
| html.Td(html.Span(str(piece).upper() if piece else '?', style={'color': '#FF8800'})) | |
| ]), | |
| ], style={'borderCollapse': 'collapse', 'width': '100%'}) | |
| ], style={'backgroundColor': '#0a0a12', 'padding': '10px', 'borderRadius': '4px', 'marginBottom': '10px', | |
| 'border': '1px solid #1a1a2e'})) | |
| # Properties / Flags | |
| props = [] | |
| if is_capture: | |
| props.append(html.Div([ | |
| html.Span("⚔ CAPTURE", style={'color': '#FF4444', 'fontWeight': 'bold'}), | |
| html.Span(" — This move captures an enemy piece", style={'color': '#666', 'fontSize': '9px'}) | |
| ], style={'padding': '4px 8px', 'backgroundColor': '#1a0a0a', 'borderRadius': '3px', 'marginBottom': '4px', | |
| 'border': '1px solid #FF444433'})) | |
| if is_check: | |
| props.append(html.Div([ | |
| html.Span("♚+ CHECK", style={'color': '#D4A020', 'fontWeight': 'bold'}), | |
| html.Span(" — This move puts the opponent in check", style={'color': '#666', 'fontSize': '9px'}) | |
| ], style={'padding': '4px 8px', 'backgroundColor': '#1a1a00', 'borderRadius': '3px', 'marginBottom': '4px', | |
| 'border': '1px solid #D4A02033'})) | |
| if props: | |
| sections.append(html.Div([ | |
| html.Div("🚩 PROPERTIES", style={'color': '#888', 'fontSize': '9px', 'fontWeight': 'bold', 'marginBottom': '6px'}), | |
| html.Div(props) | |
| ], style={'marginBottom': '10px'})) | |
| # Why not chosen (for alternatives) | |
| if not is_chosen: | |
| sections.append(html.Div([ | |
| html.Div("💭 WHY NOT CHOSEN?", style={'color': '#FF8800', 'fontSize': '9px', 'fontWeight': 'bold', 'marginBottom': '6px'}), | |
| html.Div([ | |
| html.Span("This candidate was evaluated but not selected. ", style={'color': '#666', 'fontSize': '10px'}), | |
| html.Span("The AI chose a move with better expected value or strategic alignment.", | |
| style={'color': '#555', 'fontSize': '9px', 'fontStyle': 'italic'}) | |
| ], style={'padding': '8px', 'backgroundColor': '#0a0a12', 'borderRadius': '4px', 'border': '1px solid #FF880033'}) | |
| ], style={'marginBottom': '10px'})) | |
| # Raw data | |
| sections.append(html.Details([ | |
| html.Summary("📦 RAW CANDIDATE DATA (click to expand)", | |
| style={'color': '#555', 'cursor': 'pointer', 'fontSize': '9px', 'outline': 'none'}), | |
| html.Pre(json.dumps(cand_data, indent=2, default=str), | |
| style={'color': '#444', 'fontSize': '8px', 'backgroundColor': '#050508', | |
| 'padding': '8px', 'borderRadius': '3px', 'marginTop': '6px', 'whiteSpace': 'pre-wrap', | |
| 'maxHeight': '150px', 'overflowY': 'auto', 'border': '1px solid #222'}) | |
| ], style={'marginTop': '10px'})) | |
| return html.Div(sections, style={'fontFamily': 'monospace', 'fontSize': '10px'}) | |
| # Update graph to highlight selected node - handles both decision points and candidates | |
| def sync_selection_to_graph(idx, selected_node_id, node_map): | |
| """Highlight the selected node (decision point OR candidate). Only one at a time.""" | |
| # Build FRESH stylesheet from scratch - ensures only ONE node highlighted | |
| base_stylesheet = [ | |
| # Base node style | |
| { | |
| 'selector': 'node', | |
| 'style': { | |
| 'label': 'data(label)', | |
| 'text-valign': 'center', | |
| 'text-halign': 'center', | |
| 'font-size': '9px', | |
| 'font-family': 'monospace', | |
| 'color': '#ffffff', | |
| 'text-outline-color': '#000', | |
| 'text-outline-width': 1, | |
| 'text-wrap': 'wrap', | |
| 'text-max-width': '55px', | |
| 'width': 50, | |
| 'height': 45 | |
| } | |
| }, | |
| # DECISION POINT - White (top lane) | |
| { | |
| 'selector': '.white-decision', | |
| 'style': { | |
| 'shape': 'round-rectangle', | |
| 'background-color': '#001a2e', | |
| 'border-color': '#4080A0', | |
| 'border-width': 2, | |
| 'width': 55, | |
| 'height': 45 | |
| } | |
| }, | |
| # DECISION POINT - Black (bottom lane) | |
| { | |
| 'selector': '.black-decision', | |
| 'style': { | |
| 'shape': 'round-rectangle', | |
| 'background-color': '#1a0018', | |
| 'border-color': '#904060', | |
| 'border-width': 2, | |
| 'width': 55, | |
| 'height': 45 | |
| } | |
| }, | |
| # Chosen move highlight | |
| { | |
| 'selector': '.chosen-move', | |
| 'style': { | |
| 'border-color': '#308040', | |
| 'border-width': 3 | |
| } | |
| }, | |
| # Candidate nodes (smaller, branching off) | |
| { | |
| 'selector': '.candidate-node', | |
| 'style': { | |
| 'shape': 'ellipse', | |
| 'width': 35, | |
| 'height': 30, | |
| 'font-size': '8px', | |
| 'opacity': 0.8, | |
| 'background-color': '#0a0a12', | |
| 'border-color': '#444466', | |
| 'border-width': 1 | |
| } | |
| }, | |
| { | |
| 'selector': '.candidate-top3', | |
| 'style': { | |
| 'border-color': '#FF8800', | |
| 'opacity': 0.9 | |
| } | |
| }, | |
| # Timeline edges (main flow between decisions) | |
| { | |
| 'selector': '.flow-edge', | |
| 'style': { | |
| 'width': 3, | |
| 'line-color': '#FF6600', | |
| 'target-arrow-color': '#FF6600', | |
| 'target-arrow-shape': 'triangle', | |
| 'curve-style': 'bezier', | |
| 'arrow-scale': 1.2 | |
| } | |
| }, | |
| # Cross-lane edges (white to black transition) | |
| { | |
| 'selector': '.cross-edge', | |
| 'style': { | |
| 'width': 2, | |
| 'line-color': '#666666', | |
| 'line-style': 'dashed', | |
| 'target-arrow-shape': 'triangle', | |
| 'target-arrow-color': '#666666', | |
| 'curve-style': 'unbundled-bezier', | |
| 'control-point-distances': [40], | |
| 'control-point-weights': [0.5] | |
| } | |
| }, | |
| # Candidate edges | |
| { | |
| 'selector': '.candidate-edge', | |
| 'style': { | |
| 'width': 1, | |
| 'line-color': '#333344', | |
| 'target-arrow-shape': 'none', | |
| 'curve-style': 'bezier', | |
| 'opacity': 0.5 | |
| } | |
| } | |
| ] | |
| # Determine which node to highlight | |
| # Priority: selected_node_id (direct click) takes precedence if it's a candidate | |
| # Otherwise use the timeline-synced decision node | |
| highlight_node_id = None | |
| if selected_node_id and '_cand_' in str(selected_node_id): | |
| # A candidate node was directly clicked - highlight it | |
| highlight_node_id = selected_node_id | |
| highlight_style = { | |
| 'border-color': '#FF8800', | |
| 'border-width': 4, | |
| 'background-color': '#2a1a00', | |
| 'width': 50, | |
| 'height': 40, | |
| 'font-size': '10px', | |
| 'font-weight': 'bold', | |
| 'opacity': 1, | |
| 'z-index': 100 | |
| } | |
| elif node_map: | |
| # Use the timeline index to highlight decision point | |
| highlight_node_id = node_map.get(str(idx)) or node_map.get(idx) | |
| highlight_style = { | |
| 'border-color': '#D4A020', | |
| 'border-width': 5, | |
| 'background-color': '#2a2a00', | |
| 'width': 70, | |
| 'height': 55, | |
| 'font-size': '12px', | |
| 'font-weight': 'bold', | |
| 'z-index': 100 | |
| } | |
| # Add highlight for the selected node | |
| if highlight_node_id: | |
| base_stylesheet.append({ | |
| 'selector': f'[id = "{highlight_node_id}"]', | |
| 'style': highlight_style | |
| }) | |
| return base_stylesheet | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # TIMELINE NAVIGATOR - Granular event traversal | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def navigate_timeline(slider_val, first_clicks, prev_clicks, next_clicks, last_clicks, current_idx, events_cache): | |
| """Handle timeline navigation.""" | |
| from dash import ctx | |
| if not events_cache: | |
| return 0, 0 | |
| max_idx = len(events_cache) - 1 | |
| triggered = ctx.triggered_id | |
| if triggered == 'timeline-slider': | |
| new_idx = min(max(0, slider_val), max_idx) | |
| elif triggered == 'timeline-first-btn': | |
| new_idx = 0 | |
| elif triggered == 'timeline-prev-btn': | |
| new_idx = max(0, current_idx - 1) | |
| elif triggered == 'timeline-next-btn': | |
| new_idx = min(max_idx, current_idx + 1) | |
| elif triggered == 'timeline-last-btn': | |
| new_idx = max_idx | |
| else: | |
| new_idx = current_idx | |
| return new_idx, new_idx | |
| def display_timeline_event(idx, cinematic_active, cinematic_idx, events_cache, cinematic_events): | |
| """Display FULL comprehensive metadata for the selected timeline event.""" | |
| # During cinematic replay, show cinematic frame counter | |
| if cinematic_active and cinematic_events: | |
| total = len(cinematic_events) | |
| frame = min(cinematic_idx + 1, total) | |
| position_text = f"🎬 {frame} / {total}" | |
| return position_text, html.Div() # Empty div for metadata (no longer displayed) | |
| if not events_cache or idx >= len(events_cache): | |
| return "0 / 0", html.Div("No events yet", style={'color': '#555', 'fontStyle': 'italic', 'textAlign': 'center', 'padding': '20px'}) | |
| evt = events_cache[idx] | |
| total = len(events_cache) | |
| position_text = f"{idx + 1} / {total}" | |
| # Extract all event fields | |
| event_type = evt.get('event_type', '?') | |
| event_id = evt.get('event_id', '?') | |
| timestamp = evt.get('timestamp', '?') | |
| component = evt.get('component', '?') | |
| data = evt.get('data', {}) | |
| # Build comprehensive metadata sections | |
| sections = [] | |
| # ═══════════════════════════════════════════════════════════════ | |
| # SECTION 1: Event Identity Header | |
| # ═══════════════════════════════════════════════════════════════ | |
| turn = data.get('turn', 'unknown') | |
| turn_color = CYAN if turn == 'white' else MAGENTA if turn == 'black' else '#888' | |
| turn_symbol = '♔' if turn == 'white' else '♚' if turn == 'black' else '?' | |
| sections.append(html.Div([ | |
| html.Div([ | |
| html.Span(f"{turn_symbol} ", style={'fontSize': '16px'}), | |
| html.Span(f"{event_type.upper()}", style={'color': '#FF6600', 'fontWeight': 'bold', 'fontSize': '13px'}), | |
| html.Span(f" [{turn.upper()}]", style={'color': turn_color, 'fontSize': '11px', 'fontWeight': 'bold'}), | |
| ]), | |
| ], style={'marginBottom': '10px', 'borderBottom': '2px solid #FF660044', 'paddingBottom': '8px'})) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # SECTION 2: Event Identifiers (ID, Timestamp, Component, Merkle) | |
| # ═══════════════════════════════════════════════════════════════ | |
| merkle = data.get('merkle', 'N/A') | |
| sections.append(html.Div([ | |
| html.Div("📋 EVENT IDENTITY", style={'color': '#888', 'fontSize': '9px', 'fontWeight': 'bold', 'marginBottom': '6px'}), | |
| html.Table([ | |
| html.Tr([ | |
| html.Td("Event ID:", style={'color': '#555', 'fontSize': '9px', 'paddingRight': '10px', 'verticalAlign': 'top'}), | |
| html.Td(html.Code(event_id, style={'color': '#4080A0', 'fontSize': '9px', 'backgroundColor': '#0a0a15', | |
| 'padding': '2px 6px', 'borderRadius': '3px', 'wordBreak': 'break-all'})) | |
| ]), | |
| html.Tr([ | |
| html.Td("Timestamp:", style={'color': '#555', 'fontSize': '9px', 'paddingRight': '10px'}), | |
| html.Td(str(timestamp), style={'color': '#888', 'fontSize': '9px'}) | |
| ]), | |
| html.Tr([ | |
| html.Td("Component:", style={'color': '#555', 'fontSize': '9px', 'paddingRight': '10px'}), | |
| html.Td(component, style={'color': '#FF8800', 'fontSize': '9px'}) | |
| ]), | |
| html.Tr([ | |
| html.Td("Merkle:", style={'color': '#555', 'fontSize': '9px', 'paddingRight': '10px', 'verticalAlign': 'top'}), | |
| html.Td(html.Code(merkle[:40] + ('...' if len(str(merkle)) > 40 else ''), | |
| style={'color': '#308040', 'fontSize': '8px', 'backgroundColor': '#0a0a15', | |
| 'padding': '2px 6px', 'borderRadius': '3px', 'wordBreak': 'break-all'})) | |
| ]) | |
| ], style={'borderCollapse': 'collapse', 'width': '100%'}) | |
| ], style={'backgroundColor': '#0a0a12', 'padding': '8px', 'borderRadius': '4px', 'marginBottom': '10px', | |
| 'border': '1px solid #1a1a2e'})) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # SECTION 3: All Candidates Evaluated (Full Decision Matrix) | |
| # ═══════════════════════════════════════════════════════════════ | |
| all_candidates = data.get('all_candidates', []) | |
| if all_candidates: | |
| cand_rows = [] | |
| for i, cand in enumerate(all_candidates): | |
| move = cand.get('move', '?') | |
| score = cand.get('score', 0) | |
| prob = cand.get('prob', 0) | |
| is_capture = cand.get('is_capture', False) | |
| is_check = cand.get('is_check', False) | |
| move_type = cand.get('move_type', 'normal') | |
| # Styling based on rank | |
| if i == 0: | |
| row_bg = '#0a2a0a' | |
| move_color = '#308040' | |
| rank_label = '★ CHOSEN' | |
| elif i < 3: | |
| row_bg = '#1a1400' | |
| move_color = '#FF8800' | |
| rank_label = f'#{i+1}' | |
| else: | |
| row_bg = '#0a0a12' | |
| move_color = '#666' | |
| rank_label = f'#{i+1}' | |
| # Property indicators | |
| props = [] | |
| if is_capture: | |
| props.append(html.Span("⚔", title="Capture", style={'color': '#FF4444', 'marginRight': '3px'})) | |
| if is_check: | |
| props.append(html.Span("♚+", title="Check", style={'color': '#D4A020', 'marginRight': '3px'})) | |
| cand_rows.append(html.Div([ | |
| html.Span(rank_label, style={'color': move_color, 'width': '65px', 'display': 'inline-block', 'fontSize': '9px'}), | |
| html.Span(move, style={'color': move_color, 'fontWeight': 'bold', 'width': '50px', 'display': 'inline-block'}), | |
| html.Span(f"{score:+.0f}cp", style={'color': '#4080A0', 'width': '55px', 'display': 'inline-block', 'fontSize': '9px'}), | |
| html.Span(f"p={prob:.2f}", style={'color': '#555', 'width': '50px', 'display': 'inline-block', 'fontSize': '9px'}), | |
| html.Span(props), | |
| html.Span(f"[{move_type}]", style={'color': '#444', 'fontSize': '8px', 'marginLeft': '5px'}) | |
| ], style={'padding': '4px 8px', 'backgroundColor': row_bg, 'marginBottom': '2px', 'borderRadius': '3px', | |
| 'border': f'1px solid {move_color}22', 'fontSize': '10px'})) | |
| sections.append(html.Div([ | |
| html.Div(f"🎯 ALL CANDIDATES ({len(all_candidates)} moves evaluated)", | |
| style={'color': '#FF6600', 'fontSize': '10px', 'fontWeight': 'bold', 'marginBottom': '6px'}), | |
| html.Div(cand_rows) | |
| ], style={'marginBottom': '10px'})) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # SECTION 4: Imagination (Predicted Opponent Responses) | |
| # ═══════════════════════════════════════════════════════════════ | |
| imagination = data.get('imagination', {}) | |
| if imagination: | |
| imag_rows = [] | |
| for key, imag in imagination.items(): | |
| pred_resp = imag.get('predicted_response', '?') | |
| value = imag.get('value_after_response', 0) | |
| continuation = imag.get('continuation', '') | |
| value_color = '#308040' if value > 0 else '#FF4444' if value < 0 else '#888' | |
| imag_rows.append(html.Div([ | |
| html.Span(f"If #{int(key)+1}: ", style={'color': '#666', 'width': '45px', 'display': 'inline-block', 'fontSize': '9px'}), | |
| html.Span(f"→ {pred_resp}", style={'color': '#904060', 'width': '60px', 'display': 'inline-block'}), | |
| html.Span(f"val={value:+.2f}", style={'color': value_color, 'width': '65px', 'display': 'inline-block', 'fontSize': '9px'}), | |
| html.Span(continuation, style={'color': '#6666FF', 'fontSize': '8px'}) if continuation else None | |
| ], style={'padding': '3px 8px', 'backgroundColor': '#0a0a1a', 'marginBottom': '2px', 'borderRadius': '3px', | |
| 'border': '1px solid #6666FF22'})) | |
| sections.append(html.Div([ | |
| html.Div("🔮 IMAGINATION (opponent response predictions)", | |
| style={'color': '#6666FF', 'fontSize': '10px', 'fontWeight': 'bold', 'marginBottom': '6px'}), | |
| html.Div(imag_rows) | |
| ], style={'marginBottom': '10px'})) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # SECTION 5: Reasoning (AI's Thought Process) | |
| # ═══════════════════════════════════════════════════════════════ | |
| reasoning = data.get('reasoning', {}) | |
| if reasoning: | |
| sections.append(html.Div([ | |
| html.Div("💭 REASONING", style={'color': '#D4A020', 'fontSize': '10px', 'fontWeight': 'bold', 'marginBottom': '6px'}), | |
| html.Pre(json.dumps(reasoning, indent=2), | |
| style={'color': '#888', 'fontSize': '9px', 'backgroundColor': '#050508', | |
| 'padding': '8px', 'borderRadius': '3px', 'margin': '0', 'whiteSpace': 'pre-wrap', | |
| 'border': '1px solid #D4A02033', 'maxHeight': '100px', 'overflowY': 'auto'}) | |
| ], style={'marginBottom': '10px'})) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # SECTION 6: Position (FEN) | |
| # ═══════════════════════════════════════════════════════════════ | |
| fen = data.get('fen', '') | |
| if fen: | |
| sections.append(html.Div([ | |
| html.Div("♟ POSITION (FEN)", style={'color': '#888', 'fontSize': '9px', 'fontWeight': 'bold', 'marginBottom': '4px'}), | |
| html.Code(fen, style={'color': '#555', 'fontSize': '8px', 'wordBreak': 'break-all', | |
| 'display': 'block', 'padding': '6px', 'backgroundColor': '#050508', | |
| 'borderRadius': '3px', 'border': '1px solid #1a1a2e'}) | |
| ], style={'marginBottom': '10px'})) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # SECTION 7: Raw Data (Expandable) | |
| # ═══════════════════════════════════════════════════════════════ | |
| sections.append(html.Details([ | |
| html.Summary("📦 RAW EVENT DATA (click to expand)", | |
| style={'color': '#555', 'cursor': 'pointer', 'fontSize': '9px', 'outline': 'none'}), | |
| html.Pre(json.dumps(data, indent=2, default=str), | |
| style={'color': '#444', 'fontSize': '8px', 'backgroundColor': '#050508', | |
| 'padding': '8px', 'borderRadius': '3px', 'marginTop': '6px', 'whiteSpace': 'pre-wrap', | |
| 'maxHeight': '150px', 'overflowY': 'auto', 'border': '1px solid #222'}) | |
| ], style={'marginTop': '10px'})) | |
| return position_text, html.Div(sections, style={'fontFamily': 'monospace', 'fontSize': '10px'}) | |
| 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) | |