import numpy as np
from scipy.io.wavfile import read
from dataclasses import dataclass, field
from typing import Dict, List, Tuple, Optional, Callable
from enum import Enum
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)


# =============================================================================
# ERROR TAXONOMY & LOSS REGISTRY
# =============================================================================

class LossCategory(Enum):
    """Categories of diagnostic losses."""
    SPATIAL = "spatial"
    SPECTRAL = "spectral"
    STATISTICAL = "statistical"
    COMPONENT = "component"
    PATTERN = "pattern"
    ADVERSARIAL = "adversarial"
    REGULARIZATION = "regularization"


@dataclass
class LossConfig:
    """Configuration for a single loss function."""
    name: str
    category: LossCategory
    weight: float = 1.0
    enabled: bool = True
    target: Optional[float] = None  # Target value (e.g., target entropy)
    thresholds: Tuple[float, float] = (0.0, float('inf'))  # Min/max acceptable
    

@dataclass
class LossResult:
    """Result from computing a loss function."""
    name: str
    value: float
    gradient: np.ndarray
    diagnostics: Dict  # Additional diagnostic info
    category: LossCategory
    weight: float
    normalized_value: float  # Value normalized to [0, 1] scale


@dataclass
class MultiLossState:
    """State tracking for multi-loss optimization."""
    losses: Dict[str, LossResult]
    total_loss: float
    weighted_contributions: Dict[str, float]
    dominant_loss: str
    gradient_norms: Dict[str, float]
    optimization_advice: List[str]


# =============================================================================
# BASE LOSS FUNCTIONS
# =============================================================================

class BaseLossFunction:
    """Base class for all loss functions derived from diagnostics."""
    
    def __init__(self, config: LossConfig):
        self.config = config
        self.history = []
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        """Compute loss and gradient."""
        raise NotImplementedError
    
    def get_gradient(self, X: np.ndarray, gradient: np.ndarray) -> np.ndarray:
        """Compute gradient with respect to input."""
        raise NotImplementedError


class MSELoss(BaseLossFunction):
    """Baseline mean squared error loss."""

    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        flat_error = error.ravel()
        loss_value = float(np.mean(flat_error ** 2))
        gradient = (2.0 / max(flat_error.size, 1)) * error

        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient,
            diagnostics={'rmse': float(np.sqrt(loss_value))},
            category=self.config.category,
            weight=self.config.weight,
            normalized_value=min(loss_value, 1.0)
        )


# =============================================================================
# SPATIAL LOSSES
# =============================================================================

class SpatialConcentrationLoss(BaseLossFunction):
    """
    Loss based on spatial concentration of errors.
    
    Encourages errors to be spread uniformly (like good convolution outputs).
    Penalizes clustered errors which indicate model focusing too much
    on specific regions.
    
    Loss = concentration_score (higher = worse)
    """
    
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        
        # Compute local variance vs global variance
        local_windows = []
        window_size = 5
        for y in range(0, 28 - window_size, window_size):
            for x in range(0, 28 - window_size, window_size):
                window = error_2d[y:y+window_size, x:x+window_size]
                local_windows.append(np.var(window))
        
        local_var = np.mean(local_windows) if local_windows else 0
        global_var = np.var(error_2d) + 1e-8
        
        # Concentration: local variance should match global variance
        concentration = local_var / global_var
        
        # Gradient: where is concentration high?
        grad_y = np.gradient(error_2d, axis=0)
        grad_x = np.gradient(error_2d, axis=1)
        gradient_magnitude = np.sqrt(grad_y**2 + grad_x**2)
        
        # Higher loss when concentration is high (errors clustered)
        loss_value = concentration if concentration > 1.0 else 0.0
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient_magnitude.ravel()[:len(error)],
            diagnostics={'concentration': concentration, 'local_var': local_var, 'global_var': global_var},
            category=LossCategory.SPATIAL,
            weight=self.config.weight,
            normalized_value=min(concentration, 2.0) / 2.0
        )
    
    def get_gradient(self, X: np.ndarray, output_grad: np.ndarray) -> np.ndarray:
        return output_grad


class SpatialEntropyLoss(BaseLossFunction):
    """
    Loss based on spatial entropy of error distribution.
    
    Encourages uniform error distribution (high entropy).
    Penalizes focused/predictable error patterns.
    
    Target: Maximum entropy = uniform distribution
    """
    
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        
        # Compute probability distribution of error magnitudes
        error_mag = np.abs(error_2d).ravel()
        error_mag = error_mag / (np.sum(error_mag) + 1e-8)
        
        # Entropy
        entropy = -np.sum(error_mag * np.log(error_mag + 1e-8))
        max_entropy = np.log(len(error_mag))
        
        # Normalized entropy (0-1)
        normalized_entropy = entropy / (max_entropy + 1e-8)
        
        # Loss: penalize low entropy (predictable errors)
        # We want high entropy, so loss = 1 - normalized_entropy
        loss_value = 1.0 - normalized_entropy
        
        # Gradient: increases loss for concentrated high-error regions
        gradient = np.abs(error_2d) * (1 - normalized_entropy)
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={'entropy': entropy, 'max_entropy': max_entropy, 'normalized': normalized_entropy},
            category=LossCategory.SPATIAL,
            weight=self.config.weight,
            normalized_value=loss_value
        )


class HotspotPenaltyLoss(BaseLossFunction):
    """
    Loss that specifically penalizes error hotspots.
    
    Identifies top-k highest error regions and applies
    additional penalty to reduce them.
    
    Inspired by attention mechanism - focus correction
    on worst regions.
    """
    
    def __init__(self, config: LossConfig, n_hotspots: int = 5, penalty_scale: float = 2.0):
        super().__init__(config)
        self.n_hotspots = n_hotspots
        self.penalty_scale = penalty_scale
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        error_size = error.size
        
        # Find hotspots (top-k highest error positions)
        flat_error = np.abs(error_2d).ravel()
        threshold = np.percentile(flat_error, 100 * (1 - self.n_hotspots / len(flat_error)))
        hotspot_mask = flat_error >= threshold
        
        # Compute hotspot statistics
        n_hotspots_actual = np.sum(hotspot_mask)
        hotspot_error = flat_error[hotspot_mask]
        mean_hotspot_error = np.mean(hotspot_error) if n_hotspots_actual > 0 else 0
        max_hotspot_error = np.max(flat_error) if n_hotspots_actual > 0 else 0
        
        # Loss: weighted sum of hotspot errors
        # Higher penalty for larger errors
        loss_value = np.sum(hotspot_error ** self.penalty_scale) / (n_hotspots_actual + 1)
        
        # Gradient: focus on hotspots
        hotspot_gradient = np.zeros_like(flat_error)
        hotspot_gradient[hotspot_mask] = hotspot_error * self.penalty_scale
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=hotspot_gradient[:error_size].reshape(error.shape),
            diagnostics={
                'n_hotspots': n_hotspots_actual,
                'mean_hotspot_error': mean_hotspot_error,
                'max_hotspot_error': max_hotspot_error,
                'threshold': threshold
            },
            category=LossCategory.SPATIAL,
            weight=self.config.weight,
            normalized_value=min(loss_value / 10.0, 1.0)
        )


# =============================================================================
# SPECTRAL LOSSES
# =============================================================================

class SpectralBandLoss(BaseLossFunction):
    """
    Loss based on error energy in different frequency bands.
    
    Allows separate control over:
    - Low frequency (overall structure)
    - Mid frequency (shapes and patterns)
    - High frequency (edges and details)
    
    Each band can have different target weights.
    """
    
    def __init__(self, config: LossConfig, band: str = 'high', target_ratio: float = 0.0):
        super().__init__(config)
        self.band = band  # 'low', 'mid', 'high'
        self.target_ratio = target_ratio  # Target ratio of this band to total
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        
        # FFT
        fft = np.fft.fft2(error_2d)
        fft_shift = np.fft.fftshift(fft)
        magnitude = np.abs(fft_shift)
        
        # Radial frequency bands
        H, W = 28, 28
        center_y, center_x = H // 2, W // 2
        y, x = np.ogrid[:H, :W]
        distance = np.sqrt((y - center_y)**2 + (x - center_x)**2)
        max_dist = np.sqrt(center_y**2 + center_x**2)
        
        # Define bands
        if self.band == 'low':
            mask = distance < max_dist * 0.25
        elif self.band == 'mid':
            mask = (distance >= max_dist * 0.25) & (distance < max_dist * 0.75)
        else:  # high
            mask = distance >= max_dist * 0.75
        
        # Energy in this band
        band_energy = np.sum(magnitude[mask] ** 2)
        total_energy = np.sum(magnitude ** 2) + 1e-8
        ratio = band_energy / total_energy
        
        # Loss: difference from target ratio
        loss_value = abs(ratio - self.target_ratio)
        
        # Gradient: directions to adjust this band
        # Create gradient mask
        grad_mask = np.zeros_like(magnitude)
        if self.band == 'high':
            # Gradient pushes energy outward
            grad_mask[mask] = magnitude[mask] * np.sign(ratio - self.target_ratio)
        elif self.band == 'low':
            # Gradient pushes energy inward
            grad_mask[mask] = magnitude[mask] * np.sign(self.target_ratio - ratio)
        else:
            grad_mask[mask] = magnitude[mask] * np.sign(ratio - self.target_ratio)
        
        # Convert back to spatial domain gradient
        grad_fft = np.fft.ifftshift(grad_mask)
        grad_spatial = np.real(np.fft.ifft2(grad_fft))
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=grad_spatial.ravel()[:len(error)],
            diagnostics={
                'band': self.band,
                'energy_ratio': ratio,
                'target_ratio': self.target_ratio,
                'band_energy': band_energy,
                'total_energy': total_energy
            },
            category=LossCategory.SPECTRAL,
            weight=self.config.weight,
            normalized_value=min(loss_value, 1.0)
        )


class SpectralSkewnessLoss(BaseLossFunction):
    """
    Loss based on spectral skewness.
    
    Penalizes asymmetric frequency distributions which indicate
    systematic bias in certain directions/frequencies.
    """
    
    def __init__(self, config: LossConfig, target_skewness: float = 0.0):
        super().__init__(config)
        self.target_skewness = target_skewness
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        
        # FFT
        fft = np.fft.fft2(error_2d)
        fft_shift = np.fft.fftshift(fft)
        magnitude = np.abs(fft_shift)
        
        # Skewness of spectral distribution
        mean_mag = np.mean(magnitude)
        std_mag = np.std(magnitude) + 1e-8
        
        # Asymmetry in different directions
        # Horizontal skewness
        h_mean = np.mean(magnitude, axis=0)
        h_skew = np.mean(((h_mean - np.mean(h_mean)) / std_mag) ** 3)
        
        # Vertical skewness
        v_mean = np.mean(magnitude, axis=1)
        v_skew = np.mean(((v_mean - np.mean(v_mean)) / std_mag) ** 3)
        
        # Combined skewness
        skewness = (abs(h_skew) + abs(v_skew)) / 2
        
        # Loss
        loss_value = abs(skewness - self.target_skewness)
        
        # Gradient based on directional bias
        grad = error_2d * np.sign(h_skew)  # Directional correction
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=grad.ravel()[:len(error)],
            diagnostics={
                'h_skew': h_skew,
                'v_skew': v_skew,
                'combined_skew': skewness,
                'target': self.target_skewness
            },
            category=LossCategory.SPECTRAL,
            weight=self.config.weight,
            normalized_value=min(skewness, 2.0) / 2.0
        )


# =============================================================================
# STATISTICAL LOSSES
# =============================================================================

class MeanErrorLoss(BaseLossFunction):
    """
    Loss based on mean error (bias correction).
    
    Penalizes non-zero mean error which indicates
    systematic over/under-estimation.
    
    Target: mean ≈ 0
    """
    
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        mean_error = np.mean(error)
        
        # Loss: absolute mean
        loss_value = abs(mean_error)
        
        # Gradient: uniform (constant) adjustment
        gradient = np.ones_like(error) * np.sign(mean_error)
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={'mean': mean_error, 'std': np.std(error)},
            category=LossCategory.STATISTICAL,
            weight=self.config.weight,
            normalized_value=min(abs(mean_error) * 10, 1.0)
        )


class SkewnessLoss(BaseLossFunction):
    """
    Loss based on error distribution skewness.
    
    Encourages symmetric error distribution.
    Penalizes right-skewed (many small + few large positive errors)
    or left-skewed (many small + few large negative errors) distributions.
    """
    
    def __init__(self, config: LossConfig, target_skew: float = 0.0):
        super().__init__(config)
        self.target_skew = target_skew
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_flat = error.ravel()
        
        mean = np.mean(error_flat)
        std = np.std(error_flat) + 1e-8
        
        skewness = np.mean(((error_flat - mean) / std) ** 3)
        
        # Loss: deviation from target skewness
        loss_value = abs(skewness - self.target_skew)
        
        # Gradient: push extreme values toward center
        standardized = (error_flat - mean) / std
        gradient = standardized ** 2 * np.sign(skewness - self.target_skew)
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={'skewness': skewness, 'target': self.target_skew},
            category=LossCategory.STATISTICAL,
            weight=self.config.weight,
            normalized_value=min(abs(skewness), 3.0) / 3.0
        )


class KurtosisLoss(BaseLossFunction):
    """
    Loss based on error distribution kurtosis.
    
    Encourages normal-like kurtosis (≈3).
    Penalizes heavy tails (>3) or light tails (<3).
    
    Heavy tails indicate outliers - important for robust learning.
    """
    
    def __init__(self, config: LossConfig, target_kurtosis: float = 3.0):
        super().__init__(config)
        self.target_kurtosis = target_kurtosis
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_flat = error.ravel()
        
        mean = np.mean(error_flat)
        std = np.std(error_flat) + 1e-8
        
        kurtosis = np.mean(((error_flat - mean) / std) ** 4)
        
        # Loss: deviation from target kurtosis
        loss_value = abs(kurtosis - self.target_kurtosis)
        
        # Gradient: emphasize tail behavior
        standardized = (error_flat - mean) / std
        gradient = standardized ** 3 * np.sign(kurtosis - self.target_kurtosis)
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={'kurtosis': kurtosis, 'target': self.target_kurtosis},
            category=LossCategory.STATISTICAL,
            weight=self.config.weight,
            normalized_value=min(abs(kurtosis - 3) / 5, 1.0)
        )


class BimodalityLoss(BaseLossFunction):
    """
    Loss to detect and penalize bimodal error distributions.
    
    Bimodal errors indicate the model is making two different
    types of mistakes - suggests a mixture of failure modes.
    
    Target: unimodal distribution
    """
    
    def __init__(self, config: LossConfig, n_bins: int = 20):
        super().__init__(config)
        self.n_bins = n_bins
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_flat = error.ravel()
        
        # Compute histogram
        hist, bin_edges = np.histogram(error_flat, bins=self.n_bins, density=True)
        
        # Find peaks
        peaks = []
        for i in range(1, len(hist) - 1):
            if hist[i] > hist[i-1] and hist[i] > hist[i+1]:
                peaks.append((i, hist[i]))
        
        # Check for bimodality
        if len(peaks) >= 2:
            # Two dominant peaks
            peaks.sort(key=lambda x: x[1], reverse=True)
            peak1, peak2 = peaks[0], peaks[1]
            
            # Valley between peaks
            valley_start = min(peak1[0], peak2[0])
            valley_end = max(peak1[0], peak2[0])
            valley_height = np.min(hist[valley_start:valley_end+1])
            
            # Bimodality coefficient
            peak_heights = (peak1[1] + peak2[1]) / 2
            bimodality_score = 1.0 - (valley_height / (peak_heights + 1e-8))
        else:
            bimodality_score = 0.0
        
        # Loss
        loss_value = max(0, bimodality_score - 0.3)  # Allow some bimodality
        
        # Gradient: push valley up, push peaks down
        gradient = np.zeros_like(error_flat)
        bin_idx = np.digitize(error_flat, bin_edges) - 1
        bin_idx = np.clip(bin_idx, 0, self.n_bins - 1)
        
        for b in range(self.n_bins):
            mask = bin_idx == b
            if len(peaks) >= 2 and valley_start <= b <= valley_end:
                gradient[mask] = 1.0  # Push valley up
            elif len(peaks) >= 2 and b in [peaks[0][0], peaks[1][0]]:
                gradient[mask] = -0.5  # Push peaks down
            else:
                gradient[mask] = 0.1 * np.sign(error_flat[mask] - np.mean(error_flat))
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={
                'bimodality_score': bimodality_score,
                'n_peaks': len(peaks),
                'peak_heights': [p[1] for p in peaks[:3]]
            },
            category=LossCategory.STATISTICAL,
            weight=self.config.weight,
            normalized_value=bimodality_score
        )


# =============================================================================
# COMPONENT LOSSES
# =============================================================================

class ChannelErrorLoss(BaseLossFunction):
    """
    Loss based on per-channel error magnitudes.
    
    Penalizes channels with high error, encouraging
    balanced error distribution across all channels.
    """
    
    def __init__(self, config: LossConfig, target_equal: bool = True):
        super().__init__(config)
        self.target_equal = target_equal
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        flat_error = error.ravel()

        # Reshape to identify channels
        if flat_error.size == 784:
            channel_errors = np.array([np.mean(np.abs(flat_error))])
        else:
            # Assume multiple channels
            n_channels = max(flat_error.size // 784, 1)
            usable = flat_error[:n_channels * 784].reshape(n_channels, 784)
            channel_errors = usable.mean(axis=1)
        
        mean_errors = np.abs(channel_errors)
        
        if self.target_equal:
            # Want equal errors across channels
            target = np.mean(mean_errors)
            loss_value = np.mean((mean_errors - target) ** 2)
            
            # Gradient: push high-error channels down
            gradient = (mean_errors - target) * 2
        else:
            # Just penalize total error
            loss_value = np.mean(mean_errors)
            gradient = np.sign(channel_errors)
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=np.full_like(flat_error, np.mean(gradient)).reshape(error.shape),
            diagnostics={
                'mean_errors': mean_errors,
                'max_channel_error': np.max(mean_errors),
                'min_channel_error': np.min(mean_errors),
                'channel_variance': np.var(mean_errors)
            },
            category=LossCategory.COMPONENT,
            weight=self.config.weight,
            normalized_value=min(loss_value * 10, 1.0)
        )


class CorrelationLoss(BaseLossFunction):
    """
    Loss based on error correlation structure.
    
    Penalizes highly correlated errors across dimensions.
    Encourages errors to be independent (each dimension
    has independent noise).
    
    Target: identity covariance matrix
    """
    
    def __init__(self, config: LossConfig, target_correlation: float = 0.0):
        super().__init__(config)
        self.target_correlation = target_correlation
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        flat_error = error.ravel()
        error_2d = flat_error.reshape(-1, 784) if flat_error.size > 784 else flat_error.reshape(1, -1)

        if error_2d.shape[0] < 2:
            return LossResult(
                name=self.config.name,
                value=0.0,
                gradient=np.zeros_like(error),
                diagnostics={
                    'mean_correlation': 0.0,
                    'max_correlation': 0.0,
                    'n_pairs': 0
                },
                category=LossCategory.COMPONENT,
                weight=self.config.weight,
                normalized_value=0.0
            )
        
        # Compute correlation matrix
        corr_matrix = np.corrcoef(error_2d + 1e-8)
        
        # Off-diagonal elements (correlations)
        n = corr_matrix.shape[0]
        off_diagonal = []
        for i in range(n):
            for j in range(i+1, n):
                off_diagonal.append(corr_matrix[i, j])
        
        if len(off_diagonal) > 0:
            mean_off_diagonal = np.mean(np.abs(off_diagonal))
            max_off_diagonal = np.max(np.abs(off_diagonal))
        else:
            mean_off_diagonal = 0.0
            max_off_diagonal = 0.0
        
        # Loss: correlation deviates from target
        loss_value = abs(mean_off_diagonal - self.target_correlation)
        
        # Gradient: decorrelation signal
        gradient = error * mean_off_diagonal
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={
                'mean_correlation': mean_off_diagonal,
                'max_correlation': max_off_diagonal,
                'n_pairs': len(off_diagonal)
            },
            category=LossCategory.COMPONENT,
            weight=self.config.weight,
            normalized_value=min(mean_off_diagonal, 1.0)
        )


# =============================================================================
# PATTERN LOSSES
# =============================================================================

class EdgeErrorLoss(BaseLossFunction):
    """
    Loss based on error at edge regions.
    
    Separately tracks error in edge regions vs smooth regions.
    Allows differential treatment of edge vs smooth errors.
    """
    
    def __init__(self, config: LossConfig, edge_weight: float = 1.0, smooth_weight: float = 1.0):
        super().__init__(config)
        self.edge_weight = edge_weight
        self.smooth_weight = smooth_weight
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        
        # Detect edges in reference (or input)
        if reference is not None:
            ref_2d = reference.ravel()[:784].reshape(28, 28)
        elif X is not None:
            X_2d = X.ravel()[:784].reshape(28, 28) if hasattr(X, 'ravel') else X
            ref_2d = X_2d
        else:
            ref_2d = error_2d
        
        # Compute gradients (edge detection)
        grad_y = np.abs(np.gradient(ref_2d, axis=0))
        grad_x = np.abs(np.gradient(ref_2d, axis=1))
        edge_magnitude = grad_y + grad_x
        
        # Threshold to identify edges
        edge_threshold = np.percentile(edge_magnitude, 75)
        edge_mask = edge_magnitude > edge_threshold
        smooth_mask = ~edge_mask
        
        # Error in each region
        edge_error = np.mean(np.abs(error_2d)[edge_mask]) if np.any(edge_mask) else 0
        smooth_error = np.mean(np.abs(error_2d)[smooth_mask]) if np.any(smooth_mask) else 0
        
        # Combined loss with weights
        loss_value = self.edge_weight * edge_error + self.smooth_weight * smooth_error
        
        # Gradient: separate gradients for edge and smooth regions
        gradient = np.zeros_like(error_2d)
        gradient[edge_mask] = self.edge_weight * np.sign(error_2d[edge_mask])
        gradient[smooth_mask] = self.smooth_weight * np.sign(error_2d[smooth_mask])
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={
                'edge_error': edge_error,
                'smooth_error': smooth_error,
                'edge_ratio': edge_error / (smooth_error + 1e-8),
                'edge_pixels': np.sum(edge_mask),
                'smooth_pixels': np.sum(smooth_mask)
            },
            category=LossCategory.PATTERN,
            weight=self.config.weight,
            normalized_value=min(loss_value / 10.0, 1.0)
        )


class StructuralSimilarityLoss(BaseLossFunction):
    """
    Loss based on structural similarity (SSIM-like).
    
    Measures structural preservation between prediction
    and reference, not just pixel-level differences.
    """
    
    def __init__(self, config: LossConfig, window_size: int = 5):
        super().__init__(config)
        self.window_size = window_size
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        
        # Original = reference, Error = original - predicted
        # So predicted = original - error
        if reference is not None:
            original = reference.ravel()[:784].reshape(28, 28)
            predicted = original - error_2d
        else:
            original = error_2d
            predicted = -error_2d
        
        # Local means and variances
        pad = self.window_size // 2
        original_padded = np.pad(original, pad, mode='reflect')
        predicted_padded = np.pad(predicted, pad, mode='reflect')
        
        # Compute local statistics using convolution
        kernel = np.ones((self.window_size, self.window_size)) / (self.window_size ** 2)
        
        mu_original = self._convolve2d(original_padded, kernel)[pad:-pad, pad:-pad]
        mu_predicted = self._convolve2d(predicted_padded, kernel)[pad:-pad, pad:-pad]
        
        mu_original_sq = mu_original ** 2
        mu_predicted_sq = mu_predicted ** 2
        mu_original_predicted = mu_original * mu_predicted
        
        sigma_original_sq = self._convolve2d(original_padded ** 2, kernel)[pad:-pad, pad:-pad] - mu_original_sq
        sigma_predicted_sq = self._convolve2d(predicted_padded ** 2, kernel)[pad:-pad, pad:-pad] - mu_predicted_sq
        sigma_original_predicted = self._convolve2d(original_padded * predicted_padded, kernel)[pad:-pad, pad:-pad] - mu_original_predicted
        
        # Constants for stability
        C1 = 0.01 ** 2
        C2 = 0.03 ** 2
        
        # SSIM
        numerator = (2 * mu_original_predicted + C1) * (2 * sigma_original_predicted + C2)
        denominator = (mu_original_sq + mu_predicted_sq + C1) * (sigma_original_sq + sigma_predicted_sq + C2)
        
        ssim = numerator / (denominator + 1e-8)
        mean_ssim = np.mean(ssim)
        
        # Loss: 1 - SSIM (we want high SSIM, low loss)
        loss_value = 1.0 - mean_ssim
        
        # Gradient: based on structural differences
        gradient = error_2d * (1 - mean_ssim)
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={
                'ssim': mean_ssim,
                'luminance_component': np.mean((mu_original - mu_predicted) ** 2),
                'contrast_component': np.mean((sigma_original_sq - sigma_predicted_sq) ** 2),
                'structure_component': np.mean((sigma_original_predicted / (np.sqrt(sigma_original_sq * sigma_predicted_sq) + 1e-8)) ** 2)
            },
            category=LossCategory.PATTERN,
            weight=self.config.weight,
            normalized_value=loss_value
        )
    
    def _convolve2d(self, X: np.ndarray, kernel: np.ndarray) -> np.ndarray:
        """Simple 2D convolution."""
        return np.array([
            [
                np.sum(X[y:y+len(kernel), x:x+len(kernel)] * kernel)
                for x in range(X.shape[1] - len(kernel) + 1)
            ]
            for y in range(X.shape[0] - len(kernel) + 1)
        ])


# =============================================================================
# ADVERSARIAL LOSSES
# =============================================================================

class AdversarialLoss(BaseLossFunction):
    """
    Adversarial loss using discriminator to judge error quality.
    
    Generator tries to minimize error in ways that fool discriminator.
    Discriminator learns to distinguish "good" errors from "bad" errors.
    
    This creates a game-theoretic formulation where the model
    learns not just to minimize numerical error, but to produce
    errors that look "natural" to the discriminator.
    """
    
    def __init__(self, config: LossConfig, discriminator: 'ErrorDiscriminator' = None):
        super().__init__(config)
        self.discriminator = discriminator
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        if self.discriminator is None:
            # No discriminator - just use MSE-like loss
            loss_value = np.mean(error ** 2)
            gradient = 2 * error
        else:
            # Use discriminator to judge error quality
            quality_score, is_real = self.discriminator.judge(error)
            
            # Generator wants to fool discriminator (high score for fake = good)
            if is_real:
                loss_value = quality_score  # We want real-like errors
            else:
                loss_value = 1.0 - quality_score  # We want to fool discriminator
            
            # Gradient based on what discriminator says
            gradient = self.discriminator.get_gradient(error)
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={'discriminator_score': quality_score, 'is_real': is_real},
            category=LossCategory.ADVERSARIAL,
            weight=self.config.weight,
            normalized_value=min(loss_value, 1.0)
        )


class ErrorDiscriminator:
    """
    Discriminator that judges error quality.
    
    Learned to distinguish:
    - Natural/easy errors (low frequency, uniform, symmetric)
    - Artificial/hard errors (high frequency, clustered, asymmetric)
    
    Used in adversarial training to guide error generation.
    """
    
    def __init__(self, hidden_size: int = 32):
        self.W1 = np.random.randn(784, hidden_size) * 0.01
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, 1) * 0.01
        self.b2 = np.zeros((1, 1))
        
        self.real_score = 0.5
        self.fake_score = 0.5
        
    def forward(self, error: np.ndarray) -> float:
        """Judge error quality, return score 0-1."""
        error_flat = error.ravel()[:784]
        
        z1 = np.dot(error_flat, self.W1) + self.b1
        a1 = np.maximum(0, z1)  # ReLU
        score = np.dot(a1, self.W2) + self.b2
        return float(np.clip(score[0, 0], 0, 1))
    
    def judge(self, error: np.ndarray) -> Tuple[float, bool]:
        """Judge error, return (score, is_real)."""
        score = self.forward(error)
        
        # Determine if error looks "real" (natural) or "fake" (model's error)
        if score > 0.5:
            return score, True  # Looks like natural error
        else:
            return score, False  # Looks like model artifact
    
    def train_step(self, real_errors: np.ndarray, fake_errors: np.ndarray, lr: float = 0.01):
        """Train discriminator to distinguish real vs fake errors."""
        # Compute scores
        real_scores = np.array([self.forward(e) for e in real_errors])
        fake_scores = np.array([self.forward(e) for e in fake_errors])
        
        # Loss: want high score for real, low score for fake
        real_loss = np.mean((real_scores - 1) ** 2)  # Target 1
        fake_loss = np.mean(fake_scores ** 2)  # Target 0
        
        total_loss = real_loss + fake_loss
        
        # Simplified gradient update
        self.W1 += lr * (np.mean(fake_errors, axis=0) - np.mean(real_errors, axis=0)) * 0.01
        self.b1 += lr * (np.mean(fake_scores) - np.mean(real_scores)) * 0.01
        
        # Update scores
        self.real_score = float(np.mean(real_scores))
        self.fake_score = float(np.mean(fake_scores))
        
        return total_loss, self.real_score, self.fake_score
    
    def get_gradient(self, error: np.ndarray) -> np.ndarray:
        """Get gradient for generator to reduce."""
        error_flat = error.ravel()[:784]
        
        # Score-based gradient
        score = self.forward(error)
        
        # If score is low (fake), generator should adjust error to look more real
        # Gradient pushes error toward "real-like" patterns
        adjustment = (0.5 - score) * error_flat
        
        return adjustment


# =============================================================================
# REGULARIZATION LOSSES
# =============================================================================

class GradientPenaltyLoss(BaseLossFunction):
    """
    Gradient penalty to ensure smooth error surfaces.
    
    Penalizes large gradients (unstable predictions).
    Encourages smooth, well-behaved error landscapes.
    """
    
    def __init__(self, config: LossConfig, penalty: float = 1.0):
        super().__init__(config)
        self.penalty = penalty
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        
        # Compute gradients
        grad_y = np.gradient(error_2d, axis=0)
        grad_x = np.gradient(error_2d, axis=1)
        
        # Gradient magnitude
        grad_mag = np.sqrt(grad_y**2 + grad_x**2)
        
        # Loss: penalize large gradients
        loss_value = np.mean(grad_mag ** 2)
        
        # Gradient: second derivative (Laplacian)
        laplacian = np.gradient(grad_y, axis=0) + np.gradient(grad_x, axis=1)
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=laplacian.ravel()[:len(error)],
            diagnostics={
                'mean_gradient': np.mean(grad_mag),
                'max_gradient': np.max(grad_mag),
                'gradient_std': np.std(grad_mag)
            },
            category=LossCategory.REGULARIZATION,
            weight=self.config.weight,
            normalized_value=min(loss_value / 10.0, 1.0)
        )


class LipschitzPenaltyLoss(BaseLossFunction):
    """
    Lipschitz penalty to ensure smooth function behavior.
    
    Penalizes large local changes (K-Lipschitz constraint).
    Encourages stable, predictable error surfaces.
    """
    
    def __init__(self, config: LossConfig, K: float = 1.0):
        super().__init__(config)
        self.K = K
        
    def compute(self, error: np.ndarray, X: np.ndarray = None, reference: np.ndarray = None) -> LossResult:
        error_2d = error.ravel()[:784].reshape(28, 28)
        
        # Compute pairwise differences
        H, W = 28, 28
        
        # Horizontal differences
        h_diff = error_2d[:, 1:] - error_2d[:, :-1]
        # Vertical differences
        v_diff = error_2d[1:, :] - error_2d[:-1, :]
        
        # Max local variation
        max_h_diff = np.max(np.abs(h_diff))
        max_v_diff = np.max(np.abs(v_diff))
        max_diff = max(max_h_diff, max_v_diff)
        
        # Loss: deviation from Lipschitz constant K
        loss_value = max(0, max_diff - self.K)
        
        # Gradient: based on where Lipschitz constraint is violated
        gradient = np.zeros_like(error_2d)
        
        for y in range(H):
            for x in range(W - 1):
                if abs(h_diff[y, x]) > self.K:
                    gradient[y, x] += h_diff[y, x]
                    gradient[y, x + 1] -= h_diff[y, x]
        
        for y in range(H - 1):
            for x in range(W):
                if abs(v_diff[y, x]) > self.K:
                    gradient[y, x] += v_diff[y, x]
                    gradient[y + 1, x] -= v_diff[y, x]
        
        return LossResult(
            name=self.config.name,
            value=loss_value,
            gradient=gradient.ravel()[:len(error)],
            diagnostics={
                'max_diff': max_diff,
                'K': self.K,
                'violations': np.sum(np.abs(h_diff) > self.K) + np.sum(np.abs(v_diff) > self.K)
            },
            category=LossCategory.REGULARIZATION,
            weight=self.config.weight,
            normalized_value=min(loss_value / 5.0, 1.0)
        )


# =============================================================================
# MULTI-LOSS OPTIMIZER
# =============================================================================

class MultiLossOptimizer:
    """
    Optimizer that combines multiple diagnostic losses.
    
    Key features:
    - Dynamic loss weighting based on which losses are large
    - Gradient normalization to prevent dominance
    - Automatic tuning of loss weights based on training progress
    - Adversarial training support
    """
    
    def __init__(
        self,
        shape: Tuple[int, ...] = (28, 28, 1),
        default_weights: Optional[Dict[str, float]] = None
    ):
        self.shape = shape
        self.loss_functions = {}
        self.discriminator = ErrorDiscriminator()
        
        # Default weights
        self.default_weights = default_weights or {
            'mse': 1.0,
            'spatial_concentration': 0.5,
            'spatial_entropy': 0.3,
            'hotspot_penalty': 0.5,
            'spectral_high': 0.3,
            'spectral_low': 0.3,
            'mean_error': 0.5,
            'skewness': 0.3,
            'kurtosis': 0.3,
            'bimodality': 0.4,
            'channel_error': 0.3,
            'correlation': 0.3,
            'edge_error': 0.4,
            'structural_similarity': 0.5,
            'adversarial': 0.5,
            'gradient_penalty': 0.2,
            'lipschitz_penalty': 0.2
        }
        
        self._initialize_loss_functions()
        
        # Training state
        self.loss_history = []
        self.gradient_history = []
        
    def _initialize_loss_functions(self):
        """Initialize all loss functions with their configs."""
        
        # MSE (baseline)
        self.loss_functions['mse'] = MSELoss(
            LossConfig('mse', LossCategory.STATISTICAL, weight=1.0)
        )
        
        # Spatial losses
        self.loss_functions['spatial_concentration'] = SpatialConcentrationLoss(
            LossConfig('spatial_concentration', LossCategory.SPATIAL, weight=0.5)
        )
        self.loss_functions['spatial_entropy'] = SpatialEntropyLoss(
            LossConfig('spatial_entropy', LossCategory.SPATIAL, weight=0.3)
        )
        self.loss_functions['hotspot_penalty'] = HotspotPenaltyLoss(
            LossConfig('hotspot_penalty', LossCategory.SPATIAL, weight=0.5),
            n_hotspots=5, penalty_scale=2.0
        )
        
        # Spectral losses
        self.loss_functions['spectral_high'] = SpectralBandLoss(
            LossConfig('spectral_high', LossCategory.SPECTRAL, weight=0.3),
            band='high', target_ratio=0.2
        )
        self.loss_functions['spectral_mid'] = SpectralBandLoss(
            LossConfig('spectral_mid', LossCategory.SPECTRAL, weight=0.3),
            band='mid', target_ratio=0.5
        )
        self.loss_functions['spectral_low'] = SpectralBandLoss(
            LossConfig('spectral_low', LossCategory.SPECTRAL, weight=0.3),
            band='low', target_ratio=0.3
        )
        self.loss_functions['spectral_skewness'] = SpectralSkewnessLoss(
            LossConfig('spectral_skewness', LossCategory.SPECTRAL, weight=0.3),
            target_skewness=0.0
        )
        
        # Statistical losses
        self.loss_functions['mean_error'] = MeanErrorLoss(
            LossConfig('mean_error', LossCategory.STATISTICAL, weight=0.5)
        )
        self.loss_functions['skewness'] = SkewnessLoss(
            LossConfig('skewness', LossCategory.STATISTICAL, weight=0.3),
            target_skew=0.0
        )
        self.loss_functions['kurtosis'] = KurtosisLoss(
            LossConfig('kurtosis', LossCategory.STATISTICAL, weight=0.3),
            target_kurtosis=3.0
        )
        self.loss_functions['bimodality'] = BimodalityLoss(
            LossConfig('bimodality', LossCategory.STATISTICAL, weight=0.4),
            n_bins=20
        )
        
        # Component losses
        self.loss_functions['channel_error'] = ChannelErrorLoss(
            LossConfig('channel_error', LossCategory.COMPONENT, weight=0.3),
            target_equal=True
        )
        self.loss_functions['correlation'] = CorrelationLoss(
            LossConfig('correlation', LossCategory.COMPONENT, weight=0.3),
            target_correlation=0.0
        )
        
        # Pattern losses
        self.loss_functions['edge_error'] = EdgeErrorLoss(
            LossConfig('edge_error', LossCategory.PATTERN, weight=0.4),
            edge_weight=1.5, smooth_weight=1.0
        )
        self.loss_functions['structural_similarity'] = StructuralSimilarityLoss(
            LossConfig('structural_similarity', LossCategory.PATTERN, weight=0.5),
            window_size=5
        )
        
        # Adversarial loss
        self.loss_functions['adversarial'] = AdversarialLoss(
            LossConfig('adversarial', LossCategory.ADVERSARIAL, weight=0.5),
            discriminator=self.discriminator
        )
        
        # Regularization losses
        self.loss_functions['gradient_penalty'] = GradientPenaltyLoss(
            LossConfig('gradient_penalty', LossCategory.REGULARIZATION, weight=0.2),
            penalty=1.0
        )
        self.loss_functions['lipschitz_penalty'] = LipschitzPenaltyLoss(
            LossConfig('lipschitz_penalty', LossCategory.REGULARIZATION, weight=0.2),
            K=1.0
        )
    
    def compute_all_losses(
        self,
        error: np.ndarray,
        X: np.ndarray = None,
        reference: np.ndarray = None
    ) -> Dict[str, LossResult]:
        """Compute all enabled losses."""
        results = {}
        
        for name, loss_fn in self.loss_functions.items():
            try:
                result = loss_fn.compute(error, X, reference)
                results[name] = result
            except Exception as e:
                logger.warning(f"Loss {name} failed: {e}")
                # Create dummy result
                results[name] = LossResult(
                    name=name,
                    value=0.0,
                    gradient=np.zeros_like(error),
                    diagnostics={},
                    category=LossCategory.REGULARIZATION,
                    weight=0.0,
                    normalized_value=0.0
                )
        
        return results
    
    def aggregate_losses(
        self,
        losses: Dict[str, LossResult],
        dynamic_weighting: bool = True
    ) -> Tuple[float, np.ndarray, MultiLossState]:
        """
        Aggregate all losses into single loss and gradient.
        
        Args:
            losses: Dict of loss results
            dynamic_weighting: If True, adjust weights based on loss magnitudes
            
        Returns:
            Tuple of (total_loss, combined_gradient, state)
        """
        total_loss = 0.0
        combined_gradient = np.zeros_like(list(losses.values())[0].gradient)
        weighted_contributions = {}
        gradient_norms = {}
        
        # Dynamic weight adjustment
        if dynamic_weighting:
            loss_values = [l.normalized_value for l in losses.values() if l.weight > 0]
            if loss_values:
                max_loss = max(loss_values)
                min_loss = min(loss_values)
                
                for name, loss in losses.items():
                    if loss.weight > 0:
                        # Boost weights for large losses
                        normalized = (loss.normalized_value - min_loss) / (max_loss - min_loss + 1e-8)
                        adjusted_weight = loss.weight * (1 + normalized)
                        loss.weight = adjusted_weight
        
        # Aggregate
        for name, loss in losses.items():
            if loss.weight > 0:
                weighted_loss = loss.value * loss.weight
                total_loss += weighted_loss
                weighted_contributions[name] = weighted_loss
                
                # Normalize gradient
                grad_norm = np.linalg.norm(loss.gradient) + 1e-8
                normalized_gradient = loss.gradient / grad_norm
                
                combined_gradient += normalized_gradient * loss.weight
                gradient_norms[name] = grad_norm
        
        # Find dominant loss
        dominant_loss = max(weighted_contributions, key=weighted_contributions.get) if weighted_contributions else 'mse'
        
        # Create state
        state = MultiLossState(
            losses=losses,
            total_loss=total_loss,
            weighted_contributions=weighted_contributions,
            dominant_loss=dominant_loss,
            gradient_norms=gradient_norms,
            optimization_advice=self._generate_advice(losses, dominant_loss)
        )
        
        return total_loss, combined_gradient, state
    
    def _generate_advice(self, losses: Dict[str, LossResult], dominant_loss: str) -> List[str]:
        """Generate optimization advice based on current loss state."""
        advice = []
        
        # Analyze each category
        spatial_losses = [l for n, l in losses.items() if l.category == LossCategory.SPATIAL]
        spectral_losses = [l for n, l in losses.items() if l.category == LossCategory.SPECTRAL]
        stat_losses = [l for n, l in losses.items() if l.category == LossCategory.STATISTICAL]
        
        # Spatial advice
        if spatial_losses:
            avg_spatial = np.mean([l.normalized_value for l in spatial_losses])
            if avg_spatial > 0.5:
                advice.append("Focus on reducing spatial concentration - errors are clustered")
        
        # Spectral advice
        if spectral_losses:
            high_freq = next((l for l in spectral_losses if 'high' in l.name), None)
            if high_freq and high_freq.normalized_value > 0.6:
                advice.append("High-frequency errors dominant - smooth the prediction surface")
        
        # Statistical advice
        if stat_losses:
            bimodality = next((l for l in stat_losses if 'bimodality' in l.name), None)
            if bimodality and bimodality.normalized_value > 0.4:
                advice.append("Bimodal error detected - model has multiple failure modes")
            
            skewness = next((l for l in stat_losses if 'skewness' in l.name and 'spectral' not in l.name), None)
            if skewness and skewness.normalized_value > 0.5:
                advice.append("Error distribution is skewed - apply bias correction")
        
        # Dominant loss specific
        if dominant_loss == 'hotspot_penalty':
            advice.append("Focus training on identified high-error regions")
        elif dominant_loss == 'edge_error':
            advice.append("Improve edge rendering accuracy")
        elif dominant_loss == 'mean_error':
            advice.append("Apply global offset correction")
        
        return advice
    
    def get_combined_gradient(
        self,
        error: np.ndarray,
        X: np.ndarray = None,
        reference: np.ndarray = None,
        dynamic_weighting: bool = True
    ) -> Tuple[np.ndarray, MultiLossState]:
        """
        Get combined gradient from all losses.
        
        This is the main entry point for gradient-based optimization.
        """
        losses = self.compute_all_losses(error, X, reference)
        _, combined_gradient, state = self.aggregate_losses(losses, dynamic_weighting)
        
        return combined_gradient, state


# =============================================================================
# MLP WITH MULTI-LOSS LEARNING
# =============================================================================

class MultiLossMLPClassifier:
    """
    MLP Classifier using multi-loss diagnostic learning.
    
    Instead of single MSE loss, uses rich diagnostic losses
    to learn from multiple error aspects simultaneously.
    """
    
    def __init__(
        self,
        input_size: int = 784,
        hidden_size: int = 100,
        output_size: int = 10,
        shape: Tuple[int, ...] = (28, 28, 1)
    ):
        self.input_size = input_size
        self.hidden_size = hidden_size
        self.output_size = output_size
        self.shape = shape
        
        # MLP weights
        self.W1 = np.random.randn(input_size, hidden_size) * 0.01
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * 0.01
        self.b2 = np.zeros((1, output_size))
        self.lr = np.array([0.01, 0.01, 0.01, 0.01])
        
        # Multi-loss optimizer
        self.multi_loss = MultiLossOptimizer(shape=shape)
        
        # Reference bank for diagnostic comparisons
        self.reference_bank = []

    def _prepare_input(self, X: np.ndarray) -> np.ndarray:
        """Flatten image-shaped inputs for the MLP."""
        if X.ndim > 2:
            return X.reshape(X.shape[0], -1)
        return X
        
    def forward(self, X: np.ndarray) -> np.ndarray:
        X = self._prepare_input(X)
        X_norm = X / 127.5 - 1.0
        self.z1 = np.dot(X_norm, self.W1) + self.b1
        self.a1 = np.sin(self.z1) * np.sqrt(np.abs(self.z1) + 1e-8)  # SPDER
        self.z2 = np.dot(self.a1, self.W2) + self.b2
        self.output = self._softmax(self.z2)
        return self.output
    
    def _softmax(self, x: np.ndarray) -> np.ndarray:
        exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
        return exp_x / np.sum(exp_x, axis=1, keepdims=True)
    
    def backward_with_multi_loss(
        self,
        X: np.ndarray,
        y_true: np.ndarray,
        y_pred: np.ndarray
    ) -> Dict:
        """
        Backward pass with multi-loss diagnostic gradients.
        
        Instead of just computing dz2 = y_pred - y_true,
        we analyze the error in detail and create multiple gradient signals.
        """
        X = self._prepare_input(X)
        m = y_true.shape[0]
        X_norm = X / 127.5 - 1.0
        
        # Standard classification loss
        dz2 = y_pred - y_true
        dW2_standard = np.dot(self.a1.T, dz2) / m
        db2_standard = np.sum(dz2, axis=0, keepdims=True) / m
        
        # Get multi-loss gradient for hidden layer
        da1 = np.dot(dz2, self.W2.T)
        
        # Compute multi-loss gradient from diagnostic analysis
        # We analyze what kind of errors the model is making
        error_for_analysis = np.mean(X_norm, axis=0)  # Analyze batch-average error pattern
        
        # Get diagnostic gradients
        multi_gradient, loss_state = self.multi_loss.get_combined_gradient(
            error_for_analysis,
            X=X,
            reference=None
        )
        
        # Combine standard and multi-loss gradients
        # The multi-loss gradient tells us HOW to adjust (direction)
        # The standard gradient tells us WHAT to adjust (magnitude)
        
        # Compute SPDER derivative for hidden layer
        abs_z1 = np.abs(self.z1) + 1e-8
        sqrt_abs_z1 = np.sqrt(abs_z1)
        spder_deriv = sqrt_abs_z1 * np.cos(self.z1) + (np.sign(self.z1) / (2 * sqrt_abs_z1)) * np.sin(self.z1)
        
        # Standard dz1
        dz1_standard = da1 * spder_deriv
        
        # Multi-loss informed dz1 (adjusts based on diagnostic analysis)
        # Use multi-loss gradient to modulate the standard gradient
        multi_gradient_reshaped = multi_gradient[:self.hidden_size]
        multi_modulation = np.tanh(multi_gradient_reshaped)  # Normalize to [-1, 1]
        
        # Combine: keep standard direction but adjust magnitude based on diagnostics
        dz1_multi = dz1_standard * (1.0 + 0.1 * multi_modulation)
        
        dW1_standard = np.dot(X_norm.T, dz1_multi) / m
        db1_standard = np.sum(dz1_multi, axis=0, keepdims=True) / m
        
        return {
            'dW1': dW1_standard,
            'db1': db1_standard,
            'dW2': dW2_standard,
            'db2': db2_standard,
            'loss_state': loss_state,
            'dz2': dz2
        }
    
    def update(self, X: np.ndarray, y_true: np.ndarray):
        X = self._prepare_input(X)
        y_pred = self.forward(X)
        grads = self.backward_with_multi_loss(X, y_true, y_pred)
        
        self.W1 -= self.lr[0] * grads['dW1']
        self.b1 -= self.lr[1] * grads['db1']
        self.W2 -= self.lr[2] * grads['dW2']
        self.b2 -= self.lr[3] * grads['db2']
        
        return grads['loss_state']
    
    def train_with_visualization(self, X_train: np.ndarray, y_train: np.ndarray, epochs: int = 100):
        """Train with detailed loss visualization."""
        logger.info("Training with multi-loss diagnostic learning...")
        
        for i in range(epochs):
            idx = np.random.randint(0, len(X_train), 100)
            X_batch = X_train[idx]
            y_batch = y_train[idx]
            
            # Update with multi-loss
            loss_state = self.update(X_batch, np.eye(self.output_size)[y_batch])
            
            # Log periodically
            if i % 20 == 0:
                accuracy = self.score(X_train[:1000], y_train[:1000])
                
                logger.info(f"\n=== Epoch {i} ===")
                logger.info(f"Accuracy: {accuracy:.4f}")
                logger.info(f"Total loss: {loss_state.total_loss:.4f}")
                logger.info(f"Dominant loss: {loss_state.dominant_loss}")
                
                # Log top 3 contributing losses
                sorted_losses = sorted(
                    loss_state.weighted_contributions.items(),
                    key=lambda x: x[1],
                    reverse=True
                )[:3]
                logger.info(f"Top contributors: {sorted_losses}")
                
                # Log optimization advice
                if loss_state.optimization_advice:
                    logger.info(f"Advice: {loss_state.optimization_advice}")
    
    def predict(self, X: np.ndarray) -> np.ndarray:
        return np.argmax(self.forward(X), axis=1)
    
    def score(self, X: np.ndarray, y_true: np.ndarray) -> float:
        return np.mean(self.predict(X) == y_true)


# =============================================================================
# DEMONSTRATION
# =============================================================================

if __name__ == "__main__":
    print("=== Multi-Loss Diagnostic Learning Demo ===\n")
    
    # Initialize optimizer
    multi_loss = MultiLossOptimizer(shape=(28, 28, 1))
    
    # Simulate different error patterns
    np.random.seed(42)
    
    test_errors = {
        'uniform_small': np.random.randn(784) * 0.1,
        'clustered': np.zeros(784),
        'high_freq': np.sin(np.arange(784) * 0.5) * 0.5,
        'skewed': np.concatenate([np.random.randn(500) * 0.1, np.random.randn(284) * 2.0])
    }
    
    print("--- Computing Multi-Loss for Different Error Patterns ---\n")
    
    for error_name, error in test_errors.items():
        error_2d = error.reshape(28, 28)
        
        print(f"=== Error Pattern: {error_name.upper()} ===")
        print(f"Mean: {np.mean(error):.4f}, Std: {np.std(error):.4f}")
        
        # Compute all losses
        losses = multi_loss.compute_all_losses(error_2d)
        
        # Show results
        print(f"\nLoss breakdown:")
        for name, loss in sorted(losses.items(), key=lambda x: x[1].value, reverse=True):
            if loss.value > 0.01:
                print(f"  {name}: {loss.value:.4f} (normalized: {loss.normalized_value:.2f})")
        
        # Aggregate
        total, gradient, state = multi_loss.aggregate_losses(losses)
        
        print(f"\nTotal weighted loss: {total:.4f}")
        print(f"Dominant loss: {state.dominant_loss}")
        
        if state.optimization_advice:
            print(f"Advice: {state.optimization_advice[0]}")
        
        print()
    
    # Train MLP with multi-loss
    print("\n--- Training MLP with Multi-Loss ---")
    
    try:
        X_train = read('../X_train.wav')[1].reshape(-1, 784)
        y_train = (read('../y_train.wav')[1] * 9).astype(int)
        
        X_train_img = X_train.reshape(-1, 28, 28, 1)
        
        classifier = MultiLossMLPClassifier(
            input_size=784,
            hidden_size=100,
            output_size=10,
            shape=(28, 28, 1)
        )
        
        classifier.train_with_visualization(X_train_img, y_train, epochs=100)
        
        # Evaluate
        X_test = read('../X_test.wav')[1].reshape(-1, 784)
        y_test = (read('../y_test.wav')[1] * 9).astype(int)
        X_test_img = X_test.reshape(-1, 28, 28, 1)
        
        accuracy = classifier.score(X_test_img, y_test)
        logger.info(f"\nFinal Test Accuracy: {accuracy:.4f}")
        
    except FileNotFoundError:
        print("Data files not found. Running synthetic demo.")
        
        # Synthetic demo
        X_synthetic = np.random.randn(1000, 28, 28, 1)
        y_synthetic = np.random.randint(0, 10, 1000)
        
        classifier = MultiLossMLPClassifier(
            input_size=784,
            hidden_size=50,
            output_size=10,
            shape=(28, 28, 1)
        )
        
        classifier.train_with_visualization(X_synthetic, y_synthetic, epochs=50)
