""" 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 # ═══════════════════════════════════════════════════════════════════════════════ @dataclass class MoveCandidate: move: str prob: float value: float from_sq: int to_sq: int is_capture: bool = False is_check: bool = False move_type: str = 'quiet' @dataclass class CascadeTrace: """Represents a cascade-lattice inference trace.""" step: int operation: str inputs: List[str] output: str duration_ms: float confidence: float # Global trace storage TRACE_LOG: List[dict] = [] DECISION_TREE: List[dict] = [] # ═══════════════════════════════════════════════════════════════════════════════ # 3D VISUALIZATION # ═══════════════════════════════════════════════════════════════════════════════ def square_to_3d(square: int): file = square % 8 rank = square // 8 return (file - 3.5, rank - 3.5, 0) def create_board_traces(): """Create 3D board with mesh tiles.""" traces = [] tile_size = 0.48 tile_height = 0.08 for rank in range(8): for file in range(8): cx, cy = file - 3.5, rank - 3.5 is_light = (rank + file) % 2 == 1 color = BOARD_LIGHT if is_light else BOARD_DARK # Top surface of tile traces.append(go.Mesh3d( x=[cx-tile_size, cx+tile_size, cx+tile_size, cx-tile_size], y=[cy-tile_size, cy-tile_size, cy+tile_size, cy+tile_size], z=[tile_height, tile_height, tile_height, tile_height], i=[0, 0], j=[1, 2], k=[2, 3], color=color, opacity=0.95, flatshading=True, hoverinfo='skip', showlegend=False )) # Board frame/border border = 4.3 frame_z = tile_height / 2 traces.append(go.Scatter3d( x=[-border, border, border, -border, -border], y=[-border, -border, border, border, -border], z=[frame_z]*5, mode='lines', line=dict(color=GOLD, width=3), hoverinfo='skip', showlegend=False )) # File labels (a-h) for i, label in enumerate('abcdefgh'): traces.append(go.Scatter3d( x=[i-3.5], y=[-4.6], z=[0.1], mode='text', text=[label], textfont=dict(size=11, color='#666'), showlegend=False )) # Rank labels (1-8) for i in range(8): traces.append(go.Scatter3d( x=[-4.6], y=[i-3.5], z=[0.1], mode='text', text=[str(i+1)], textfont=dict(size=11, color='#666'), showlegend=False )) return traces def create_piece_traces(board: chess.Board): """Create holographic crystal-style chess pieces using rotated point silhouettes.""" traces = [] # Define piece silhouettes as point patterns (normalized 0-1 range) # These trace the outline/shape of each piece type PIECE_SILHOUETTES = { chess.PAWN: [ (0.5, 0.0), (0.3, 0.1), (0.25, 0.2), (0.3, 0.3), (0.35, 0.4), (0.4, 0.5), (0.35, 0.6), (0.3, 0.7), (0.4, 0.8), (0.5, 0.9), (0.6, 0.8), (0.7, 0.7), (0.65, 0.6), (0.6, 0.5), (0.65, 0.4), (0.7, 0.3), (0.75, 0.2), (0.7, 0.1), (0.5, 0.0) ], chess.ROOK: [ (0.25, 0.0), (0.25, 0.15), (0.35, 0.15), (0.35, 0.25), (0.25, 0.25), (0.25, 0.85), (0.35, 0.9), (0.35, 1.0), (0.45, 1.0), (0.45, 0.9), (0.55, 0.9), (0.55, 1.0), (0.65, 1.0), (0.65, 0.9), (0.75, 0.85), (0.75, 0.25), (0.65, 0.25), (0.65, 0.15), (0.75, 0.15), (0.75, 0.0) ], chess.KNIGHT: [ (0.25, 0.0), (0.25, 0.2), (0.3, 0.35), (0.25, 0.5), (0.3, 0.6), (0.35, 0.7), (0.3, 0.8), (0.4, 0.9), (0.5, 1.0), (0.6, 0.95), (0.7, 0.85), (0.75, 0.7), (0.7, 0.55), (0.65, 0.4), (0.7, 0.25), (0.75, 0.15), (0.75, 0.0) ], chess.BISHOP: [ (0.3, 0.0), (0.25, 0.15), (0.3, 0.3), (0.35, 0.45), (0.4, 0.6), (0.35, 0.7), (0.4, 0.8), (0.5, 0.95), (0.6, 0.8), (0.65, 0.7), (0.6, 0.6), (0.65, 0.45), (0.7, 0.3), (0.75, 0.15), (0.7, 0.0) ], chess.QUEEN: [ (0.25, 0.0), (0.2, 0.15), (0.25, 0.3), (0.3, 0.5), (0.25, 0.65), (0.3, 0.75), (0.2, 0.85), (0.35, 0.9), (0.5, 1.0), (0.65, 0.9), (0.8, 0.85), (0.7, 0.75), (0.75, 0.65), (0.7, 0.5), (0.75, 0.3), (0.8, 0.15), (0.75, 0.0) ], chess.KING: [ (0.25, 0.0), (0.2, 0.15), (0.25, 0.35), (0.3, 0.55), (0.35, 0.7), (0.3, 0.8), (0.45, 0.85), (0.45, 0.92), (0.4, 0.92), (0.4, 0.97), (0.5, 1.0), (0.6, 0.97), (0.6, 0.92), (0.55, 0.92), (0.55, 0.85), (0.7, 0.8), (0.65, 0.7), (0.7, 0.55), (0.75, 0.35), (0.8, 0.15), (0.75, 0.0) ], } PIECE_HEIGHT = { chess.PAWN: 0.4, chess.KNIGHT: 0.55, chess.BISHOP: 0.55, chess.ROOK: 0.5, chess.QUEEN: 0.65, chess.KING: 0.7, } NUM_ROTATIONS = 6 # How many rotated planes per piece for sq in chess.SQUARES: piece = board.piece_at(sq) if not piece: continue cx, cy, _ = square_to_3d(sq) silhouette = PIECE_SILHOUETTES[piece.piece_type] height = PIECE_HEIGHT[piece.piece_type] color = '#E8E8E8' if piece.color == chess.WHITE else '#404040' # Create rotated copies of the silhouette for rot in range(NUM_ROTATIONS): angle = (rot / NUM_ROTATIONS) * np.pi # 0 to 180 degrees xs, ys, zs = [], [], [] for px, pz in silhouette: # Offset from center (-0.5 to +0.5 range) local_x = (px - 0.5) * 0.4 # Scale to fit in square local_z = pz * height + 0.1 # Height above board # Rotate around Y (vertical) axis rotated_x = local_x * np.cos(angle) rotated_y = local_x * np.sin(angle) xs.append(cx + rotated_x) ys.append(cy + rotated_y) zs.append(local_z) traces.append(go.Scatter3d( x=xs, y=ys, z=zs, mode='lines', line=dict(color=color, width=2), opacity=0.7, hoverinfo='skip', showlegend=False )) return traces return traces def create_arc(from_sq, to_sq, is_white, num_points=40): """Create smooth arc trajectory with proper curve.""" x1, y1, _ = square_to_3d(from_sq) x2, y2, _ = square_to_3d(to_sq) dist = np.sqrt((x2-x1)**2 + (y2-y1)**2) # Cap height to stay within z bounds - short moves get small arcs, long moves capped height = min(1.8, max(0.8, dist * 0.35)) t = np.linspace(0, 1, num_points) x = x1 + (x2 - x1) * t y = y1 + (y2 - y1) * t # Arc always goes UP (positive z) - offset from board surface z = 0.3 + height * np.sin(np.pi * t) return x, y, z def create_move_arcs(candidates: List[MoveCandidate], is_white: bool): """Create glowing move arc traces.""" traces = [] # Color gradient based on rank if is_white: colors = [CYAN, '#00CCAA', '#009988', '#006666', '#004444'] else: colors = [MAGENTA, '#CC0088', '#990066', '#660044', '#440022'] for i, cand in enumerate(candidates): x, y, z = create_arc(cand.from_sq, cand.to_sq, is_white) color = colors[min(i, len(colors)-1)] width = max(4, cand.prob * 15) opacity = max(0.5, cand.prob * 1.2) # Outer glow (wide, transparent) traces.append(go.Scatter3d( x=x, y=y, z=z, mode='lines', line=dict(color=color, width=width+6), opacity=opacity*0.2, hoverinfo='skip', showlegend=False )) # Mid glow traces.append(go.Scatter3d( x=x, y=y, z=z, mode='lines', line=dict(color=color, width=width+2), opacity=opacity*0.4, hoverinfo='skip', showlegend=False )) # Core line traces.append(go.Scatter3d( x=x, y=y, z=z, mode='lines', line=dict(color=color, width=width), opacity=opacity, name=f"{cand.move} ({cand.prob*100:.0f}%)", hovertemplate=f"{cand.move}
Probability: {cand.prob*100:.1f}%
Eval: {cand.value:+.2f}" )) # Start point (piece origin) traces.append(go.Scatter3d( x=[x[0]], y=[y[0]], z=[z[0]], mode='markers', marker=dict(size=5, color=color, symbol='circle', opacity=opacity), hoverinfo='skip', showlegend=False )) # End point (destination) - larger, prominent traces.append(go.Scatter3d( x=[x[-1]], y=[y[-1]], z=[z[-1]], mode='markers', marker=dict(size=8, color=color, symbol='diamond', line=dict(color='white', width=1)), hoverinfo='skip', showlegend=False )) # Drop line to board (shows landing square) x2, y2, _ = square_to_3d(cand.to_sq) traces.append(go.Scatter3d( x=[x2, x2], y=[y2, y2], z=[z[-1], 0.15], mode='lines', line=dict(color=color, width=2, dash='dot'), opacity=opacity*0.5, hoverinfo='skip', showlegend=False )) return traces # ═══════════════════════════════════════════════════════════════════════════════ # TACTICAL VISUALIZATION # ═══════════════════════════════════════════════════════════════════════════════ def get_piece_attacks(board: chess.Board, square: int) -> List[int]: """Get squares attacked by piece at given square.""" piece = board.piece_at(square) if not piece: return [] return list(board.attacks(square)) def get_attackers(board: chess.Board, square: int, color: bool) -> List[int]: """Get pieces of given color attacking a square.""" return list(board.attackers(color, square)) def create_threat_beams(board: chess.Board): """Create downward beams showing attack vectors - underneath the board.""" traces = [] for sq in chess.SQUARES: piece = board.piece_at(sq) if not piece: continue attacks = get_piece_attacks(board, sq) if not attacks: continue px, py, _ = square_to_3d(sq) color = 'rgba(255,215,0,0.15)' if piece.color == chess.WHITE else 'rgba(220,20,60,0.15)' for target_sq in attacks: tx, ty, _ = square_to_3d(target_sq) # Beam goes from piece position DOWN through board to show threat zone traces.append(go.Scatter3d( x=[px, tx], y=[py, ty], z=[-0.05, -0.2], mode='lines', line=dict(color=color, width=1), hoverinfo='skip', showlegend=False )) return traces def create_tension_lines(board: chess.Board): """Create lines between pieces that threaten each other (mutual tension).""" traces = [] tension_pairs = set() for sq in chess.SQUARES: piece = board.piece_at(sq) if not piece: continue # Check if this piece attacks any enemy pieces for target_sq in board.attacks(sq): target_piece = board.piece_at(target_sq) if target_piece and target_piece.color != piece.color: # Create unique pair key pair = tuple(sorted([sq, target_sq])) if pair not in tension_pairs: tension_pairs.add(pair) x1, y1, _ = square_to_3d(sq) x2, y2, _ = square_to_3d(target_sq) # Check if it's mutual (both threaten each other) mutual = target_sq in board.attacks(sq) and sq in board.attacks(target_sq) if mutual: color = MAGENTA width = 3 opacity = 0.6 else: color = CRIMSON if piece.color == chess.BLACK else GOLD width = 2 opacity = 0.4 # Tension line slightly above board traces.append(go.Scatter3d( x=[x1, x2], y=[y1, y2], z=[0.8, 0.8], mode='lines', line=dict(color=color, width=width), opacity=opacity, hoverinfo='skip', showlegend=False )) return traces def create_hanging_indicators(board: chess.Board): """Mark pieces that are hanging (attacked but not defended).""" traces = [] for sq in chess.SQUARES: piece = board.piece_at(sq) if not piece: continue # Count attackers and defenders enemy_color = not piece.color attackers = len(board.attackers(enemy_color, sq)) defenders = len(board.attackers(piece.color, sq)) if attackers > 0 and defenders == 0: # Hanging! Draw danger indicator x, y, _ = square_to_3d(sq) # Pulsing ring under the piece theta = np.linspace(0, 2*np.pi, 20) ring_x = x + 0.3 * np.cos(theta) ring_y = y + 0.3 * np.sin(theta) ring_z = [0.05] * len(theta) traces.append(go.Scatter3d( x=ring_x, y=ring_y, z=ring_z, mode='lines', line=dict(color=CRIMSON, width=4), opacity=0.8, hoverinfo='skip', showlegend=False )) # Exclamation point above traces.append(go.Scatter3d( x=[x], y=[y], z=[1.0], mode='text', text=['⚠'], textfont=dict(size=16, color=CRIMSON), hoverinfo='skip', showlegend=False )) return traces def create_king_safety(board: chess.Board): """Create safety dome around kings showing their safety status.""" traces = [] for color in [chess.WHITE, chess.BLACK]: king_sq = board.king(color) if king_sq is None: continue kx, ky, _ = square_to_3d(king_sq) # Count attackers around king zone danger_level = 0 for sq in chess.SQUARES: file_dist = abs(chess.square_file(sq) - chess.square_file(king_sq)) rank_dist = abs(chess.square_rank(sq) - chess.square_rank(king_sq)) if file_dist <= 1 and rank_dist <= 1: # King zone enemy_attackers = len(board.attackers(not color, sq)) danger_level += enemy_attackers # Dome color based on safety if board.is_check() and board.turn == color: dome_color = CRIMSON opacity = 0.5 elif danger_level > 5: dome_color = '#FF6600' # Orange - danger opacity = 0.3 elif danger_level > 2: dome_color = GOLD # Yellow - caution opacity = 0.2 else: dome_color = '#00FF00' # Green - safe opacity = 0.15 # Draw dome as circles at different heights for h in [0.3, 0.5, 0.7]: radius = 0.6 * (1 - h/1.5) # Smaller at top theta = np.linspace(0, 2*np.pi, 24) dome_x = kx + radius * np.cos(theta) dome_y = ky + radius * np.sin(theta) dome_z = [h] * len(theta) traces.append(go.Scatter3d( x=dome_x, y=dome_y, z=dome_z, mode='lines', line=dict(color=dome_color, width=2), opacity=opacity, hoverinfo='skip', showlegend=False )) return traces def create_candidate_origins(board: chess.Board, candidates: List[MoveCandidate]): """Highlight pieces that are generating the top candidate moves.""" traces = [] if not candidates: return traces # Collect unique origin squares from candidates origins = {} for i, cand in enumerate(candidates): sq = cand.from_sq if sq not in origins: origins[sq] = i # Store best rank for sq, rank in origins.items(): x, y, _ = square_to_3d(sq) # Intensity based on rank (top candidate = brightest) intensity = 1.0 - (rank * 0.15) color = f'rgba(0,255,212,{intensity * 0.6})' # Cyan glow # Glow ring around the piece theta = np.linspace(0, 2*np.pi, 20) ring_x = x + 0.35 * np.cos(theta) ring_y = y + 0.35 * np.sin(theta) ring_z = [0.08] * len(theta) traces.append(go.Scatter3d( x=ring_x, y=ring_y, z=ring_z, mode='lines', line=dict(color=color, width=3 + (5-rank)), hoverinfo='skip', showlegend=False )) return traces # ═══════════════════════════════════════════════════════════════════════════════ # STATE-SPACE LATTICE GRAPH # ═══════════════════════════════════════════════════════════════════════════════ def create_state_lattice(move_history: List[str], candidates: List[MoveCandidate] = None): """ Create a state-space graph visualization above/below the board. Each move node is positioned ABOVE its destination square. White moves above board, black moves below. Edges trace the flow of the game through space. """ traces = [] if not move_history or len(move_history) < 1: return traces # Z heights - white moves float above, black sink below BASE_Z_WHITE = 2.2 BASE_Z_BLACK = -0.3 Z_LAYER_STEP = 0.25 # Each subsequent move goes higher/lower # Track all nodes for edge drawing all_nodes = [] # List of (x, y, z, move_uci, is_white, move_idx) for i, move_uci in enumerate(move_history): is_white = (i % 2 == 0) move_num = i // 2 + 1 same_color_idx = i // 2 # How many moves of this color so far # Parse the UCI move to get destination square if len(move_uci) >= 4: to_sq_name = move_uci[2:4] # e.g., "e4" from "e2e4" try: to_sq = chess.parse_square(to_sq_name) # Get 3D position of destination square x, y, _ = square_to_3d(to_sq) except: x, y = 0, 0 else: x, y = 0, 0 # Z position - stacks up/down with each move if is_white: z = BASE_Z_WHITE + (same_color_idx * Z_LAYER_STEP) node_color = '#FFFEF0' edge_color = 'rgba(255,254,240,0.6)' glow_color = 'rgba(0,255,212,0.4)' # Cyan glow else: z = BASE_Z_BLACK - (same_color_idx * Z_LAYER_STEP) node_color = '#AAAAAA' edge_color = 'rgba(150,150,150,0.6)' glow_color = 'rgba(255,100,150,0.4)' # Pink glow all_nodes.append((x, y, z, move_uci, is_white, i)) # Draw vertical "drop line" from node to board board_z = 0.1 traces.append(go.Scatter3d( x=[x, x], y=[y, y], z=[z, board_z if is_white else board_z], mode='lines', line=dict(color=glow_color, width=1), hoverinfo='skip', showlegend=False )) # Node marker traces.append(go.Scatter3d( x=[x], y=[y], z=[z], mode='markers+text', marker=dict(size=10, color=node_color, line=dict(width=2, color=glow_color), symbol='diamond'), text=[f"{move_num}.{move_uci}" if is_white else move_uci], textposition='top center', textfont=dict(size=9, color=node_color, family='monospace'), hoverinfo='text', hovertext=f"{'White' if is_white else 'Black'} {move_num}: {move_uci}", showlegend=False )) # Draw edges connecting sequential moves (alternating colors) for i in range(len(all_nodes) - 1): x0, y0, z0, _, _, _ = all_nodes[i] x1, y1, z1, _, is_white_next, _ = all_nodes[i + 1] # Color based on who just moved (the edge leads TO the next move) edge_color = 'rgba(0,255,212,0.4)' if is_white_next else 'rgba(255,100,150,0.4)' traces.append(go.Scatter3d( x=[x0, x1], y=[y0, y1], z=[z0, z1], mode='lines', line=dict(color=edge_color, width=2), hoverinfo='skip', showlegend=False )) # Show candidate moves as potential branches from last position if candidates and all_nodes: last_x, last_y, last_z, _, last_white, _ = all_nodes[-1] is_white_turn = len(move_history) % 2 == 0 # Who moves next branch_z = (BASE_Z_WHITE + (len(move_history)//2) * Z_LAYER_STEP) if is_white_turn \ else (BASE_Z_BLACK - (len(move_history)//2) * Z_LAYER_STEP) for j, cand in enumerate(candidates[:5]): # Position at candidate's destination if len(cand.move) >= 4: try: cand_to_sq = chess.parse_square(cand.move[2:4]) cx, cy, _ = square_to_3d(cand_to_sq) except: continue else: continue cz = branch_z # Branch edge from last move to candidate traces.append(go.Scatter3d( x=[last_x, cx], y=[last_y, cy], z=[last_z, cz], mode='lines', line=dict(color='rgba(255,215,0,0.3)', width=1, dash='dot'), hoverinfo='skip', showlegend=False )) # Candidate node traces.append(go.Scatter3d( x=[cx], y=[cy], z=[cz], mode='markers+text', marker=dict(size=6, color=GOLD, opacity=0.5, symbol='diamond'), text=[cand.move], textposition='top center', textfont=dict(size=7, color='rgba(255,215,0,0.6)', family='monospace'), hoverinfo='text', hovertext=f"Candidate: {cand.move} ({cand.cp}cp)", showlegend=False )) return traces # Default camera position DEFAULT_CAMERA = dict( eye=dict(x=0.8, y=-1.4, z=0.9), up=dict(x=0, y=0, z=1), center=dict(x=0, y=0, z=0) ) def create_figure(board: chess.Board, candidates: List[MoveCandidate] = None, camera: dict = None): """Create the full 3D figure with polished visuals.""" fig = go.Figure() # Use provided camera or default cam = camera if camera else DEFAULT_CAMERA # Layer 1: Board for trace in create_board_traces(): fig.add_trace(trace) # Layer 2: Pieces (crystal style) for trace in create_piece_traces(board): fig.add_trace(trace) # Layer 3: Move arcs (when HOLD active) if candidates: # Highlight candidate origin pieces for trace in create_candidate_origins(board, candidates): fig.add_trace(trace) is_white = board.turn == chess.WHITE for trace in create_move_arcs(candidates, is_white): fig.add_trace(trace) # Turn indicator turn_text = "⚪ WHITE" if board.turn else "⚫ BLACK" turn_color = '#EEE' if board.turn else '#888' fig.add_trace(go.Scatter3d( x=[0], y=[5.2], z=[0.3], mode='text', text=[turn_text], textfont=dict(size=14, color=turn_color, family='monospace'), showlegend=False, hoverinfo='skip' )) # Move number fig.add_trace(go.Scatter3d( x=[0], y=[-5.2], z=[0.3], mode='text', text=[f"Move {board.fullmove_number}"], textfont=dict(size=11, color='#666', family='monospace'), showlegend=False, hoverinfo='skip' )) fig.update_layout( scene=dict( xaxis=dict(range=[-5.5, 5.5], showgrid=False, showbackground=False, showticklabels=False, showline=False, zeroline=False, title=''), yaxis=dict(range=[-5.5, 5.5], showgrid=False, showbackground=False, showticklabels=False, showline=False, zeroline=False, title=''), zaxis=dict(range=[-0.5, 3.0], showgrid=False, showbackground=False, showticklabels=False, showline=False, zeroline=False, title=''), aspectmode='manual', aspectratio=dict(x=1, y=1, z=0.35), camera=cam, bgcolor=BG_COLOR ), paper_bgcolor=BG_COLOR, plot_bgcolor=BG_COLOR, margin=dict(l=0, r=0, t=0, b=0), showlegend=False, height=650 ) return fig # ═══════════════════════════════════════════════════════════════════════════════ # ENGINE + CASCADE HELPERS # ═══════════════════════════════════════════════════════════════════════════════ import time 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 = ''' {%metas%} CASCADE // LATTICE - Chess Inference Visualization {%favicon%} {%css%} {%app_entry%} ''' # ═══════════════════════════════════════════════════════════════════════════════ # FLASK API: Expose CausationGraph data # ═══════════════════════════════════════════════════════════════════════════════ from flask import jsonify @server.route('/api/causation') 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': {}}) @server.route('/api/hold') 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 @callback( Output('page-initialized', 'data'), Input('url', 'pathname'), State('page-initialized', 'data'), ) 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 @callback( Output('board-fen', 'data'), Output('candidates-store', 'data'), Output('trace-store', 'data'), Output('decision-store', 'data'), Output('hold-data-store', 'data'), Output('is-held', 'data'), Output('move-history', 'data'), Output('auto-play', 'data'), Output('black-thinking', 'data'), Output('black-candidates', 'data'), Output('black-chosen-move', 'data'), Output('selected-candidate', 'data'), Output('hold-refresh-trigger', 'data'), Output('auto-thinking', 'data'), Output('auto-pending-move', 'data'), Input('btn-step', 'n_clicks'), Input('btn-hold', 'n_clicks'), Input('btn-reset', 'n_clicks'), Input('btn-auto', 'n_clicks'), Input('auto-interval', 'n_intervals'), Input('auto-visual-interval', 'n_intervals'), State('board-fen', 'data'), State('candidates-store', 'data'), State('is-held', 'data'), State('move-history', 'data'), State('auto-play', 'data'), State('selected-candidate', 'data'), State('hold-refresh-trigger', 'data'), State('auto-thinking', 'data'), State('auto-pending-move', 'data'), prevent_initial_call=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 @callback( Output('human-move-candidate', 'data'), Output('human-move-input', 'value'), Output('human-move-input', 'style'), Input('btn-add-human-move', 'n_clicks'), State('human-move-input', 'value'), State('board-fen', 'data'), prevent_initial_call=True ) 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 @callback( Output('candidates-store', 'data', allow_duplicate=True), Input('human-move-candidate', 'data'), State('candidates-store', 'data'), State('is-held', 'data'), prevent_initial_call=True ) 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 @callback( Output('scene-state', 'children'), Output('loading-output', 'children'), Input('board-fen', 'data'), Input('candidates-store', 'data'), Input('selected-candidate', 'data') ) 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 ) @callback( Output('btn-hold', 'children'), Output('btn-hold', 'style'), Input('is-held', 'data'), Input('cinematic-active', 'data') ) 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}'} @callback( Output('status', 'children'), Input('board-fen', 'data'), Input('is-held', 'data'), Input('auto-play', 'data'), Input('auto-thinking', 'data'), Input('move-history', 'data'), Input('cinematic-active', 'data'), Input('cinematic-index', 'data'), State('cinematic-events', 'data') ) 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}" @callback( Output('auto-interval', 'disabled'), Input('auto-play', 'data'), Input('auto-thinking', 'data') ) 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 @callback( Output('auto-visual-interval', 'disabled'), Input('auto-thinking', 'data') ) def toggle_visual_interval(auto_thinking): # Enable visual interval only during thinking phase (to trigger commit after delay) return not auto_thinking @callback( Output('header-turn', 'children'), Output('header-turn', 'style'), Output('header-movenum', 'children'), Output('header-phase', 'children'), Output('header-phase', 'style'), Input('board-fen', 'data') ) def update_game_state(fen): board = chess.Board(fen) # Turn turn_text = "White" if board.turn else "Black" turn_style = {'color': '#FFF', '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 # ═══════════════════════════════════════════════════════════════════════════════ @callback( Output('cascade-trace', 'children'), Input('hold-refresh-trigger', 'data'), Input('trace-store', 'data'), State('selected-candidate', 'data') ) 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 @callback( Output('decision-tree', 'children'), Input('hold-refresh-trigger', 'data'), Input('decision-store', 'data'), State('selected-candidate', 'data') ) 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 @callback( Output('metric-latency', 'children'), Output('metric-candidates', 'children'), Output('metric-confidence', 'children'), Input('trace-store', 'data'), Input('decision-store', 'data') ) def update_metrics(trace_data, decision_data): if not trace_data: return "--ms", "0", "--%" total_latency = sum(t['duration'] for t in trace_data) num_candidates = len(decision_data) if decision_data else 0 confidence = decision_data[0]['prob'] * 100 if decision_data else 0 return f"{total_latency:.1f}ms", str(num_candidates), f"{confidence:.0f}%" @callback( Output('move-buttons', 'children'), Input('candidates-store', 'data'), Input('is-held', 'data'), Input('selected-candidate', 'data') ) 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 @callback( Output('selected-candidate', 'data', allow_duplicate=True), Input({'type': 'move-btn', 'index': dash.ALL}, 'n_clicks'), prevent_initial_call=True ) 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 @callback( Output('board-fen', 'data', allow_duplicate=True), Output('candidates-store', 'data', allow_duplicate=True), Output('is-held', 'data', allow_duplicate=True), Output('move-history', 'data', allow_duplicate=True), Output('selected-candidate', 'data', allow_duplicate=True), Output('trace-store', 'data', allow_duplicate=True), Output('decision-store', 'data', allow_duplicate=True), Output('hold-data-store', 'data', allow_duplicate=True), Output('hold-refresh-trigger', 'data', allow_duplicate=True), Input('btn-commit', 'n_clicks'), State('board-fen', 'data'), State('candidates-store', 'data'), State('selected-candidate', 'data'), State('move-history', 'data'), State('hold-data-store', 'data'), State('hold-refresh-trigger', 'data'), prevent_initial_call=True ) 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 @callback( Output('btn-commit', 'style'), Input('is-held', 'data') ) 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 @callback( Output('selected-move-info', 'children'), Input('selected-candidate', 'data'), Input('candidates-store', 'data'), Input('is-held', 'data') ) 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 @callback( Output('wealth-panel', 'children'), Input('hold-refresh-trigger', 'data'), Input('hold-data-store', 'data'), Input('is-held', 'data'), State('selected-candidate', 'data') ) 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 @callback( Output('merkle-display', 'children'), Input('hold-data-store', 'data'), Input('is-held', 'data') ) 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 @callback( Output('causation-cytoscape', 'elements'), Output('causation-stats', 'children'), Output('timeline-events-cache', 'data'), Output('timeline-slider', 'max'), Output('timeline-slider', 'marks'), Output('graph-node-map', 'data'), Input('board-fen', 'data'), Input('move-history', 'data') ) 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 @callback( Output('timeline-index', 'data', allow_duplicate=True), Output('timeline-slider', 'value', allow_duplicate=True), Output('timeline-event-metadata', 'children', allow_duplicate=True), Output('selected-graph-node', 'data'), # TIME TRAVEL OUTPUTS - restore full state Output('board-fen', 'data', allow_duplicate=True), Output('candidates-store', 'data', allow_duplicate=True), Output('trace-store', 'data', allow_duplicate=True), Output('decision-store', 'data', allow_duplicate=True), Output('hold-data-store', 'data', allow_duplicate=True), Output('is-held', 'data', allow_duplicate=True), Output('hold-refresh-trigger', 'data', allow_duplicate=True), Input('causation-cytoscape', 'tapNodeData'), State('graph-node-map', 'data'), State('timeline-events-cache', 'data'), State('hold-refresh-trigger', 'data'), prevent_initial_call=True ) 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 # ═══════════════════════════════════════════════════════════════════════════════ @callback( Output('cinematic-active', 'data'), Output('cinematic-index', 'data', allow_duplicate=True), Output('cinematic-events', 'data'), Output('cinematic-replay-btn', 'children'), Output('cinematic-replay-btn', 'style'), Output('cinematic-camera-cmd', 'data', allow_duplicate=True), Input('cinematic-replay-btn', 'n_clicks'), State('cinematic-active', 'data'), State('timeline-events-cache', 'data'), prevent_initial_call=True ) 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 @callback( Output('cinematic-interval', 'disabled'), Input('cinematic-active', 'data') ) def toggle_cinematic_interval(is_active): """Enable/disable the cinematic interval based on active state.""" return not is_active @callback( Output('cinematic-index', 'data', allow_duplicate=True), Output('board-fen', 'data', allow_duplicate=True), Output('candidates-store', 'data', allow_duplicate=True), Output('trace-store', 'data', allow_duplicate=True), Output('decision-store', 'data', allow_duplicate=True), Output('hold-data-store', 'data', allow_duplicate=True), Output('is-held', 'data', allow_duplicate=True), Output('hold-refresh-trigger', 'data', allow_duplicate=True), Output('timeline-index', 'data', allow_duplicate=True), Output('timeline-slider', 'value', allow_duplicate=True), Output('timeline-event-metadata', 'children', allow_duplicate=True), Output('cinematic-active', 'data', allow_duplicate=True), Output('cinematic-replay-btn', 'children', allow_duplicate=True), Output('cinematic-replay-btn', 'style', allow_duplicate=True), Output('cinematic-camera-cmd', 'data', allow_duplicate=True), Input('cinematic-interval', 'n_intervals'), State('cinematic-active', 'data'), State('cinematic-index', 'data'), State('cinematic-events', 'data'), State('hold-refresh-trigger', 'data'), prevent_initial_call=True ) 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 @callback( Output('causation-cytoscape', 'stylesheet'), Input('timeline-index', 'data'), Input('selected-graph-node', 'data'), State('graph-node-map', 'data'), prevent_initial_call=True ) 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 # ═══════════════════════════════════════════════════════════════════════════════ @callback( Output('timeline-index', 'data'), Output('timeline-slider', 'value'), Input('timeline-slider', 'value'), Input('timeline-first-btn', 'n_clicks'), Input('timeline-prev-btn', 'n_clicks'), Input('timeline-next-btn', 'n_clicks'), Input('timeline-last-btn', 'n_clicks'), State('timeline-index', 'data'), State('timeline-events-cache', 'data'), prevent_initial_call=True ) 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 @callback( Output('timeline-position', 'children'), Output('timeline-event-metadata', 'children'), Input('timeline-index', 'data'), Input('cinematic-active', 'data'), Input('cinematic-index', 'data'), State('timeline-events-cache', 'data'), State('cinematic-events', 'data') ) 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)