Cascade-Lattice-Chess / src /app_threejs.py
tostido's picture
Fix double-turn bug: add POSITION_LOCK for race condition, try/finally in extract_features, FEN-based turn detection
e013a14
"""
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"<b>{cand.move}</b><br>Probability: {cand.prob*100:.1f}%<br>Eval: {cand.value:+.2f}<extra></extra>"
))
# Start point (piece origin)
traces.append(go.Scatter3d(
x=[x[0]], y=[y[0]], z=[z[0]], mode='markers',
marker=dict(size=5, color=color, symbol='circle', opacity=opacity),
hoverinfo='skip', showlegend=False
))
# End point (destination) - larger, prominent
traces.append(go.Scatter3d(
x=[x[-1]], y=[y[-1]], z=[z[-1]], mode='markers',
marker=dict(size=8, color=color, symbol='diamond',
line=dict(color='white', width=1)),
hoverinfo='skip', showlegend=False
))
# Drop line to board (shows landing square)
x2, y2, _ = square_to_3d(cand.to_sq)
traces.append(go.Scatter3d(
x=[x2, x2], y=[y2, y2], z=[z[-1], 0.15],
mode='lines', line=dict(color=color, width=2, dash='dot'),
opacity=opacity*0.5, hoverinfo='skip', showlegend=False
))
return traces
# ═══════════════════════════════════════════════════════════════════════════════
# TACTICAL VISUALIZATION
# ═══════════════════════════════════════════════════════════════════════════════
def get_piece_attacks(board: chess.Board, square: int) -> List[int]:
"""Get squares attacked by piece at given square."""
piece = board.piece_at(square)
if not piece:
return []
return list(board.attacks(square))
def get_attackers(board: chess.Board, square: int, color: bool) -> List[int]:
"""Get pieces of given color attacking a square."""
return list(board.attackers(color, square))
def create_threat_beams(board: chess.Board):
"""Create downward beams showing attack vectors - underneath the board."""
traces = []
for sq in chess.SQUARES:
piece = board.piece_at(sq)
if not piece:
continue
attacks = get_piece_attacks(board, sq)
if not attacks:
continue
px, py, _ = square_to_3d(sq)
color = 'rgba(255,215,0,0.15)' if piece.color == chess.WHITE else 'rgba(220,20,60,0.15)'
for target_sq in attacks:
tx, ty, _ = square_to_3d(target_sq)
# Beam goes from piece position DOWN through board to show threat zone
traces.append(go.Scatter3d(
x=[px, tx], y=[py, ty], z=[-0.05, -0.2],
mode='lines', line=dict(color=color, width=1),
hoverinfo='skip', showlegend=False
))
return traces
def create_tension_lines(board: chess.Board):
"""Create lines between pieces that threaten each other (mutual tension)."""
traces = []
tension_pairs = set()
for sq in chess.SQUARES:
piece = board.piece_at(sq)
if not piece:
continue
# Check if this piece attacks any enemy pieces
for target_sq in board.attacks(sq):
target_piece = board.piece_at(target_sq)
if target_piece and target_piece.color != piece.color:
# Create unique pair key
pair = tuple(sorted([sq, target_sq]))
if pair not in tension_pairs:
tension_pairs.add(pair)
x1, y1, _ = square_to_3d(sq)
x2, y2, _ = square_to_3d(target_sq)
# Check if it's mutual (both threaten each other)
mutual = target_sq in board.attacks(sq) and sq in board.attacks(target_sq)
if mutual:
color = MAGENTA
width = 3
opacity = 0.6
else:
color = CRIMSON if piece.color == chess.BLACK else GOLD
width = 2
opacity = 0.4
# Tension line slightly above board
traces.append(go.Scatter3d(
x=[x1, x2], y=[y1, y2], z=[0.8, 0.8],
mode='lines', line=dict(color=color, width=width),
opacity=opacity, hoverinfo='skip', showlegend=False
))
return traces
def create_hanging_indicators(board: chess.Board):
"""Mark pieces that are hanging (attacked but not defended)."""
traces = []
for sq in chess.SQUARES:
piece = board.piece_at(sq)
if not piece:
continue
# Count attackers and defenders
enemy_color = not piece.color
attackers = len(board.attackers(enemy_color, sq))
defenders = len(board.attackers(piece.color, sq))
if attackers > 0 and defenders == 0:
# Hanging! Draw danger indicator
x, y, _ = square_to_3d(sq)
# Pulsing ring under the piece
theta = np.linspace(0, 2*np.pi, 20)
ring_x = x + 0.3 * np.cos(theta)
ring_y = y + 0.3 * np.sin(theta)
ring_z = [0.05] * len(theta)
traces.append(go.Scatter3d(
x=ring_x, y=ring_y, z=ring_z,
mode='lines', line=dict(color=CRIMSON, width=4),
opacity=0.8, hoverinfo='skip', showlegend=False
))
# Exclamation point above
traces.append(go.Scatter3d(
x=[x], y=[y], z=[1.0],
mode='text', text=['⚠'],
textfont=dict(size=16, color=CRIMSON),
hoverinfo='skip', showlegend=False
))
return traces
def create_king_safety(board: chess.Board):
"""Create safety dome around kings showing their safety status."""
traces = []
for color in [chess.WHITE, chess.BLACK]:
king_sq = board.king(color)
if king_sq is None:
continue
kx, ky, _ = square_to_3d(king_sq)
# Count attackers around king zone
danger_level = 0
for sq in chess.SQUARES:
file_dist = abs(chess.square_file(sq) - chess.square_file(king_sq))
rank_dist = abs(chess.square_rank(sq) - chess.square_rank(king_sq))
if file_dist <= 1 and rank_dist <= 1: # King zone
enemy_attackers = len(board.attackers(not color, sq))
danger_level += enemy_attackers
# Dome color based on safety
if board.is_check() and board.turn == color:
dome_color = CRIMSON
opacity = 0.5
elif danger_level > 5:
dome_color = '#FF6600' # Orange - danger
opacity = 0.3
elif danger_level > 2:
dome_color = GOLD # Yellow - caution
opacity = 0.2
else:
dome_color = '#00FF00' # Green - safe
opacity = 0.15
# Draw dome as circles at different heights
for h in [0.3, 0.5, 0.7]:
radius = 0.6 * (1 - h/1.5) # Smaller at top
theta = np.linspace(0, 2*np.pi, 24)
dome_x = kx + radius * np.cos(theta)
dome_y = ky + radius * np.sin(theta)
dome_z = [h] * len(theta)
traces.append(go.Scatter3d(
x=dome_x, y=dome_y, z=dome_z,
mode='lines', line=dict(color=dome_color, width=2),
opacity=opacity, hoverinfo='skip', showlegend=False
))
return traces
def create_candidate_origins(board: chess.Board, candidates: List[MoveCandidate]):
"""Highlight pieces that are generating the top candidate moves."""
traces = []
if not candidates:
return traces
# Collect unique origin squares from candidates
origins = {}
for i, cand in enumerate(candidates):
sq = cand.from_sq
if sq not in origins:
origins[sq] = i # Store best rank
for sq, rank in origins.items():
x, y, _ = square_to_3d(sq)
# Intensity based on rank (top candidate = brightest)
intensity = 1.0 - (rank * 0.15)
color = f'rgba(0,255,212,{intensity * 0.6})' # Cyan glow
# Glow ring around the piece
theta = np.linspace(0, 2*np.pi, 20)
ring_x = x + 0.35 * np.cos(theta)
ring_y = y + 0.35 * np.sin(theta)
ring_z = [0.08] * len(theta)
traces.append(go.Scatter3d(
x=ring_x, y=ring_y, z=ring_z,
mode='lines', line=dict(color=color, width=3 + (5-rank)),
hoverinfo='skip', showlegend=False
))
return traces
# ═══════════════════════════════════════════════════════════════════════════════
# STATE-SPACE LATTICE GRAPH
# ═══════════════════════════════════════════════════════════════════════════════
def create_state_lattice(move_history: List[str], candidates: List[MoveCandidate] = None):
"""
Create a state-space graph visualization above/below the board.
Each move node is positioned ABOVE its destination square.
White moves above board, black moves below.
Edges trace the flow of the game through space.
"""
traces = []
if not move_history or len(move_history) < 1:
return traces
# Z heights - white moves float above, black sink below
BASE_Z_WHITE = 2.2
BASE_Z_BLACK = -0.3
Z_LAYER_STEP = 0.25 # Each subsequent move goes higher/lower
# Track all nodes for edge drawing
all_nodes = [] # List of (x, y, z, move_uci, is_white, move_idx)
for i, move_uci in enumerate(move_history):
is_white = (i % 2 == 0)
move_num = i // 2 + 1
same_color_idx = i // 2 # How many moves of this color so far
# Parse the UCI move to get destination square
if len(move_uci) >= 4:
to_sq_name = move_uci[2:4] # e.g., "e4" from "e2e4"
try:
to_sq = chess.parse_square(to_sq_name)
# Get 3D position of destination square
x, y, _ = square_to_3d(to_sq)
except:
x, y = 0, 0
else:
x, y = 0, 0
# Z position - stacks up/down with each move
if is_white:
z = BASE_Z_WHITE + (same_color_idx * Z_LAYER_STEP)
node_color = '#FFFEF0'
edge_color = 'rgba(255,254,240,0.6)'
glow_color = 'rgba(0,255,212,0.4)' # Cyan glow
else:
z = BASE_Z_BLACK - (same_color_idx * Z_LAYER_STEP)
node_color = '#AAAAAA'
edge_color = 'rgba(150,150,150,0.6)'
glow_color = 'rgba(255,100,150,0.4)' # Pink glow
all_nodes.append((x, y, z, move_uci, is_white, i))
# Draw vertical "drop line" from node to board
board_z = 0.1
traces.append(go.Scatter3d(
x=[x, x], y=[y, y], z=[z, board_z if is_white else board_z],
mode='lines',
line=dict(color=glow_color, width=1),
hoverinfo='skip', showlegend=False
))
# Node marker
traces.append(go.Scatter3d(
x=[x], y=[y], z=[z],
mode='markers+text',
marker=dict(size=10, color=node_color,
line=dict(width=2, color=glow_color),
symbol='diamond'),
text=[f"{move_num}.{move_uci}" if is_white else move_uci],
textposition='top center',
textfont=dict(size=9, color=node_color, family='monospace'),
hoverinfo='text',
hovertext=f"{'White' if is_white else 'Black'} {move_num}: {move_uci}",
showlegend=False
))
# Draw edges connecting sequential moves (alternating colors)
for i in range(len(all_nodes) - 1):
x0, y0, z0, _, _, _ = all_nodes[i]
x1, y1, z1, _, is_white_next, _ = all_nodes[i + 1]
# Color based on who just moved (the edge leads TO the next move)
edge_color = 'rgba(0,255,212,0.4)' if is_white_next else 'rgba(255,100,150,0.4)'
traces.append(go.Scatter3d(
x=[x0, x1], y=[y0, y1], z=[z0, z1],
mode='lines',
line=dict(color=edge_color, width=2),
hoverinfo='skip', showlegend=False
))
# Show candidate moves as potential branches from last position
if candidates and all_nodes:
last_x, last_y, last_z, _, last_white, _ = all_nodes[-1]
is_white_turn = len(move_history) % 2 == 0 # Who moves next
branch_z = (BASE_Z_WHITE + (len(move_history)//2) * Z_LAYER_STEP) if is_white_turn \
else (BASE_Z_BLACK - (len(move_history)//2) * Z_LAYER_STEP)
for j, cand in enumerate(candidates[:5]):
# Position at candidate's destination
if len(cand.move) >= 4:
try:
cand_to_sq = chess.parse_square(cand.move[2:4])
cx, cy, _ = square_to_3d(cand_to_sq)
except:
continue
else:
continue
cz = branch_z
# Branch edge from last move to candidate
traces.append(go.Scatter3d(
x=[last_x, cx], y=[last_y, cy], z=[last_z, cz],
mode='lines',
line=dict(color='rgba(255,215,0,0.3)', width=1, dash='dot'),
hoverinfo='skip', showlegend=False
))
# Candidate node
traces.append(go.Scatter3d(
x=[cx], y=[cy], z=[cz],
mode='markers+text',
marker=dict(size=6, color=GOLD, opacity=0.5,
symbol='diamond'),
text=[cand.move],
textposition='top center',
textfont=dict(size=7, color='rgba(255,215,0,0.6)', family='monospace'),
hoverinfo='text',
hovertext=f"Candidate: {cand.move} ({cand.cp}cp)",
showlegend=False
))
return traces
# Default camera position
DEFAULT_CAMERA = dict(
eye=dict(x=0.8, y=-1.4, z=0.9),
up=dict(x=0, y=0, z=1),
center=dict(x=0, y=0, z=0)
)
def create_figure(board: chess.Board, candidates: List[MoveCandidate] = None, camera: dict = None):
"""Create the full 3D figure with polished visuals."""
fig = go.Figure()
# Use provided camera or default
cam = camera if camera else DEFAULT_CAMERA
# Layer 1: Board
for trace in create_board_traces():
fig.add_trace(trace)
# Layer 2: Pieces (crystal style)
for trace in create_piece_traces(board):
fig.add_trace(trace)
# Layer 3: Move arcs (when HOLD active)
if candidates:
# Highlight candidate origin pieces
for trace in create_candidate_origins(board, candidates):
fig.add_trace(trace)
is_white = board.turn == chess.WHITE
for trace in create_move_arcs(candidates, is_white):
fig.add_trace(trace)
# Turn indicator
turn_text = "⚪ WHITE" if board.turn else "⚫ BLACK"
turn_color = '#EEE' if board.turn else '#888'
fig.add_trace(go.Scatter3d(
x=[0], y=[5.2], z=[0.3], mode='text', text=[turn_text],
textfont=dict(size=14, color=turn_color, family='monospace'),
showlegend=False, hoverinfo='skip'
))
# Move number
fig.add_trace(go.Scatter3d(
x=[0], y=[-5.2], z=[0.3], mode='text',
text=[f"Move {board.fullmove_number}"],
textfont=dict(size=11, color='#666', family='monospace'),
showlegend=False, hoverinfo='skip'
))
fig.update_layout(
scene=dict(
xaxis=dict(range=[-5.5, 5.5], showgrid=False, showbackground=False,
showticklabels=False, showline=False, zeroline=False, title=''),
yaxis=dict(range=[-5.5, 5.5], showgrid=False, showbackground=False,
showticklabels=False, showline=False, zeroline=False, title=''),
zaxis=dict(range=[-0.5, 3.0], showgrid=False, showbackground=False,
showticklabels=False, showline=False, zeroline=False, title=''),
aspectmode='manual',
aspectratio=dict(x=1, y=1, z=0.35),
camera=cam,
bgcolor=BG_COLOR
),
paper_bgcolor=BG_COLOR,
plot_bgcolor=BG_COLOR,
margin=dict(l=0, r=0, t=0, b=0),
showlegend=False,
height=650
)
return fig
# ═══════════════════════════════════════════════════════════════════════════════
# ENGINE + CASCADE HELPERS
# ═══════════════════════════════════════════════════════════════════════════════
import time
import numpy as np
def extract_features(board: chess.Board) -> dict:
"""Extract chess features for cascade-lattice."""
# Material count
material = 0
for piece_type in [chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN]:
material += len(board.pieces(piece_type, chess.WHITE)) - len(board.pieces(piece_type, chess.BLACK))
# Center control (d4, d5, e4, e5)
center_squares = [chess.D4, chess.D5, chess.E4, chess.E5]
white_center = sum(1 for sq in center_squares if board.is_attacked_by(chess.WHITE, sq))
black_center = sum(1 for sq in center_squares if board.is_attacked_by(chess.BLACK, sq))
# King safety (attackers near king)
wk = board.king(chess.WHITE)
bk = board.king(chess.BLACK)
white_king_danger = len(board.attackers(chess.BLACK, wk)) if wk else 0
black_king_danger = len(board.attackers(chess.WHITE, bk)) if bk else 0
# Mobility - save and restore turn properly with try/finally for safety
original_turn = board.turn
try:
board.turn = chess.WHITE
white_mobility = len(list(board.legal_moves))
board.turn = chess.BLACK
black_mobility = len(list(board.legal_moves))
finally:
board.turn = original_turn # Always restore even if exception
return {
'material': material,
'center_control': (white_center - black_center) / 4.0,
'white_king_danger': white_king_danger,
'black_king_danger': black_king_danger,
'white_mobility': white_mobility,
'black_mobility': black_mobility,
'phase': 'opening' if board.fullmove_number < 10 else ('middlegame' if board.fullmove_number < 30 else 'endgame')
}
def get_candidates_with_trace(board: chess.Board, num=5) -> tuple:
"""Get move candidates from Stockfish WITH cascade-lattice integration."""
global TRACE_LOG, DECISION_TREE, ENGINE
trace_data = []
decision_data = []
hold_data = {} # Rich data from cascade Hold
start_time = time.perf_counter()
# Use lock for all engine operations
with ENGINE_LOCK:
if not ENGINE:
ENGINE = restart_engine()
if not ENGINE:
return [], trace_data, decision_data, hold_data
try:
# TRACE: Board state encoding
t0 = time.perf_counter()
fen = board.fen()
fen_turn = 'WHITE' if ' w ' in fen else 'BLACK'
print(f"[GET_CANDIDATES] Called for {fen_turn} position: {fen[:50]}...")
features = extract_features(board)
trace_data.append({
'step': 1, 'op': 'ENCODE', 'detail': f'FEN → tensor',
'input': fen[:20] + '...', 'output': 'state_vec[768]',
'duration': round((time.perf_counter() - t0) * 1000, 2),
'confidence': 1.0
})
# TRACE: Engine analysis
t1 = time.perf_counter()
turn_before = "WHITE" if board.turn else "BLACK"
info = ENGINE.analyse(board, chess.engine.Limit(depth=12), multipv=num)
trace_data.append({
'step': 2, 'op': 'ANALYZE', 'detail': f'Stockfish depth=12',
'input': 'state_vec', 'output': f'{len(info)} candidates',
'duration': round((time.perf_counter() - t1) * 1000, 2),
'confidence': 0.95
})
candidates = []
action_labels = []
raw_cp_scores = [] # Raw centipawn scores from engine
# TRACE: Candidate scoring - observe engine output directly
t2 = time.perf_counter()
for i, pv in enumerate(info):
move = pv['pv'][0]
score = pv.get('score', chess.engine.Cp(0))
if score.is_mate():
cp = 10000 if score.mate() > 0 else -10000
eval_str = f"M{score.mate()}"
else:
cp = score.relative.score(mate_score=10000)
eval_str = f"{cp:+d}cp"
raw_cp_scores.append(cp)
action_labels.append(move.uci())
# Move type classification
move_type = 'quiet'
if board.is_capture(move):
move_type = 'capture'
elif board.gives_check(move):
move_type = 'check'
elif board.is_castling(move):
move_type = 'castle'
cand = MoveCandidate(
move=move.uci(), prob=0, value=cp, # Store raw cp as value
from_sq=move.from_square, to_sq=move.to_square,
is_capture=board.is_capture(move),
is_check=board.gives_check(move),
move_type=move_type
)
candidates.append(cand)
# Observe engine's raw output - normalize cp scores for arc visualization only
# This is just for visual scaling, not fake probabilities
cp_arr = np.array(raw_cp_scores)
cp_min, cp_max = cp_arr.min(), cp_arr.max()
cp_range = max(cp_max - cp_min, 1) # Avoid div by zero
for i, c in enumerate(candidates):
# Scale for visualization: best move = 1.0, others proportionally less
c.prob = float((raw_cp_scores[i] - cp_min) / cp_range) if cp_range > 0 else 1.0
decision_data.append({
'move': c.move,
'eval': f"{raw_cp_scores[i]:+d}cp", # Show actual centipawn
'prob': c.prob, # Visual scale factor
'cp': raw_cp_scores[i], # Raw centipawn for display
'rank': i + 1,
'capture': c.is_capture,
'check': c.is_check,
'move_type': c.move_type,
'selected': i == 0
})
trace_data.append({
'step': 3, 'op': 'OBSERVE', 'detail': f'Engine evaluation (raw cp)',
'input': f'{len(candidates)} moves', 'output': f'best={raw_cp_scores[0]:+d}cp',
'duration': round((time.perf_counter() - t2) * 1000, 2),
'confidence': 0.88
})
# CASCADE HOLD INTEGRATION
t3 = time.perf_counter()
if HOLD and candidates:
# Build imagination (predicted responses)
imagination = {}
for i, c in enumerate(candidates[:3]): # Top 3
test_board = board.copy()
test_board.push(chess.Move.from_uci(c.move))
if not test_board.is_game_over():
try:
response_info = ENGINE.analyse(test_board, chess.engine.Limit(depth=8), multipv=1)
if response_info:
resp_move = response_info[0]['pv'][0].uci()
resp_score = response_info[0].get('score', chess.engine.Cp(0))
if resp_score.is_mate():
resp_val = -1.0 if resp_score.mate() > 0 else 1.0
else:
cp = resp_score.relative.score(mate_score=10000)
resp_val = -max(-1, min(1, cp / 1000)) # Flip for opponent
imagination[i] = {
'predicted_response': resp_move,
'value_after_response': resp_val,
'continuation': f"{c.move}{resp_move}"
}
except:
pass
# Build reasoning
reasoning = []
if candidates[0].is_capture:
reasoning.append("Top move captures material")
if candidates[0].is_check:
reasoning.append("Top move gives check")
if features['material'] > 2:
reasoning.append("White has material advantage")
elif features['material'] < -2:
reasoning.append("Black has material advantage")
if features['center_control'] > 0.5:
reasoning.append("Strong center control")
if features[f"{'white' if board.turn else 'black'}_king_danger"] > 0:
reasoning.append("King under attack - defensive needed")
if len(candidates) > 1 and abs(candidates[0].value - candidates[1].value) < 0.1:
reasoning.append("Multiple strong options available")
# Store hold data for UI - use raw centipawn, not fake probabilities
hold_data = {
'action_probs': [c.prob for c in candidates], # Visual scale
'action_labels': action_labels,
'value': float(candidates[0].value), # Best cp score
'observation': {'fen': fen, 'turn': 'white' if board.turn else 'black'},
'features': features,
'reasoning': reasoning,
'imagination': imagination,
'ai_choice': 0,
'ai_confidence': float(candidates[0].prob), # Visual scale of best move
'merkle': None
}
# ═══════════════════════════════════════════════════════════════
# CASCADE-LATTICE: Create actual Hold yield point
# ═══════════════════════════════════════════════════════════════
if HOLD and CASCADE_AVAILABLE:
try:
# Convert probs to numpy array for yield_point
action_probs_np = np.array([c.prob for c in candidates])
# Call yield_point in NON-BLOCKING mode
# We handle the UI/resolution ourselves via Dash
resolution = HOLD.yield_point(
action_probs=action_probs_np,
value=float(candidates[0].value),
observation={'fen': fen, 'turn': 'white' if board.turn else 'black'},
brain_id='stockfish-17',
action_labels=action_labels,
features=features,
imagination=imagination,
reasoning=reasoning,
blocking=False # Don't block - Dash handles UI
)
# Get merkle from the current hold point
if HOLD.current_hold:
hold_data['merkle'] = HOLD.current_hold.merkle_root
print(f"[CASCADE] yield_point created: {hold_data['merkle'][:16]}...")
elif resolution:
hold_data['merkle'] = resolution.merkle_root
print(f"[CASCADE] yield_point (non-blocking): {hold_data['merkle'][:16]}...")
# ═══════════════════════════════════════════════════════════════
# CAUSATION GRAPH: Record FULL decision matrix
# Only record if we haven't already recorded this position
# Use lock to prevent race condition in check-then-add
# ═══════════════════════════════════════════════════════════════
global RECORDED_POSITIONS
if CAUSATION is not None and CausalEvent is not None and hold_data.get('merkle'):
with POSITION_LOCK:
# Check if we've already recorded this position (atomic with add)
if fen in RECORDED_POSITIONS:
print(f"[CASCADE] Skipping duplicate yield_point for position (already recorded)")
else:
RECORDED_POSITIONS.add(fen) # Mark IMMEDIATELY to prevent race
import time as time_mod
# Capture ALL candidates with their evaluations
all_candidates = []
for i, c in enumerate(candidates):
# Parse from/to squares from UCI move notation
move_uci = c.move
from_sq_name = move_uci[:2] if len(move_uci) >= 4 else None
to_sq_name = move_uci[2:4] if len(move_uci) >= 4 else None
# Convert to numeric indices (0-63) for Three.js camera
try:
from_sq_idx = chess.parse_square(from_sq_name) if from_sq_name else 0
to_sq_idx = chess.parse_square(to_sq_name) if to_sq_name else 0
except:
from_sq_idx = 0
to_sq_idx = 0
# Try to get piece from the board
try:
piece_at = board.piece_at(from_sq_idx) if from_sq_name else None
piece_symbol = piece_at.symbol().upper() if piece_at else '?'
except:
piece_symbol = '?'
all_candidates.append({
'move': c.move,
'score': c.value,
'prob': c.prob,
'rank': i + 1,
'is_capture': c.is_capture,
'is_check': c.is_check,
'move_type': c.move_type,
'from_sq': from_sq_idx, # Numeric index 0-63
'to_sq': to_sq_idx, # Numeric index 0-63
'piece': piece_symbol
})
# Get turn from FEN directly for consistency (not board.turn which could be modified)
fen_turn = 'white' if ' w ' in fen else 'black'
event = CausalEvent(
timestamp=time_mod.time(),
component="inference",
event_type="yield_point",
data={
'fen': fen,
'turn': fen_turn,
'top_move': candidates[0].move if candidates else None,
'top_score': candidates[0].value if candidates else None,
'num_candidates': len(candidates),
'merkle': hold_data['merkle'],
'brain_id': 'stockfish-17',
# FULL DECISION MATRIX
'all_candidates': all_candidates,
'imagination': imagination,
'reasoning': reasoning,
# FULL STATE FOR TIME TRAVEL
'trace_data': trace_data, # Full trace for this position
'decision_data': decision_data, # Full decision tree
'hold_data': hold_data, # Full wealth data
},
event_id=f"inference_{hold_data['merkle'][:8]}"
)
CAUSATION.add_event(event)
print(f"[CASCADE] CausationGraph: recorded {fen_turn} inference with {len(all_candidates)} candidates")
except Exception as e:
print(f"[CASCADE] yield_point error: {e}")
import traceback
traceback.print_exc()
merkle_short = hold_data.get('merkle', 'N/A')[:12] + '...' if hold_data.get('merkle') else 'pending'
trace_data.append({
'step': 4, 'op': 'HOLD', 'detail': f'cascade.Hold yield_point()',
'input': 'wealth + imagination', 'output': f'merkle: {merkle_short}',
'duration': round((time.perf_counter() - t3) * 1000, 2),
'confidence': candidates[0].prob if candidates else 0
})
# TRACE: Ready for inspection
total_time = (time.perf_counter() - start_time) * 1000
trace_data.append({
'step': 5, 'op': 'YIELD', 'detail': f'Awaiting human decision',
'input': f'{len(candidates)} candidates',
'output': '⏸ HELD',
'duration': round(total_time, 2),
'confidence': 1.0
})
TRACE_LOG = trace_data
DECISION_TREE = decision_data
return candidates, trace_data, decision_data, hold_data
except chess.engine.EngineTerminatedError:
print("[ENGINE] Crashed - restarting...")
ENGINE = restart_engine()
return [], [], [], {}
except Exception as e:
print(f"[ENGINE] Error: {e}")
import traceback
traceback.print_exc()
return [], [], [], {}
def get_candidates(board: chess.Board, num=5) -> List[MoveCandidate]:
"""Simple wrapper for backward compat."""
candidates, _, _, _ = get_candidates_with_trace(board, num)
return candidates
# ═══════════════════════════════════════════════════════════════════════════════
# DASH APP - TWO PANEL LAYOUT
# ═══════════════════════════════════════════════════════════════════════════════
app = dash.Dash(__name__, suppress_callback_exceptions=True)
server = app.server # Flask server for custom routes
# Set viewport meta tag for mobile responsiveness
app.index_string = '''
<!DOCTYPE html>
<html>
<head>
{%metas%}
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CASCADE // LATTICE - Chess Inference Visualization</title>
{%favicon%}
{%css%}
<style>
/* Mobile-responsive CSS */
@media (max-width: 1100px) {
/* Tablet: stack panels above/below board */
.main-layout { flex-direction: column !important; align-items: center !important; }
.main-layout > div { flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; }
/* Left/right panels become horizontal rows */
.left-panels, .right-panels {
display: flex !important;
flex-direction: row !important;
gap: 8px !important;
max-width: 100% !important;
margin-bottom: 10px !important;
}
.left-panels > div, .right-panels > div {
flex: 1 !important;
min-height: 180px !important;
margin-bottom: 0 !important;
}
/* Chess board full width */
.board-column { flex: none !important; width: 100% !important; max-width: 100% !important; order: -1 !important; }
.board-column iframe { height: 420px !important; }
}
@media (max-width: 900px) {
/* Small tablet / large phone */
.left-panels, .right-panels {
flex-direction: column !important;
}
.left-panels > div, .right-panels > div {
min-height: 150px !important;
}
.board-column iframe { height: 380px !important; }
/* Causation graph section */
.causation-section { margin: 0 10px 10px 10px !important; }
.causation-content { flex-direction: column !important; }
.causation-content > div { min-width: 100% !important; margin-right: 0 !important; margin-bottom: 10px !important; flex: none !important; }
.causation-content .graph-container { min-width: 100% !important; }
/* Controls wrap on mobile */
.controls-bar { flex-wrap: wrap !important; padding: 8px !important; }
.controls-bar button { padding: 10px 16px !important; margin: 4px !important; font-size: 12px !important; }
.human-input-group { margin-left: 0 !important; margin-top: 8px !important; padding-left: 0 !important; border-left: none !important; width: 100% !important; justify-content: center !important; }
/* Header badges wrap */
.badge-row { gap: 5px !important; }
.badge-row > div { padding: 6px 10px !important; font-size: 10px !important; }
/* Title smaller on mobile */
.main-title { font-size: 1.3em !important; }
.title-row .subtitle { display: none !important; }
/* Timeline controls compact */
.timeline-header { flex-direction: column !important; gap: 8px !important; }
.timeline-controls button { padding: 6px 10px !important; }
}
@media (max-width: 600px) {
/* Phone screens */
.board-column iframe { height: 300px !important; }
.main-title { font-size: 1.1em !important; letter-spacing: 0.05em !important; }
.badge-row > div { padding: 5px 8px !important; }
.badge-row { justify-content: center !important; }
.controls-bar button { padding: 8px 12px !important; font-size: 11px !important; min-width: 55px !important; }
/* Stack all panels vertically */
.left-panels, .right-panels {
flex-direction: column !important;
}
.left-panels > div, .right-panels > div {
min-height: 120px !important;
}
/* Smaller panel text */
.left-panels, .right-panels { font-size: 10px !important; }
}
@media (max-width: 400px) {
/* Very small phones */
.board-column iframe { height: 260px !important; }
.main-title { font-size: 0.95em !important; }
.badge-row > div { display: none !important; }
.badge-row > div:first-child { display: flex !important; } /* Only show ENGINE badge */
}
/* Cinematic replay pulse animation */
@keyframes pulse {
0% { opacity: 1; box-shadow: 0 0 5px #FF4444; }
50% { opacity: 0.7; box-shadow: 0 0 20px #FF4444; }
100% { opacity: 1; box-shadow: 0 0 5px #FF4444; }
}
/* Time travel glow effect */
@keyframes timetravel-glow {
0% { box-shadow: 0 0 5px #2090B0; }
50% { box-shadow: 0 0 25px #2090B0, 0 0 50px #2090B044; }
100% { box-shadow: 0 0 5px #2090B0; }
}
</style>
</head>
<body>
{%app_entry%}
<footer>
{%config%}
{%scripts%}
{%renderer%}
</footer>
</body>
</html>
'''
# ═══════════════════════════════════════════════════════════════════════════════
# FLASK API: Expose CausationGraph data
# ═══════════════════════════════════════════════════════════════════════════════
from flask import jsonify
@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)