"""
╔══════════════════════════════════════════════════════════════════════════════╗
║                  MNIST SET CLASSIFIER - LETTER OPERATOR VERSION               ║
║                                                                               ║
║  A..Ö (Swedish alphabet) as set-theoretic operators for classification       ║
║  All operations derived from: A B C D E F G H I J K L M N O P R S T U V W X Y Z Å Ä Ö
╚══════════════════════════════════════════════════════════════════════════════╝

LETTER OPERATOR DEFINITIONS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

UPPERCASE (Set-level operators):
  A = Universal container / ambient context
  B = Boolean enclosure / singleton {x}
  C = Complement (relative)
  D = Domain restriction / filter by predicate
  E = Empty set ∅
  F = Function mapping / image f[X]
  G = Union aggregation ∪
  H = Homomorphism / structural equivalence test
  I = Intersection ∩
  J = Disjoint union / coproduct ⊔
  K = Kernel / preimage f⁻¹(y)
  L = Limit point / accumulation
  M = Cardinality / measure |X|
  N = Negation / absolute complement
  O = Open set interior
  P = Power set P(X)
  R = Cartesian product / relation ×
  S = Subset test ⊆
  T = Topology generator
  U = Infinite union ⋃
  V = Symmetric difference △
  W = Well-order constructor
  X = Generalized product ∏
  Y = Function space Y^X
  Z = ZF comprehension {x ∈ X : φ(x)}
  Å = Scale/resolution operator
  Ä = Equivalence/quotient X/~
  Ö = Transfinite limit ω₁

LOWERCASE (Element-level operators):
  a = Atomic access / choose(x)
  b = Bounding (inf, sup)
  c = Count/enumerate
  d = Difference −
  e = Embed/injection
  f = Find/membership test
  g = Gather/list elements
  h = Hash/encode
  i = Index/select by position
  j = Join/concatenate
  k = Key/domain
  l = Link/map values
  m = Merge/union all
  n = Nest/power set iteration
  o = Order/sort
  p = Project/first coordinate
  q = Query/filter
  r = Range/codomain
  s = Slice/restrict
  t = Transpose/swap
  u = Unify/canonical form
  v = Verify/consistency check
  w = Witness/counterexample
  x = XOR/symmetric difference
  y = Yield/extract value
  z = Zip/interleave
  å = Average/mean
  ä = Jaccard similarity |A∩B|/|A∪B|
  ö = Translate/shift
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision
import torchvision.transforms as transforms
from typing import Set, List, Tuple, Dict, Optional, Any
from dataclasses import dataclass
from collections import defaultdict
import math

# ═══════════════════════════════════════════════════════════════════════════════
# PART I: LETTER OPERATOR DEFINITIONS (PyTorch Implementation)
# ═══════════════════════════════════════════════════════════════════════════════

class LetterOperators:
    """
    Collection of all Swedish alphabet letter operators implemented as PyTorch operations.
    Maintains set-theoretic semantics while using tensors for efficiency.
    """
    
    # ─────────────────────────────────────────────────────────────────────────────
    # UPPERCASE LETTER OPERATORS (Set-level operations)
    # ─────────────────────────────────────────────────────────────────────────────
    
    @staticmethod
    def A(universe: torch.Tensor) -> torch.Tensor:
        """
        A = Universal Container / Ambient Context
        Returns the full universal set for the given space.
        
        Args:
            universe: Base tensor defining the ambient space
            
        Returns:
            Full set containing all possible elements
        """
        if len(universe.shape) == 1:
            return universe  # Already a 1D tensor of elements
        # Return indices for full grid
        return torch.arange(universe.numel())
    
    @staticmethod
    def B(element: Any) -> Set:
        """
        B = Boolean Enclosure / Singleton
        Wraps an element into a singleton set: {element}
        
        Args:
            element: Any hashable element
            
        Returns:
            Set containing only the element
        """
        return {element}
    
    @staticmethod
    def B_tensor(element: torch.Tensor) -> torch.Tensor:
        """
        B_tensor version for tensor elements.
        """
        return element.unsqueeze(0)  # Shape: (1, *element.shape)
    
    @staticmethod
    def C(set_a: torch.Tensor, set_b: torch.Tensor) -> torch.Tensor:
        """
        C = Complement (relative to set_a)
        Returns elements in set_a that are not in set_b: set_a \\ set_b
        
        Args:
            set_a: The ambient set
            set_b: The set to complement against
            
        Returns:
            set_a - set_b
        """
        mask = torch.ones(set_a.numel(), dtype=torch.bool)
        for elem in set_b:
            mask &= (set_a != elem)
        return set_a[mask]
    
    @staticmethod
    def D(tensor: torch.Tensor, predicate_fn) -> torch.Tensor:
        """
        D = Domain Restriction / Filter by Predicate
        Returns elements satisfying the predicate: {x ∈ tensor : predicate(x)}
        
        Args:
            tensor: Input tensor
            predicate_fn: Function that returns bool for each element
            
        Returns:
            Filtered tensor
        """
        return tensor[predicate_fn(tensor)]
    
    @staticmethod
    def E() -> torch.Tensor:
        """
        E = Empty Set ∅
        Returns an empty tensor representing the null set.
        
        Returns:
            Empty tensor
        """
        return torch.tensor([], dtype=torch.long)
    
    @staticmethod
    def F(func: callable, tensor: torch.Tensor) -> torch.Tensor:
        """
        F = Function Mapping / Image
        Applies function to all elements: f[tensor] = {f(x) : x ∈ tensor}
        
        Args:
            func: Function to apply
            tensor: Input tensor
            
        Returns:
            Transformed tensor
        """
        return func(tensor)
    
    @staticmethod
    def G(*tensors: torch.Tensor) -> torch.Tensor:
        """
        G = Union Aggregation
        Combines multiple sets: tensor_1 ∪ tensor_2 ∪ ... ∪ tensor_n
        
        Args:
            *tensors: Variable number of tensors to union
            
        Returns:
            Union of all input tensors (unique elements)
        """
        if len(tensors) == 0:
            return LetterOperators.E()
        if len(tensors) == 1:
            return tensors[0]
        
        # Concatenate and unique
        combined = torch.cat([t.flatten() for t in tensors])
        return torch.unique(combined)
    
    @staticmethod
    def H(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> bool:
        """
        H = Homomorphism / Structural Equivalence
        Tests if two sets have the same structure (same cardinality here).
        
        Args:
            tensor_a: First set
            tensor_b: Second set
            
        Returns:
            True if structurally equivalent
        """
        return tensor_a.numel() == tensor_b.numel()
    
    @staticmethod
    def I(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """
        I = Intersection
        Returns common elements: tensor_a ∩ tensor_b
        
        Args:
            tensor_a: First set
            tensor_b: Second set
            
        Returns:
            Intersection tensor
        """
        if tensor_a.numel() == 0 or tensor_b.numel() == 0:
            return LetterOperators.E()
        
        # Find common elements
        set_a = set(tensor_a.flatten().tolist())
        set_b = set(tensor_b.flatten().tolist())
        common = set_a.intersection(set_b)
        
        if not common:
            return LetterOperators.E()
        return torch.tensor(list(common), dtype=torch.long)
    
    @staticmethod
    def J(tensor_a: torch.Tensor, tensor_b: torch.Tensor, label_a: int = 0, label_b: int = 1) -> torch.Tensor:
        """
        J = Disjoint Union / Coproduct
        Unions while preserving identity via tagging: tensor_a ⊔ tensor_b
        
        Args:
            tensor_a: First set
            tensor_b: Second set
            label_a: Tag for first set (default 0)
            label_b: Tag for second set (default 1)
            
        Returns:
            Disjoint union with identity preserved
        """
        tagged_a = torch.stack([torch.full_like(tensor_a.flatten(), label_a), tensor_a.flatten()], dim=1)
        tagged_b = torch.stack([torch.full_like(tensor_b.flatten(), label_b), tensor_b.flatten()], dim=1)
        return torch.cat([tagged_a, tagged_b], dim=0)
    
    @staticmethod
    def K(func: callable, value: Any) -> torch.Tensor:
        """
        K = Kernel / Preimage
        Returns preimage of a value: f⁻¹(value) = {x : f(x) = value}
        
        Args:
            func: Function mapping
            value: Target value
            
        Returns:
            Preimage tensor
        """
        # For discrete case: find indices where func equals value
        indices = torch.arange(func.numel())
        return indices[func == value]
    
    @staticmethod
    def L(tensor: torch.Tensor, radius: float = 1.0) -> torch.Tensor:
        """
        L = Limit Point / Accumulation Point
        Returns elements that are within radius of other elements.
        
        Args:
            tensor: Input set
            radius: Neighborhood radius
            
        Returns:
            Set of limit points
        """
        if tensor.numel() <= 1:
            return tensor
        
        # Compute pairwise distances
        diff = tensor.unsqueeze(1) - tensor.unsqueeze(0)  # Shape: (n, n)
        dist = torch.norm(diff, dim=2)
        
        # Limit points are within radius of at least one other point
        has_neighbor = (dist <= radius).any(dim=1)
        return tensor[has_neighbor]
    
    @staticmethod
    def M(tensor: torch.Tensor) -> int:
        """
        M = Cardinality / Measure
        Returns the size of the set: |tensor|
        
        Args:
            tensor: Input set
            
        Returns:
            Number of elements
        """
        return tensor.numel()
    
    @staticmethod
    def N(tensor: torch.Tensor, universe: torch.Tensor) -> torch.Tensor:
        """
        N = Negation / Absolute Complement
        Returns elements in universe not in tensor: universe \\ tensor
        
        Args:
            tensor: Set to complement
            universe: Universal set
            
        Returns:
            Complement tensor
        """
        set_tensor = set(tensor.flatten().tolist())
        set_universe = set(universe.flatten().tolist())
        complement = set_universe - set_tensor
        return torch.tensor(list(complement), dtype=torch.long)
    
    @staticmethod
    def O(tensor: torch.Tensor, boundary_threshold: float = 0.5) -> torch.Tensor:
        """
        O = Open Set Interior
        Returns interior points (not on boundary).
        
        Args:
            tensor: Input set
            boundary_threshold: Threshold for boundary detection
            
        Returns:
            Interior tensor
        """
        # For binary classification: return points clearly interior
        return tensor[tensor > boundary_threshold]
    
    @staticmethod
    def P(tensor: torch.Tensor) -> List[torch.Tensor]:
        """
        P = Power Set
        Returns all subsets of the tensor: P(tensor)
        
        Note: Exponential size! Only for small tensors.
        
        Args:
            tensor: Input set
            
        Returns:
            List of all subsets
        """
        n = tensor.numel()
        if n > 10:
            raise ValueError(f"Power set of size {n} too large (>1024 subsets)")
        
        subsets = []
        for mask in range(1 << n):
            subset = tensor[torch.tensor([(mask >> i) & 1 for i in range(n)], dtype=torch.bool)]
            subsets.append(subset)
        return subsets
    
    @staticmethod
    def R(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """
        R = Cartesian Product
        Returns all ordered pairs: tensor_a × tensor_b
        
        Args:
            tensor_a: First set
            tensor_b: Second set
            
        Returns:
            Cartesian product as (n_a * n_b) x 2 tensor
        """
        # Create meshgrid
        a_expanded = tensor_a.unsqueeze(1).expand(-1, tensor_b.numel())
        b_expanded = tensor_b.unsqueeze(0).expand(tensor_a.numel(), -1)
        return torch.stack([a_expanded.flatten(), b_expanded.flatten()], dim=1)
    
    @staticmethod
    def S(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> bool:
        """
        S = Subset Test
        Returns True if tensor_a ⊆ tensor_b
        
        Args:
            tensor_a: Potential subset
            tensor_b: Potential superset
            
        Returns:
            True if subset relation holds
        """
        set_a = set(tensor_a.flatten().tolist())
        set_b = set(tensor_b.flatten().tolist())
        return set_a.issubset(set_b)
    
    @staticmethod
    def T(basis_tensors: List[torch.Tensor]) -> List[torch.Tensor]:
        """
        T = Topology Generator
        Generates topology from basis sets.
        
        Args:
            basis_tensors: List of basis sets
            
        Returns:
            Generated topology (all unions of finite intersections)
        """
        # Simplified: return all possible unions of basis
        topology = [LetterOperators.E()]
        for b in basis_tensors:
            topology.append(b)
            for existing in list(topology):
                if existing.numel() > 0:
                    union = LetterOperators.G(existing, b)
                    topology.append(union)
        return list(set([tuple(t.tolist()) for t in topology if t.numel() > 0]))
    
    @staticmethod
    def U(tensor_list: List[torch.Tensor]) -> torch.Tensor:
        """
        U = Infinite Union (generalized)
        Union over indexed family of sets.
        
        Args:
            tensor_list: List of tensors to union
            
        Returns:
            Union of all tensors
        """
        return LetterOperators.G(*tensor_list)
    
    @staticmethod
    def V(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """
        V = Symmetric Difference
        Returns elements in exactly one set: tensor_a △ tensor_b
        
        Args:
            tensor_a: First set
            tensor_b: Second set
            
        Returns:
            Symmetric difference tensor
        """
        set_a = set(tensor_a.flatten().tolist())
        set_b = set(tensor_b.flatten().tolist())
        symmetric_diff = set_a.symmetric_difference(set_b)
        return torch.tensor(list(symmetric_diff), dtype=torch.long)
    
    @staticmethod
    def W(tensor: torch.Tensor) -> torch.Tensor:
        """
        W = Well-Ordering Constructor
        Imposes total order on tensor (sorted).
        
        Args:
            tensor: Input set
            
        Returns:
            Well-ordered tensor (sorted)
        """
        return torch.sort(tensor)[0]
    
    @staticmethod
    def X(*tensors: torch.Tensor) -> torch.Tensor:
        """
        X = Generalized Product / Tensor Product
        Returns the generalized product of tensors.
        
        Args:
            *tensors: Variable number of tensors
            
        Returns:
            Generalized product tensor
        """
        if len(tensors) == 0:
            return LetterOperators.E()
        if len(tensors) == 1:
            return tensors[0]
        
        # Stack into higher-order tensor
        return torch.stack(list(tensors), dim=0)
    
    @staticmethod
    def Y(domain: torch.Tensor, codomain: torch.Tensor) -> int:
        """
        Y = Function Space Cardinality
        Returns |codomain|^|domain| (number of functions from domain to codomain).
        
        Args:
            domain: Domain set
            codomain: Codomain set
            
        Returns:
            Number of possible functions
        """
        return codomain.numel() ** domain.numel()
    
    @staticmethod
    def Z(tensor: torch.Tensor, predicate_fn) -> torch.Tensor:
        """
        Z = ZF Comprehension
        Returns subset satisfying predicate: {x ∈ tensor : φ(x)}
        
        Args:
            tensor: Input set
            predicate_fn: Boolean predicate function
            
        Returns:
            Filtered tensor
        """
        return tensor[predicate_fn(tensor)]
    
    @staticmethod
    def Å(tensor: torch.Tensor, scale: float) -> torch.Tensor:
        """
        Å = Scale/Resolution Operator
        Changes granularity of representation.
        
        Args:
            tensor: Input tensor
            scale: Scale factor
            
        Returns:
            Scaled tensor
        """
        return tensor / scale
    
    @staticmethod
    def Ä(tensor: torch.Tensor, relation_fn) -> List[torch.Tensor]:
        """
        Ä = Equivalence Quotient
        Partitions tensor into equivalence classes.
        
        Args:
            tensor: Input set
            relation_fn: Equivalence relation function
            
        Returns:
            List of equivalence class tensors
        """
        if tensor.numel() == 0:
            return []
        
        # Compute equivalence classes
        n = tensor.numel()
        classes = []
        assigned = [False] * n
        
        for i in range(n):
            if not assigned[i]:
                # Start new class
                current_class = [tensor[i].item()]
                assigned[i] = True
                
                for j in range(i + 1, n):
                    if not assigned[j] and relation_fn(tensor[i], tensor[j]):
                        current_class.append(tensor[j].item())
                        assigned[j] = True
                
                classes.append(torch.tensor(current_class, dtype=tensor.dtype))
        
        return classes
    
    @staticmethod
    def Ö(tensor: torch.Tensor) -> torch.Tensor:
        """
        Ö = Transfinite Limit / Supremum
        Returns supremum of ordinal sequence (max element for finite case).
        
        Args:
            tensor: Ordinal sequence
            
        Returns:
            Supremum (max element)
        """
        if tensor.numel() == 0:
            return LetterOperators.E()
        return torch.tensor([tensor.max().item()])
    
    # ─────────────────────────────────────────────────────────────────────────────
    # LOWERCASE LETTER OPERATORS (Element-level operations)
    # ─────────────────────────────────────────────────────────────────────────────
    
    @staticmethod
    def a(tensor: torch.Tensor) -> Any:
        """
        a = Atomic Access / Choose
        Returns an arbitrary element from the tensor.
        
        Args:
            tensor: Input set
            
        Returns:
            One element from the set (or None if empty)
        """
        if tensor.numel() == 0:
            return None
        return tensor[0].item()
    
    @staticmethod
    def b(tensor: torch.Tensor) -> Tuple[Any, Any]:
        """
        b = Bounding (infimum, supremum)
        Returns (min, max) of tensor.
        
        Args:
            tensor: Input tensor
            
        Returns:
            Tuple (inf, sup)
        """
        if tensor.numel() == 0:
            return (None, None)
        return (tensor.min().item(), tensor.max().item())
    
    @staticmethod
    def c(tensor: torch.Tensor) -> int:
        """
        c = Count / Cardinality
        Returns number of elements.
        
        Args:
            tensor: Input tensor
            
        Returns:
            Element count
        """
        return tensor.numel()
    
    @staticmethod
    def d(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """
        d = Difference
        Returns elements in tensor_a not in tensor_b: tensor_a - tensor_b
        
        Args:
            tensor_a: First tensor
            tensor_b: Second tensor
            
        Returns:
            Difference tensor
        """
        return LetterOperators.C(tensor_a, tensor_b)
    
    @staticmethod
    def e(element: Any) -> Set:
        """
        e = Embed / Injection
        Wraps element into a set (injective embedding).
        
        Args:
            element: Element to embed
            
        Returns:
            Set containing element
        """
        return {element}
    
    @staticmethod
    def f(element: Any, tensor: torch.Tensor) -> bool:
        """
        f = Find / Membership Test
        Returns True if element is in tensor.
        
        Args:
            element: Element to test
            tensor: Set to test against
            
        Returns:
            True if element in tensor
        """
        return element in tensor.tolist()
    
    @staticmethod
    def g(tensor: torch.Tensor) -> List:
        """
        g = Gather / List Elements
        Returns list of all elements.
        
        Args:
            tensor: Input tensor
            
        Returns:
            Python list of elements
        """
        return tensor.tolist()
    
    @staticmethod
    def h(tensor: torch.Tensor) -> int:
        """
        h = Hash / Encode as Scalar
        Returns hash value for tensor.
        
        Args:
            tensor: Input tensor
            
        Returns:
            Hash value
        """
        return hash(tuple(tensor.tolist()))
    
    @staticmethod
    def i(tensor: torch.Tensor, index: int) -> Any:
        """
        i = Index / Select by Position
        Returns element at position index.
        
        Args:
            tensor: Input tensor
            index: Position index
            
        Returns:
            Element at index
        """
        if index < 0 or index >= tensor.numel():
            return None
        return tensor[index].item()
    
    @staticmethod
    def j(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """
        j = Join / Concatenate
        Concatenates two tensors.
        
        Args:
            tensor_a: First tensor
            tensor_b: Second tensor
            
        Returns:
            Concatenated tensor
        """
        return torch.cat([tensor_a.flatten(), tensor_b.flatten()])
    
    @staticmethod
    def k(relation: torch.Tensor) -> torch.Tensor:
        """
        k = Key / Domain
        Returns domain of a relation (first coordinate).
        
        Args:
            relation: Relation tensor (n x 2)
            
        Returns:
            Domain set
        """
        return relation[:, 0]
    
    @staticmethod
    def l(element: Any, relation: torch.Tensor) -> torch.Tensor:
        """
        l = Link / Map to Values
        Returns all y such that (element, y) in relation.
        
        Args:
            element: Source element
            relation: Relation tensor
            
        Returns:
            Set of linked values
        """
        mask = relation[:, 0] == element
        return relation[mask, 1]
    
    @staticmethod
    def m(*tensors: torch.Tensor) -> torch.Tensor:
        """
        m = Merge / Union All
        Union of all tensors.
        
        Args:
            *tensors: Variable number of tensors
            
        Returns:
            Union tensor
        """
        return LetterOperators.G(*tensors)
    
    @staticmethod
    def n(tensor: torch.Tensor, k: int) -> List[torch.Tensor]:
        """
        n = Nest / Power Set Iteration
        Returns k-fold power set of tensor.
        
        Args:
            tensor: Input set
            k: Number of iterations
            
        Returns:
            k-fold power set as list
        """
        current = [tensor]
        for _ in range(k):
            next_level = []
            for subset in current:
                try:
                    next_level.extend(LetterOperators.P(subset))
                except ValueError:
                    pass
            current = next_level
        return current
    
    @staticmethod
    def o(tensor: torch.Tensor) -> torch.Tensor:
        """
        o = Order / Sort
        Returns sorted tensor.
        
        Args:
            tensor: Input tensor
            
        Returns:
            Sorted tensor
        """
        return torch.sort(tensor)[0]
    
    @staticmethod
    def p(pair: torch.Tensor) -> Any:
        """
        p = Project / First Coordinate
        Returns first element of pair.
        
        Args:
            pair: 2-element tensor
            
        Returns:
            First coordinate
        """
        return pair[0].item()
    
    @staticmethod
    def q(tensor: torch.Tensor, predicate_fn) -> torch.Tensor:
        """
        q = Query / Filter by Predicate
        Returns elements satisfying predicate.
        
        Args:
            tensor: Input tensor
            predicate_fn: Boolean predicate
            
        Returns:
            Filtered tensor
        """
        return tensor[predicate_fn(tensor)]
    
    @staticmethod
    def r(relation: torch.Tensor) -> torch.Tensor:
        """
        r = Range / Codomain
        Returns codomain of relation (second coordinate).
        
        Args:
            relation: Relation tensor
            
        Returns:
            Codomain set
        """
        return relation[:, 1]
    
    @staticmethod
    def s(tensor: torch.Tensor, indices: torch.Tensor) -> torch.Tensor:
        """
        s = Slice / Restrict to Subset
        Returns elements at given indices.
        
        Args:
            tensor: Input tensor
            indices: Index tensor
            
        Returns:
            Sliced tensor
        """
        return tensor[indices]
    
    @staticmethod
    def t(relation: torch.Tensor) -> torch.Tensor:
        """
        t = Transpose / Swap Coordinates
        Returns relation with coordinates swapped.
        
        Args:
            relation: Relation tensor
            
        Returns:
            Transposed relation
        """
        return torch.stack([relation[:, 1], relation[:, 0]], dim=1)
    
    @staticmethod
    def u(tensor: torch.Tensor) -> torch.Tensor:
        """
        u = Unify / Canonical Form
        Returns sorted unique tensor.
        
        Args:
            tensor: Input tensor
            
        Returns:
            Canonical tensor
        """
        return torch.unique(torch.sort(tensor)[0])
    
    @staticmethod
    def v(tensor: torch.Tensor, predicate_fn) -> bool:
        """
        v = Verify / Consistency Check
        Returns True if all elements satisfy predicate.
        
        Args:
            tensor: Input tensor
            predicate_fn: Boolean predicate
            
        Returns:
            True if all satisfy
        """
        return predicate_fn(tensor).all()
    
    @staticmethod
    def w(tensor: torch.Tensor, neg_predicate_fn) -> Any:
        """
        w = Witness / Find Counterexample
        Returns first element violating predicate.
        
        Args:
            tensor: Input tensor
            neg_predicate_fn: Predicate to violate
            
        Returns:
            Counterexample element or None
        """
        violations = tensor[~neg_predicate_fn(tensor)]
        if violations.numel() > 0:
            return violations[0].item()
        return None
    
    @staticmethod
    def x(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """
        x = XOR / Symmetric Difference
        Alias for V (Symmetric Difference).
        """
        return LetterOperators.V(tensor_a, tensor_b)
    
    @staticmethod
    def y(func: callable, arg: Any) -> Any:
        """
        y = Yield / Extract Function Value
        Applies function to argument.
        
        Args:
            func: Function
            arg: Argument
            
        Returns:
            Function result
        """
        return func(arg)
    
    @staticmethod
    def z(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """
        z = Zip / Interleave
        Interleaves two tensors element-wise.
        
        Args:
            tensor_a: First tensor
            tensor_b: Second tensor
            
        Returns:
            Interleaved tensor
        """
        n = min(tensor_a.numel(), tensor_b.numel())
        interleaved = torch.zeros(2 * n, dtype=tensor_a.dtype)
        interleaved[0::2] = tensor_a[:n]
        interleaved[1::2] = tensor_b[:n]
        return interleaved
    
    @staticmethod
    def å(tensor: torch.Tensor) -> float:
        """
        å = Average / Mean
        Returns arithmetic mean of tensor.
        
        Args:
            tensor: Input tensor
            
        Returns:
            Mean value
        """
        if tensor.numel() == 0:
            return 0.0
        return tensor.float().mean().item()
    
    @staticmethod
    def ä(tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> float:
        """
        ä = Jaccard Similarity
        Returns |A ∩ B| / |A ∪ B|
        
        Args:
            tensor_a: First set
            tensor_b: Second set
            
        Returns:
            Jaccard similarity coefficient
        """
        intersection = LetterOperators.I(tensor_a, tensor_b)
        union = LetterOperators.G(tensor_a, tensor_b)
        
        if union.numel() == 0:
            return 0.0
        return intersection.numel() / union.numel()
    
    @staticmethod
    def ö(tensor: torch.Tensor, shift: float) -> torch.Tensor:
        """
        ö = Translate / Shift
        Adds constant to all elements.
        
        Args:
            tensor: Input tensor
            shift: Shift amount
            
        Returns:
            Shifted tensor
        """
        return tensor + shift


# ═══════════════════════════════════════════════════════════════════════════════
# PART II: IMAGE ENCODING (Letter Operator Style)
# ═══════════════════════════════════════════════════════════════════════════════

class ImageEncoder:
    """
    Encodes MNIST images as sets using letter operators.
    
    Encoding strategy:
    - Each image = set of active pixel coordinates
    - Threshold determines which pixels are "in" the set
    - Sparse representation for efficiency
    """
    
    def __init__(self, threshold: float = 0.5, grid_size: int = 28):
        self.threshold = threshold
        self.grid_size = grid_size
        self.ops = LetterOperators()
        
        # Universal set: all possible pixel coordinates
        self.universe = torch.arange(grid_size * grid_size)
    
    def encode_to_set(self, image: torch.Tensor) -> torch.Tensor:
        """
        B + D + G: Encode image as set of active pixel indices.
        
        Args:
            image: Image tensor of shape (28, 28) or flattened (784,)
            
        Returns:
            Tensor of active pixel indices
        """
        # Ensure 1D
        if image.dim() > 1:
            image = image.flatten()
        
        # D: Domain restriction - filter pixels above threshold
        active_mask = image > self.threshold
        
        # Get indices of active pixels
        active_indices = torch.arange(image.numel())[active_mask]
        
        return active_indices
    
    def encode_sparse(self, image: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
        """
        Encode as (indices, values) sparse representation.
        
        Returns:
            Tuple of (indices tensor, values tensor)
        """
        if image.dim() > 1:
            image = image.flatten()
        
        active_mask = image > self.threshold
        indices = torch.arange(image.numel())[active_mask]
        values = image[active_mask]
        
        return indices, values
    
    def decode_from_set(self, indices: torch.Tensor, shape: Tuple[int, int] = (28, 28)) -> torch.Tensor:
        """
        Reconstruct image from set of active pixel indices.
        
        Args:
            indices: Set of active pixel indices
            shape: Target shape
            
        Returns:
            Reconstructed image
        """
        image = torch.zeros(self.grid_size * self.grid_size)
        image[indices] = 1.0
        return image.reshape(shape)
    
    def power_set_encode(self, image: torch.Tensor) -> List[torch.Tensor]:
        """
        P: Generate power set of active pixels (all possible sub-patterns).
        Warning: Exponential! Use for small images only.
        
        Args:
            image: Image tensor
            
        Returns:
            List of all subsets
        """
        active_set = self.encode_to_set(image)
        return LetterOperators.P(active_set)


# ═══════════════════════════════════════════════════════════════════════════════
# PART III: SET-BASED CLASSIFIER ARCHITECTURE
# ═══════════════════════════════════════════════════════════════════════════════

class SetMNISTClassifier(nn.Module):
    """
    MNIST Classifier using only letter operator set operations.
    
    Architecture:
    ┌─────────────────────────────────────────────────────────────────────────┐
    │                         SET-MNIST CLASSIFIER                            │
    │                                                                         │
    │  Input Image (28x28)                                                    │
    │       │                                                                 │
    │       ▼                                                                 │
    │  ┌─────────────────────────────────────────────────────────────────┐   │
    │  │  A (Universal) → Grid coordinates [0, 783]                      │   │
    │  └─────────────────────────────────────────────────────────────────┘   │
    │       │                                                                 │
    │       ▼                                                                 │
    │  ┌─────────────────────────────────────────────────────────────────┐   │
    │  │  D (Filter) → Threshold active pixels                           │   │
    │  │  B (Singleton) → Wrap each pixel as set element                 │   │
    │  │  G (Union) → Combine into image set                             │   │
    │  └─────────────────────────────────────────────────────────────────┘   │
    │       │                                                                 │
    │       ▼                                                                 │
    │  ┌─────────────────────────────────────────────────────────────────┐   │
    │  │  Ä (Cluster) → Group similar images per digit class             │   │
    │  │  I (Intersection) → Compute overlap with class prototypes       │   │
    │  │  M (Measure) → Cardinality of intersection                      │   │
    │  └─────────────────────────────────────────────────────────────────┘   │
    │       │                                                                 │
    │       ▼                                                                 │
    │  ┌─────────────────────────────────────────────────────────────────┐   │
    │  │  V (Symmetric Difference) → Distance between image sets         │   │
    │  │  ä (Jaccard) → Normalized similarity                            │   │
    │  │  S (Subset Test) → Pattern containment check                    │   │
    │  └─────────────────────────────────────────────────────────────────┘   │
    │       │                                                                 │
    │       ▼                                                                 │
    │  Argmax → Predicted Digit (0-9)                                        │
    └─────────────────────────────────────────────────────────────────────────┘
    """
    
    def __init__(
        self,
        grid_size: int = 28,
        threshold: float = 0.5,
        num_classes: int = 10,
        embedding_dim: int = 64,
        use_power_set: bool = False,
        use_jaccard: bool = True,
        use_intersection: bool = True,
        use_symmetric_diff: bool = True,
    ):
        super().__init__()
        
        self.grid_size = grid_size
        self.num_pixels = grid_size * grid_size
        self.threshold = threshold
        self.num_classes = num_classes
        self.ops = LetterOperators()
        
        # ─────────────────────────────────────────────────────────────────────
        # Learnable parameters for set operations
        # ─────────────────────────────────────────────────────────────────────
        
        # Threshold (learnable)
        self.learnable_threshold = nn.Parameter(torch.tensor(threshold))
        
        # Embedding dimension
        self.embedding_dim = embedding_dim
        
        # Learnable class prototypes (one per digit)
        # Each prototype is a set of pixel indices
        self.class_prototypes = nn.Parameter(
            torch.randn(num_classes, embedding_dim)
        )
        
        # Learnable importance weights for each pixel
        self.pixel_weights = nn.Parameter(torch.ones(self.num_pixels))
        
        # ─────────────────────────────────────────────────────────────────────
        # Set operation flags
        # ─────────────────────────────────────────────────────────────────────
        
        self.use_power_set = use_power_set
        self.use_jaccard = use_jaccard
        self.use_intersection = use_intersection
        self.use_symmetric_diff = use_symmetric_diff
        
        # ─────────────────────────────────────────────────────────────────────
        # Projection layers (using letter operator concepts)
        # ─────────────────────────────────────────────────────────────────────
        
        # F: Function mapping (projection)
        self.image_proj = nn.Sequential(
            nn.Linear(self.num_pixels, embedding_dim),
            nn.ReLU(),
            nn.Linear(embedding_dim, embedding_dim)
        )
        
        # T: Topology-preserving projection
        self.topology_proj = nn.Sequential(
            nn.Linear(embedding_dim, embedding_dim),
            nn.Tanh()  # Preserves topological structure
        )
        
        # Similarity computation already produces one score per class.
        # Keep a small learned mixing layer in class-logit space.
        self.output_proj = nn.Sequential(
            nn.Linear(num_classes, num_classes)
        )
    
    # ─────────────────────────────────────────────────────────────────────────
    # LETTER OPERATOR METHODS
    # ─────────────────────────────────────────────────────────────────────────
    
    def A_universal(self) -> torch.Tensor:
        """A: Return universal set of all pixel indices."""
        return torch.arange(self.num_pixels, device=self.class_prototypes.device)
    
    def B_singleton(self, x: torch.Tensor) -> torch.Tensor:
        """B: Wrap element in singleton tensor."""
        return x.unsqueeze(0)
    
    def D_filter(self, tensor: torch.Tensor, threshold: torch.Tensor) -> torch.Tensor:
        """D: Filter elements by threshold."""
        return tensor * (tensor > threshold).float()
    
    def E_empty(self) -> torch.Tensor:
        """E: Return empty tensor."""
        return torch.tensor([], device=self.class_prototypes.device)
    
    def G_union(self, *tensors: torch.Tensor) -> torch.Tensor:
        """G: Union of multiple tensors (unique elements)."""
        if len(tensors) == 0:
            return self.E_empty()
        combined = torch.cat([t.flatten() for t in tensors])
        return torch.unique(combined)
    
    def I_intersection(self, tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """I: Intersection of two tensors."""
        if tensor_a.numel() == 0 or tensor_b.numel() == 0:
            return self.E_empty()
        set_a = set(tensor_a.tolist())
        set_b = set(tensor_b.tolist())
        common = set_a.intersection(set_b)
        if not common:
            return self.E_empty()
        return torch.tensor(list(common), device=tensor_a.device, dtype=torch.long)
    
    def M_cardinality(self, tensor: torch.Tensor) -> int:
        """M: Return cardinality of tensor."""
        return tensor.numel()
    
    def V_symmetric_diff(self, tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> torch.Tensor:
        """V: Symmetric difference."""
        set_a = set(tensor_a.tolist())
        set_b = set(tensor_b.tolist())
        diff = set_a.symmetric_difference(set_b)
        return torch.tensor(list(diff), device=tensor_a.device, dtype=torch.long)
    
    def ä_jaccard(self, tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> float:
        """ä: Jaccard similarity."""
        inter = self.I_intersection(tensor_a, tensor_b)
        union = self.G_union(tensor_a, tensor_b)
        if union.numel() == 0:
            return 0.0
        return inter.numel() / union.numel()
    
    def S_subset(self, tensor_a: torch.Tensor, tensor_b: torch.Tensor) -> bool:
        """S: Subset test."""
        set_a = set(tensor_a.tolist())
        set_b = set(tensor_b.tolist())
        return set_a.issubset(set_b)
    
    def Ä_cluster(self, tensors: List[torch.Tensor]) -> List[List[torch.Tensor]]:
        """Ä: Cluster similar tensors into equivalence classes."""
        if len(tensors) <= 1:
            return [tensors]
        
        similarity_threshold = 0.5
        clusters = []
        assigned = [False] * len(tensors)
        
        for i in range(len(tensors)):
            if not assigned[i]:
                current_cluster = [tensors[i]]
                assigned[i] = True
                
                for j in range(i + 1, len(tensors)):
                    if not assigned[j]:
                        if self.ä_jaccard(tensors[i], tensors[j]) > similarity_threshold:
                            current_cluster.append(tensors[j])
                            assigned[j] = True
                
                clusters.append(current_cluster)
        
        return clusters
    
    # ─────────────────────────────────────────────────────────────────────────
    # FORWARD PASS
    # ─────────────────────────────────────────────────────────────────────────
    
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Forward pass using set-theoretic operations.
        
        Args:
            x: Image tensor of shape (batch, 784) or (batch, 28, 28)
            
        Returns:
            Class logits of shape (batch, 10)
        """
        batch_size = x.shape[0]
        
        # ─────────────────────────────────────────────────────────────────────
        # STEP 1: Encode image as set (A + B + D + G)
        # ─────────────────────────────────────────────────────────────────────
        
        # MNIST batches arrive as (B, 1, 28, 28); also accept already-flattened
        # inputs and bare (B, 28, 28) tensors.
        if x.dim() > 2:
            x = x.flatten(1)
        
        # Apply learnable threshold
        active_mask = x > torch.sigmoid(self.learnable_threshold)
        
        # Apply pixel weights
        weighted = x * self.pixel_weights.unsqueeze(0)
        
        # ─────────────────────────────────────────────────────────────────────
        # STEP 2: Project to embedding space (F: Function mapping)
        # ─────────────────────────────────────────────────────────────────────
        
        embedded = self.image_proj(weighted)
        
        # T: Topology-preserving projection
        embedded = self.topology_proj(embedded)
        
        # ─────────────────────────────────────────────────────────────────────
        # STEP 3: Compute set-based similarities
        # ─────────────────────────────────────────────────────────────────────
        
        # Normalize prototypes
        proto_normalized = F.normalize(self.class_prototypes, dim=1)
        embedded_normalized = F.normalize(embedded, dim=1)
        
        # Compute base similarities (cosine)
        similarities = torch.mm(embedded_normalized, proto_normalized.T)
        
        # ─────────────────────────────────────────────────────────────────────
        # STEP 4: Set operation enhancements
        # ─────────────────────────────────────────────────────────────────────
        
        if self.use_jaccard or self.use_intersection:
            # Compute set-based metrics per sample
            batch_scores = []
            
            for b in range(batch_size):
                sample_scores = []
                
                for c in range(self.num_classes):
                    # Compare in embedding space so sample and prototype live in
                    # the same universe and have compatible dimensionality.
                    sample_active = (embedded_normalized[b] > 0).long()
                    
                    if self.use_intersection:
                        # I: Intersection-based overlap
                        # For simplicity, use dot product as intersection proxy
                        intersection_score = (
                            sample_active * (proto_normalized[c] > 0).long()
                        ).sum().float()
                        intersection_score = intersection_score / max(sample_active.sum().float(), 1)
                        sample_scores.append(intersection_score)
                    else:
                        sample_scores.append(0.0)
                
                batch_scores.append(torch.stack(sample_scores))
            
            set_scores = torch.stack(batch_scores)
            
            # Combine cosine similarity with set similarity
            combined = similarities + 0.3 * set_scores.to(similarities.device)
        else:
            combined = similarities
        
        # ─────────────────────────────────────────────────────────────────────
        # STEP 5: Output projection
        # ─────────────────────────────────────────────────────────────────────
        
        logits = self.output_proj(combined)
        
        return logits
    
    def get_set_representation(self, x: torch.Tensor) -> Dict[str, torch.Tensor]:
        """
        Get intermediate set representations for analysis.
        
        Returns:
            Dictionary with set operations applied
        """
        if x.dim() > 2:
            x = x.flatten(1)
        
        # Active pixels
        active_mask = x > torch.sigmoid(self.learnable_threshold)
        
        return {
            'threshold': torch.sigmoid(self.learnable_threshold),
            'active_mask': active_mask,
            'pixel_weights': self.pixel_weights,
            'prototypes': self.class_prototypes,
        }


# ═══════════════════════════════════════════════════════════════════════════════
# PART IV: TRAINER WITH LETTER OPERATOR LOGGING
# ═══════════════════════════════════════════════════════════════════════════════

class LetterOperatorTrainer:
    """
    Trainer that logs operations using letter operator naming.
    """
    
    def __init__(
        self,
        model: SetMNISTClassifier,
        device: str = 'cuda' if torch.cuda.is_available() else 'cpu'
    ):
        self.model = model.to(device)
        self.device = device
        self.ops = LetterOperators()
        self.epoch_log = []
    
    def train_epoch(self, dataloader, optimizer, criterion):
        """
        Train for one epoch with letter operator logging.
        """
        self.model.train()
        total_loss = 0.0
        correct = 0
        total = 0
        
        for batch_idx, (images, labels) in enumerate(dataloader):
            images = images.to(self.device)
            labels = labels.to(self.device)
            
            # ─────────────────────────────────────────────────────────────────
            # FORWARD: G (Union) of all set operations
            # ─────────────────────────────────────────────────────────────────
            
            optimizer.zero_grad()
            outputs = self.model(images)  # Uses A, B, D, F, T, I, G internally
            
            # I: Intersection between prediction and label
            loss = criterion(outputs, labels)
            
            # BACKWARD: Propagate through all letter operators
            loss.backward()
            optimizer.step()
            
            # Metrics
            total_loss += loss.item()
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()
            
            # Log every 100 batches
            if batch_idx % 100 == 0:
                self._log_operation('TRAIN', batch_idx, {
                    'B (batch_size)': images.size(0),
                    'G (accumulated_loss)': total_loss,
                    'I (correct)': correct,
                    'M (total)': total,
                    'S (accuracy)': 100. * correct / total
                })
        
        return total_loss / len(dataloader), 100. * correct / total
    
    def evaluate(self, dataloader, criterion):
        """
        Evaluate model with letter operator metrics.
        """
        self.model.eval()
        total_loss = 0.0
        correct = 0
        total = 0
        
        with torch.no_grad():
            for images, labels in dataloader:
                images = images.to(self.device)
                labels = labels.to(self.device)
                
                outputs = self.model(images)
                loss = criterion(outputs, labels)
                
                total_loss += loss.item()
                _, predicted = outputs.max(1)
                total += labels.size(0)
                correct += predicted.eq(labels).sum().item()
        
        return total_loss / len(dataloader), 100. * correct / total
    
    def _log_operation(self, phase: str, step: int, metrics: Dict[str, Any]):
        """Log operation using letter operator terminology."""
        log_entry = f"[{phase}] Step {step}: " + \
                    " | ".join([f"{op}={val:.4f}" if isinstance(val, float) else f"{op}={val}" 
                               for op, val in metrics.items()])
        print(log_entry)


# ═══════════════════════════════════════════════════════════════════════════════
# PART V: COMPLETE TRAINING PIPELINE
# ═══════════════════════════════════════════════════════════════════════════════

def main():
    """
    Main training pipeline for Set MNIST Classifier.
    """
    # ─────────────────────────────────────────────────────────────────────────
    # CONFIGURATION
    # ─────────────────────────────────────────────────────────────────────────
    
    print("""
╔══════════════════════════════════════════════════════════════════════════════╗
║                   SET-MNIST CLASSIFIER TRAINING PIPELINE                      ║
║                                                                               ║
║  Using Letter Operators: A B C D E F G H I J K L M N O P R S T U V W X Y Z Å Ä Ö
╚══════════════════════════════════════════════════════════════════════════════╝
    """)
    
    # Hyperparameters
    GRID_SIZE = 28
    THRESHOLD = 0.5
    EMBEDDING_DIM = 128
    BATCH_SIZE = 64
    EPOCHS = 10
    LEARNING_RATE = 0.001
    
    # Device
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    print(f"[A] Universal Context: Device = {device}")
    
    # ─────────────────────────────────────────────────────────────────────────
    # LOAD DATA (D: Domain restriction to MNIST)
    # ─────────────────────────────────────────────────────────────────────────
    
    print("\n[G] Loading MNIST dataset...")
    
    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.1307,), (0.3081,))
    ])
    
    train_dataset = torchvision.datasets.MNIST(
        root='./data',
        train=True,
        download=True,
        transform=transform
    )
    
    test_dataset = torchvision.datasets.MNIST(
        root='./data',
        train=False,
        download=True,
        transform=transform
    )
    
    # Single-process loading avoids multiprocessing socket setup failures in
    # restricted environments and is sufficient for this script.
    train_loader = torch.utils.data.DataLoader(
        train_dataset,
        batch_size=BATCH_SIZE,
        shuffle=True,
        num_workers=0
    )
    
    test_loader = torch.utils.data.DataLoader(
        test_dataset,
        batch_size=BATCH_SIZE,
        shuffle=False,
        num_workers=0
    )
    
    print(f"[M] Training set size: {LetterOperators.M(torch.arange(len(train_dataset)))}")
    print(f"[M] Test set size: {LetterOperators.M(torch.arange(len(test_dataset)))}")
    
    # ─────────────────────────────────────────────────────────────────────────
    # INITIALIZE MODEL (B: Boolean enclosure of parameters)
    # ─────────────────────────────────────────────────────────────────────────
    
    print("\n[B] Initializing Set-MNIST Classifier...")
    
    model = SetMNISTClassifier(
        grid_size=GRID_SIZE,
        threshold=THRESHOLD,
        num_classes=10,
        embedding_dim=EMBEDDING_DIM,
        use_jaccard=True,
        use_intersection=True,
        use_symmetric_diff=True
    ).to(device)
    
    # Print model structure using letter operators
    print("\n[Z] Model Architecture (ZF Comprehension of layers):")
    total_params = sum(p.numel() for p in model.parameters())
    print(f"   [M] Total parameters: {total_params:,}")
    print(f"   [Ö] Supremum of embedding dimension: {EMBEDDING_DIM}")
    
    # ─────────────────────────────────────────────────────────────────────────
    # TRAINING (I: Intersection of prediction and target)
    # ─────────────────────────────────────────────────────────────────────────
    
    print("\n[T] Starting Training...")
    print("=" * 80)
    
    trainer = LetterOperatorTrainer(model, device)
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE)
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)
    
    best_accuracy = 0.0
    
    for epoch in range(EPOCHS):
        print(f"\n[EPOCH {epoch + 1}/{EPOCHS}]")
        print("-" * 40)
        
        # Train
        train_loss, train_acc = trainer.train_epoch(train_loader, optimizer, criterion)
        
        # Evaluate
        test_loss, test_acc = trainer.evaluate(test_loader, criterion)
        
        # Update scheduler
        scheduler.step()
        
        # Log
        print(f"[I] Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}%")
        print(f"[I] Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.2f}%")
        
        # Best model
        if test_acc > best_accuracy:
            best_accuracy = test_acc
            print(f"[Ö] New best accuracy: {best_accuracy:.2f}%")
    
    # ─────────────────────────────────────────────────────────────────────────
    # FINAL EVALUATION
    # ─────────────────────────────────────────────────────────────────────────
    
    print("\n" + "=" * 80)
    print("[Ö] Training Complete")
    print("=" * 80)
    
    print(f"\n[G] Final Results:")
    print(f"   [M] Best Test Accuracy: {best_accuracy:.2f}%")
    
    # Analyze learned prototypes
    print("\n[Ä] Learned Digit Prototypes (equivalence classes):")
    with torch.no_grad():
        prototypes = F.normalize(model.class_prototypes, dim=1)
        
        # Compute inter-class distances (V: Symmetric difference proxy)
        for d1 in range(10):
            for d2 in range(d1 + 1, 10):
                dist = torch.norm(prototypes[d1] - prototypes[d2]).item()
                if dist < 0.5:
                    print(f"   [V] Digits {d1} and {d2}: Similar (dist={dist:.3f})")
    
    # Save model
    torch.save(model.state_dict(), 'set_mnist_classifier.pth')
    print("\n[B] Model saved to set_mnist_classifier.pth")
    
    # ─────────────────────────────────────────────────────────────────────────
    # DEMONSTRATE LETTER OPERATOR USAGE
    # ─────────────────────────────────────────────────────────────────────────
    
    print("\n" + "=" * 80)
    print("[Z] LETTER OPERATOR USAGE DEMONSTRATION")
    print("=" * 80)
    
    # Get a sample batch
    sample_images, sample_labels = next(iter(test_loader))
    sample_images = sample_images[:5].to(device)
    
    with torch.no_grad():
        set_repr = model.get_set_representation(sample_images)
        
        print("\n[A] Universal set (all pixel indices):")
        print(f"    Range: [0, {model.num_pixels - 1}]")
        
        print(f"\n[B] Sample singleton (first active pixel):")
        first_active = torch.where(set_repr['active_mask'][0])[0]
        if len(first_active) > 0:
            print(f"    {LetterOperators.B(first_active[0].item())}")
        
        print(f"\n[D] Domain restriction (pixels above threshold {set_repr['threshold'].item():.3f}):")
        active_count = set_repr['active_mask'][0].sum().item()
        print(f"    Active pixels per image: {active_count}")
        
        print(f"\n[G] Union of pixel weights:")
        print(f"    Sum: {set_repr['pixel_weights'].sum().item():.3f}")
        
        print(f"\n[M] Cardinality of prototype space:")
        print(f"    Each prototype: {EMBEDDING_DIM} dimensions")
        
        print(f"\n[V] Symmetric difference (prototype variance):")
        proto_std = model.class_prototypes.std().item()
        print(f"    Standard deviation: {proto_std:.3f}")
        
        print(f"\n[ä] Jaccard similarity (sample to prototypes):")
        sample_emb = model.image_proj(sample_images[0].flatten().unsqueeze(0))
        sample_emb = F.normalize(sample_emb, dim=1)
        prototypes = F.normalize(model.class_prototypes, dim=1)
        similarities = torch.mm(sample_emb, prototypes.T)[0]
        for d in range(5):
            print(f"    Digit {d}: {similarities[d].item():.3f}")
    
    return model, best_accuracy


if __name__ == '__main__':
    model, accuracy = main()
