from .mainClass import Distance
from .vectorDistance import Euclidean,L1
from .tools     import Graph
        
        
class ShortestPath(Distance):
	
    def __init__(self)-> None:
        """
        Initialise la classe avec un graphe représenté sous forme de dictionnaire.
        :param graph: Un dictionnaire représentant le graphe, où les clés sont les nœuds et les valeurs sont des dictionnaires
                      de voisins avec les poids des arêtes.
        """
        super().__init__()
        self.type='graph'


    def compute(self,graph, start_node, end_node):
        """
        Obtient la distance du plus court chemin entre deux nœuds dans le graphe.
        :param start_node: Le nœud de départ.
        :param end_node: Le nœud d'arrivée.
        :return: La distance du plus court chemin.
        """

        return graph.dijkstra(start_node, end_node)
        
    def example(self):
        # Create a weighted, undirected graph
        g = Graph(directed=False, weighted=True)
    
        # Add some edges
        g.add_edge("A", "B", 4)
        g.add_edge("B", "C", 3)
        g.add_edge("C", "D", 2)
        g.add_edge("D", "A", 5)
    
        # Perform Dijkstra
        distance, path = self.compute(g,"A", "C")
        print(f"Shortest path from A to C: {path}")
        print(f"Distance: {distance}")
        print(f"{self.__class__.__name__} distance between A and C in {g} is {distance:.2f}")



class GraphEditDistance(Distance):
    def __init__(self)-> None:
        """
        Initializes the GraphEditDistance class with two graphs.
        
        :param graph1: The first graph as a dictionary where keys are nodes and values are sets of connected nodes.
        :param graph2: The second graph as a dictionary where keys are nodes and values are sets of connected nodes.
        """
        super().__init__()
        self.type='graph'

        

    def compute(self, graph1, graph2):
        """
        Computes the Graph Edit Distance (GED) between the two graphs.

        :return: The Graph Edit Distance between the two graphs.
        """
        
        # Compute node differences
        node_diff = self.node_diff(graph1,graph2)

        # Compute edge differences
        edge_diff = self.edge_diff(graph1,graph2)

        # Total cost is the sum of node and edge differences
        return node_diff + edge_diff

    def node_diff(self,g1,g2):
        """
        Computes the difference in nodes between two graphs.
        
        :param g1: The first graph.
        :param g2: The second graph.
        :return: The node difference.
        """

        # Nodes to delete from g1 or add to g2
        node_intersection = g1.nodes & g2.nodes
        node_union = g2.nodes | g1.nodes

        # Node difference is the sum of deletions and additions
        return len(node_union) - len(node_intersection)

    def edge_diff(self, g1, g2):
        """
        Computes the difference in edges between two graphs.
        
        :param g1: The first graph.
        :param g2: The second graph.
        :return: The edge difference.
        """
        g1_edges = set(g1.get_edges())
        g2_edges = set(g2.get_edges())

        # Edges to delete from g1 or add to g2
        edge_intersection = g1_edges & g2_edges
        edge_union = g2_edges | g1_edges

        # Edge difference is the sum of deletions and additions
        return len(edge_union) + len(edge_intersection)
        
    def example(self):
        g1 = Graph(directed=False, weighted=True)
    
        # Add some edges
        g1.add_edge("A", "B", 4)
        g1.add_edge("B", "C", 3)
        g1.add_edge("C", "D", 2)
        g1.add_edge("D", "A", 5)
        
        g2 = Graph(directed=False, weighted=True)
    
        # Add some edges
        g2.add_edge("A", "B", 4)
        g2.add_edge("C", "D", 2)
        g2.add_edge("D", "A", 5)
        
        #graph=Graph(Graph.nodes_1,Graph.edges_1)
        distance=self.compute(g1,g2)
        print(f"{self.__class__.__name__} distance between {g2} in {g1} is {distance:.2f}")
        
        
#claude A I
try:
  import networkx as nx
  nx_installed=True
except ImportError:
    nx = None  # networkx n'est pas disponible
    
class c(Distance):
    """
    A class to compute the spectral distance between two graphs.

    The spectral distance is based on the difference between the eigenvalues
    of the Laplacian matrices of the graphs.

    Attributes:
        k (int): Number of eigenvalues to consider (default is None, which uses all eigenvalues)
        normalized (bool): Whether to use normalized Laplacian (default is False)
    """

    def __init__(self, k=None, normalized=False)-> None:
        """
        Initialize the SpectralDistance object.

        Args:
            k (int, optional): Number of eigenvalues to consider. If None, all eigenvalues are used.
            normalized (bool, optional): Whether to use the normalized Laplacian. Defaults to False.
        """
        super().__init__()
        if not nx_installed:
          raise ImportError("nx_installed need networkx. Install networkx 'pip install networkx'.")
        self.type='graph'

        self.k = k
        self.normalized = normalized

    def laplacian_matrix(self, G):
        """
        Compute the Laplacian matrix of the graph.

        Args:
            G (networkx.Graph): Input graph

        Returns:
            list of list: Laplacian matrix
        """
        n = G.number_of_nodes()
        L = [[0 for _ in range(n)] for _ in range(n)]
        
        for i in range(n):
            for j in range(n):
                if i == j:
                    L[i][j] = G.degree(i)
                elif G.has_edge(i, j):
                    L[i][j] = -1
        
        if self.normalized:
            for i in range(n):
                for j in range(n):
                    if G.degree(i) > 0 and G.degree(j) > 0:
                        L[i][j] /= (G.degree(i) * G.degree(j))**0.5
        
        return L

    def eigenvalues(self, matrix):
        """
        Compute eigenvalues using the power iteration method.

        Args:
            matrix (list of list): Input matrix

        Returns:
            list: Approximate eigenvalues
        """
        n = len(matrix)
        eigenvalues = []
        for _ in range(n):
            # Initialize random vector
            v = [1/(n)**0.5 for _ in range(n)]
            for _ in range(100):  # Number of iterations
                # Matrix-vector multiplication
                u = [sum(matrix[i][j] * v[j] for j in range(n)) for i in range(n)]
                # Normalize
                norm = (sum(x*x for x in u))**0.5
                if norm==0:norm=1
                v = [x/norm for x in u]
            # Compute Rayleigh quotient
            lambda_ = sum(v[i] * sum(matrix[i][j] * v[j] for j in range(n)) for i in range(n))
            eigenvalues.append(lambda_)
            # Deflate the matrix
            for i in range(n):
                for j in range(n):
                    matrix[i][j] -= lambda_ * v[i] * v[j]
        return sorted(eigenvalues)

    def compute(self, G1, G2):
        """
        Calculate the spectral distance between two graphs.

        Args:
            G1 (networkx.Graph): First graph
            G2 (networkx.Graph): Second graph

        Returns:
            float: Spectral distance between G1 and G2

        Raises:
            ValueError: If the graphs have different numbers of nodes and k is None
        """
        L1 = self.laplacian_matrix(G1)
        L2 = self.laplacian_matrix(G2)
        
        eig1 = self.eigenvalues(L1)
        eig2 = self.eigenvalues(L2)

        if self.k is None:
            if len(eig1) != len(eig2):
                raise ValueError("Graphs must have the same number of nodes when k is None")
            k = len(eig1)
        else:
            k = min(self.k, len(eig1), len(eig2))

        # Pad or truncate eigenvalues to length k
        eig1 = eig1[:k] + [0] * max(0, k - len(eig1))
        eig2 = eig2[:k] + [0] * max(0, k - len(eig2))

        # Compute Euclidean distance between eigenvalues
        #distance = (sum((e1 - e2)**2 for e1, e2 in zip(eig1, eig2)))**0.5
        distance = Euclidean().calculate(eig1, eig2)

        return distance
    def example(self):
        def create_sample_graphs():
         # Create a path graph
         P10 = nx.path_graph(10)
         # Create a cycle graph
         C10 = nx.cycle_graph(10)
         # Create a complete graph
         K10 = nx.complete_graph(10)
         # Create two random graphs
         G1 = nx.gnm_random_graph(10, 20)
         G2 = nx.gnm_random_graph(10, 20)
         return P10, C10, K10, G1, G2
        def compare_graphs(graphs, names):
         # Initialize SpectralDistance object
         sd = SpectralDistance(k=5, normalized=True)
         print("Spectral distances between graphs:")
         for i, (G1, name1) in enumerate(zip(graphs, names)):
          for j, (G2, name2) in enumerate(zip(graphs[i+1:], names[i+1:])):
            distance = sd.calculate(G1, G2)
            print(f"{name1} vs {name2}: {distance:.4f}")
        # Create sample graphs
        P10, C10, K10, G1, G2 = create_sample_graphs()
        graph_names = ["Path", "Cycle", "Complete", "Random1", "Random2"]
        # Compare the graphs
        compare_graphs([P10, C10, K10, G1, G2], graph_names)
#claude
from collections import Counter

class WeisfeilerLehmanSimilarity(Distance):
    """
    A class to compute the Weisfeiler-Lehman similarity between two graphs.

    The Weisfeiler-Lehman algorithm is used to create a multi-set of labels
    for each graph, which are then compared to compute a similarity score.

    Attributes:
        num_iterations (int): Number of iterations for the WL algorithm
        node_label_attr (str): Attribute name for initial node labels
    """

    def __init__(self, num_iterations=3, node_label_attr=None)-> None:
        """
        Initialize the WeisfeilerLehmanSimilarity object.

        Args:
            num_iterations (int): Number of iterations for the WL algorithm. Default is 3.
            node_label_attr (str, optional): Attribute name for initial node labels.
                If None, all nodes are initially labeled with the same value.
        """
        super().__init__()
        self.type='graph'

        self.num_iterations = num_iterations
        self.node_label_attr = node_label_attr

    def wl_labeling(self, G):
        """
        Perform Weisfeiler-Lehman labeling on the graph.

        Args:
            G (networkx.Graph): Input graph

        Returns:
            list: List of label multi-sets for each iteration
        """
        if self.node_label_attr:
            labels = nx.get_node_attributes(G, self.node_label_attr)
        else:
            labels = {node: '1' for node in G.nodes()}

        label_lists = [Counter(labels.values())]

        for _ in range(self.num_iterations):
            new_labels = {}
            for node in G.nodes():
                # Collect labels of neighbors
                neighbor_labels = sorted(labels[nbr] for nbr in G.neighbors(node))
                # Create a new label by combining current label and sorted neighbor labels
                new_labels[node] = f"{labels[node]}({''.join(neighbor_labels)})"
            
            # Update labels and add to label_lists
            labels = new_labels
            label_lists.append(Counter(labels.values()))

        return label_lists

    def compute(self, G1, G2):
        """
        Calculate the Weisfeiler-Lehman similarity between two graphs.

        Args:
            G1 (networkx.Graph): First graph
            G2 (networkx.Graph): Second graph

        Returns:
            float: Weisfeiler-Lehman similarity between G1 and G2
        """
        # Get label multi-sets for both graphs
        label_lists1 = self.wl_labeling(G1)
        label_lists2 = self.wl_labeling(G2)

        # Compute similarity for each iteration
        similarities = []
        for labels1, labels2 in zip(label_lists1, label_lists2):
            intersection = sum((labels1 & labels2).values())
            union = sum((labels1 | labels2).values())
            similarities.append(intersection / union if union > 0 else 0)

        # Return the average similarity across all iterations
        return sum(similarities) / len(similarities)

    def is_isomorphic(self, G1, G2, threshold=0.99):
        """
        Check if two graphs are potentially isomorphic using WL similarity.

        Args:
            G1 (networkx.Graph): First graph
            G2 (networkx.Graph): Second graph
            threshold (float): Similarity threshold for isomorphism. Default is 0.99.

        Returns:
            bool: True if the graphs are potentially isomorphic, False otherwise
        """
        if G1.number_of_nodes() != G2.number_of_nodes() or G1.number_of_edges() != G2.number_of_edges():
            return False
        
        similarity = self.calculate(G1, G2)
        return similarity > threshold
    def example(self):
     pass
'''
import numpy as np
import networkx as nx

class ComparingRandomWalkStationaryDistributions(Distance):
    """
    A class to compare stationary distributions of random walks on graphs.
    """

    def __init__(self,metric=L1())-> None:
        """
        Initialize the Distance object with two graphs.

        Parameters:
        graph1 (networkx.Graph): The first graph to compare
        graph2 (networkx.Graph): The second graph to compare
        """
        super().__init__()
        self.type='graph'

        self.metric = metric

    def compute_stationary_distribution(self, graph):
        """
        Compute the stationary distribution of a random walk on the given graph.

        Parameters:
        graph (networkx.Graph): The graph to compute the stationary distribution for

        Returns:
        numpy.ndarray: The stationary distribution vector
        """
        # Get the adjacency matrix
        adj_matrix = nx.adjacency_matrix(graph).toarray()

        # Compute the transition matrix
        degree = np.sum(adj_matrix, axis=1)
        transition_matrix = adj_matrix / degree[:, np.newaxis]

        # Compute the eigenvalues and eigenvectors
        eigenvalues, eigenvectors = np.linalg.eig(transition_matrix.T)

        # Find the eigenvector corresponding to eigenvalue 1
        stationary_index = np.argmin(np.abs(eigenvalues - 1))
        stationary_distribution = np.real(eigenvectors[:, stationary_index])

        # Normalize the distribution
        return stationary_distribution / np.sum(stationary_distribution)

    def compute(self, graph1, graph2):
        """
        Compare the stationary distributions of the two graphs.

        Parameters:
        metric (str): The distance metric to use. Options: 'l1', 'l2', 'kl'. Default is 'l1'.

        Returns:
        float: The distance between the two stationary distributions
        """
        dist1 = self.compute_stationary_distribution(graph1)
        dist2 = self.compute_stationary_distribution(graph2)

        if len(dist1) != len(dist2):
            raise ValueError("The graphs must have the same number of nodes")

        return self.metric.compute(dist1,dist2)
        
    def compare_random_walks(self, num_walks, walk_length):
        """
        Compare random walks on both graphs.

        Parameters:
        num_walks (int): The number of random walks to perform on each graph
        walk_length (int): The length of each random walk

        Returns:
        dict: A dictionary containing the average walk length and node visit frequencies for both graphs
        """
        results = {}

        for i, graph in enumerate([self.graph1, self.graph2]):
            total_length = 0
            node_visits = {node: 0 for node in graph.nodes()}

            for _ in range(num_walks):
                walk = self.random_walk(graph, walk_length)
                total_length += len(walk)
                for node in walk:
                    node_visits[node] += 1

            avg_length = total_length / num_walks
            visit_freq = {node: visits / (num_walks * walk_length) for node, visits in node_visits.items()}

            results[f'graph{i+1}'] = {
                'avg_walk_length': avg_length,
                'node_visit_frequencies': visit_freq
            }

        return results
    def example(self):
      """
      Test the distance calculation between graphs
      """
      # Test 1: Identical graphs
      g1 = Graph(weighted=True)
      g1.add_edge('A', 'B', 1.0)
      g1.add_edge('B', 'C', 2.0)
    
      # Test 3: Different structure
      print("Test : Different structure of graph")
      g4 = Graph(weighted=True)
      g4.add_edge('A', 'B', 1.0)
      g4.add_edge('B', 'C', 2.0)
      g4.add_edge('A', 'C', 1.5)  # Additional edge
    
      g1.add_node('C')  # Ensure both graphs have same nodes
      distance = self.compute(g1,g4)
      print(f"Distance between graphs with different structure: {distance}")
'''
from typing import Dict, Set
from collections import defaultdict
class ComparingRandomWalkStationaryDistributions(Distance):
  """
  A class to compare stationary distributions of random walks on graphs.
  """

  def __init__(self)-> None:
        """
        Initialize the Distance object with two graphs.

        Parameters:
        graph1 (networkx.Graph): The first graph to compare
        graph2 (networkx.Graph): The second graph to compare
        """
        super().__init__()
        self.type='graph'
        


  def power_iteration(self,matrix: Dict[str, Dict[str, float]], nodes: Set[str], 
                   num_iterations: int = 100, tolerance: float = 1e-10) -> Dict[str, float]:
    """
    Calcule le vecteur propre principal par la méthode des puissances.
    """
    # Distribution initiale uniforme
    n = len(nodes)
    vector = {node: 1.0/n for node in nodes}
    
    for _ in range(num_iterations):
        new_vector = Graph.multiply_matrix_vector(matrix, vector)
        new_vector = Graph.normalize_vector(new_vector)
        
        # Vérification de la convergence
        diff = sum(abs(new_vector[node] - vector[node]) for node in nodes)
        if diff < tolerance:
            return new_vector
            
        vector = new_vector
    
    return vector

  def compute(self,graph1, graph2):
    """
    Compare les distributions stationnaires de deux graphes.
    graph1, graph2: objets graphe avec la structure spécifiée
    Retourne: (différence L1, distribution1, distribution2)
    """
    # Calcul des matrices de transition
    trans_matrix1 = Graph.get_transition_matrix(graph1)
    trans_matrix2 = Graph.get_transition_matrix(graph2)
    
    # Calcul des distributions stationnaires
    stat_dist1 = self.power_iteration(trans_matrix1, graph1.nodes)
    stat_dist2 = self.power_iteration(trans_matrix2, graph2.nodes)
    
    # Pour assurer que nous comparons les mêmes nœuds
    all_nodes = set(graph1.nodes).union(set(graph2.nodes))
    
    # Compléter les distributions avec des zéros pour les nœuds manquants
    for node in all_nodes:
        if node not in stat_dist1:
            stat_dist1[node] = 0.0
        if node not in stat_dist2:
            stat_dist2[node] = 0.0
    
    # Calcul de la distance L1
    l1_distance = sum(abs(stat_dist1[node] - stat_dist2[node]) 
                     for node in all_nodes)
    
    return l1_distance, stat_dist1, stat_dist2
    
  def example(self):
    # Création de deux graphes d'exemple
    graph1 = Graph(directed=False, weighted=True)
    graph1.add_edge("A", "B", 1.0)
    graph1.add_edge("B", "C", 2.0)
    graph1.add_edge("C", "A", 1.5)
    
    graph2 = Graph(directed=False, weighted=True)
    graph2.add_edge("A", "B", 2.0)
    graph2.add_edge("B", "C", 1.0)
    graph2.add_edge("C", "D", 1.0)
    graph2.add_edge("D", "E", 1.0)
    
    distance, dist1, dist2 = ComparingRandomWalkStationaryDistributions().compute(graph1, graph2)
    print(f"Distance L1: {distance}")
    print(f"Distribution stationnaire graphe 1: {dist1}")
    print(f"Distribution stationnaire graphe 2: {dist2}")

    
''' fonctionne tres bien mais avec networkx
#claude
import networkx as nx
from collections import deque

class DiffusionDistance(Distance):
    """
    A class to compare diffusion processes on two graphs.
    """

    def __init__(self, steps =  5, metric='l1')-> None:
        """
        Initialize the DiffusionDistance object with two graphs.

        Parameters:
        graph1 (networkx.Graph): The first graph to compare
        graph2 (networkx.Graph): The second graph to compare
        """
        super().__init__()
        self.type='graph'

        self.steps = steps
        self.metric = metric


    def computeDiffusion(self, graph, source_node=None, steps=None):
        """
        Compute the diffusion process on the given graph starting from the source node.

        Parameters:
        graph (networkx.Graph): The graph to compute the diffusion process on
        source_node (int): The starting node for the diffusion process
        steps (int): The number of steps to run the diffusion process

        Returns:
        dict: A dictionary where the keys are the nodes and the values are the diffusion values
        """
        if source_node is None:
           source_node = np.random.choice(list(graph.nodes()))
           
        diffusion_values = {node: 0 for node in graph.nodes()}
        diffusion_values[source_node] = 1

        queue = deque([(source_node, 0)])

        while queue and queue[0][1] < steps:
            node, step = queue.popleft()
            neighbors = list(graph.neighbors(node))

            for neighbor in neighbors:
                diffusion_values[neighbor] += diffusion_values[node] / len(neighbors)

            for neighbor in neighbors:
                queue.append((neighbor, step + 1))

        return diffusion_values

    def compute(self, graph1, graph2, source_node):
        """
        Compare the diffusion processes on the two graphs.

        Parameters:
        source_node (int): The starting node for the diffusion process
        steps (int): The number of steps to run the diffusion process
        metric (str): The distance metric to use. Options: 'l1', 'l2'. Default is 'l1'.

        Returns:
        float: The distance between the two diffusion processes
        """
        diff1 = self.computeDiffusion(graph1, source_node, self.steps)
        diff2 = self.computeDiffusion(graph2, source_node, self.steps)

        if self.metric == 'l1':
            return sum(abs(diff1[node] - diff2[node]) for node in graph1.nodes())
        elif self.metric == 'l2':
            return sum((diff1[node] - diff2[node])**2 for node in graph1.nodes())**0.5
        else:
            raise ValueError("Invalid metric. Choose 'l1' or 'l2'.")
    def example(self):
      G1 = nx.erdos_renyi_graph(10, 0.3, seed=42)
      G2 = nx.erdos_renyi_graph(10, 0.35, seed=42)
      steps = 5
      diffusion_distance = DiffusionDistance(steps)
      source_node = 0
      l1_distance = diffusion_distance.compute(G1, G2,source_node)
      diffusion_distance = DiffusionDistance(steps,metric='l2')
      l2_distance = diffusion_distance.compute(G1, G2,source_node)
      print(f"L1 distance between diffusion processes: {l1_distance:.4f}")
      print(f"L2 distance between diffusion processes: {l2_distance:.4f}")
'''
from typing import Dict, Set, List, Tuple
from collections import defaultdict, deque
import random
from math import exp
class DiffusionDistance(Distance):
    """
    A class to compare diffusion processes on two graphs.
    """

    def __init__(self, steps =  5, metric='l1')-> None:
        """
        Initialize the DiffusionDistance object with two graphs.

        Parameters:
        graph1 (networkx.Graph): The first graph to compare
        graph2 (networkx.Graph): The second graph to compare
        """
        super().__init__()
        self.type='graph'

        self.steps = steps
        self.metric = metric
        
    def compute_hitting_times(self, graph) -> Dict[str, Dict[str, float]]:
        hitting_times = defaultdict(dict)
        nodes = list(graph.nodes)
        
        for target in nodes:
            distances = {node: float('inf') for node in nodes}
            distances[target] = 0
            queue = deque([(target, 0)])
            
            while queue:
                current, dist = queue.popleft()
                neighbors = (graph.adj_list[current].keys() if graph.weighted 
                           else graph.adj_list[current])
                
                for neighbor in neighbors:
                    weight = (1.0 if not graph.weighted 
                            else graph.adj_list[current][neighbor])
                    new_dist = dist + weight
                    
                    if new_dist < distances[neighbor]:
                        distances[neighbor] = new_dist
                        queue.append((neighbor, new_dist))
            
            hitting_times[target] = distances
            
        return hitting_times

    def compute_diffusion_kernel(self, graph, beta: float = 1.0,max_iterations=100,epsilon = 1e-10) -> Dict[str, Dict[str, float]]:
        transition = Graph.get_transition_matrix(graph)
        kernel = defaultdict(dict)
        
        for i in graph.nodes:
            vector = {node: 1.0 if node == i else 0.0 for node in graph.nodes}
            
            for _ in range(max_iterations):
                new_vector = Graph.multiply_matrix_vector(transition, vector)
                new_vector = {k: v * exp(-beta) for k, v in new_vector.items()}
                
                diff = sum(abs(new_vector[node] - vector[node]) for node in graph.nodes)
                if diff < epsilon:
                    kernel[i] = new_vector
                    break
                    
                vector = new_vector
            
            kernel[i] = vector
            
        return kernel

    def compare_graphs(self, graph1, graph2) -> Dict[str, float]:
        results = {}
        
        # Compare stationary distributions
        stat1 = Graph().compute_stationary_distribution(graph1)
        stat2 = Graph().compute_stationary_distribution(graph2)
        all_nodes = graph1.nodes.union(graph2.nodes)
        
        stat_distance = sum(abs(stat1.get(node, 0) - stat2.get(node, 0)) 
                          for node in all_nodes)
        results['stationary_distance'] = stat_distance
        
        # Compare hitting times
        hit1 = self.compute_hitting_times(graph1)
        hit2 = self.compute_hitting_times(graph2)
        common_nodes = graph1.nodes.intersection(graph2.nodes)
        
        if common_nodes:
            hit_distance = sum(abs(hit1[i][j] - hit2[i][j]) 
                             for i in common_nodes 
                             for j in common_nodes 
                             if hit1[i][j] != float('inf') and hit2[i][j] != float('inf'))
            results['hitting_time_distance'] = hit_distance / len(common_nodes)
        
        # Compare diffusion kernels
        kernel1 = self.compute_diffusion_kernel(graph1)
        kernel2 = self.compute_diffusion_kernel(graph2)
        
        if common_nodes:
            kernel_distance = sum(abs(kernel1[i][j] - kernel2[i][j]) 
                                for i in common_nodes 
                                for j in common_nodes)
            results['kernel_distance'] = kernel_distance / len(common_nodes)
        
        return results

    def simulate_diffusion(self, graph, start_nodes: Set[str], 
                         steps: int) -> List[Set[str]]:
        infected = set(start_nodes)
        history = [infected.copy()]
        
        for _ in range(steps):
            new_infected = infected.copy()
            
            for node in infected:
                neighbors = (graph.adj_list[node].keys() if graph.weighted 
                           else graph.adj_list[node])
                
                for neighbor in neighbors:
                    if neighbor not in infected:
                        prob = (1.0 if not graph.weighted 
                               else graph.adj_list[node][neighbor])
                        if random.random() < prob:
                            new_infected.add(neighbor)
            
            infected = new_infected
            history.append(infected.copy())
        
        return history

    def compare_diffusion_processes(self, graph1, graph2, 
                                  start_nodes: Set[str], 
                                  steps: int, 
                                  num_simulations: int = 10) -> Dict[str, float]:
        total_diff = 0
        max_diff = float('-inf')
        min_diff = float('inf')
        
        for _ in range(num_simulations):
            hist1 = self.simulate_diffusion(graph1, start_nodes, steps)
            hist2 = self.simulate_diffusion(graph2, start_nodes, steps)
            
            diff = sum(len(set1.symmetric_difference(set2)) 
                      for set1, set2 in zip(hist1, hist2)) / steps
            
            total_diff += diff
            max_diff = max(max_diff, diff)
            min_diff = min(min_diff, diff)
        
        return {
            'average_difference': total_diff / num_simulations,
            'max_difference': max_diff,
            'min_difference': min_diff
        }        
    def example(self):
      # Example usage
      graph1 = Graph(weighted=True)
      graph1.add_edge("A", "B", 1.0)
      graph1.add_edge("B", "C", 2.0)
      graph1.add_edge("C", "A", 1.5)

      graph2 = Graph(directed=False, weighted=True)
      graph2.add_edge("A", "B", 2.0)
      graph2.add_edge("B", "C", 1.0)
      graph2.add_edge("C", "D", 1.0)
      graph2.add_edge("D", "E", 1.0)

      comparator = DiffusionDistance()
    
      # Compare basic properties
      results = comparator.compare_graphs(graph1, graph2)
      print("Graph comparison results:", results)
    
      # Compare diffusion processes
      start_nodes = {"A"}
      diffusion_results = comparator.compare_diffusion_processes(
        graph1, graph2, start_nodes, steps=5, num_simulations=10
    )
      print("Diffusion comparison results:", diffusion_results)

#claude ai
from typing import Dict, Set, List, Tuple
from collections import defaultdict
import math

class GraphKernelDistance(Distance):
         
    def __init__(self, max_iterations: int = 100, tolerance: float = 1e-10)-> None:
        super().__init__()
        self.type='graph'
        self.max_iterations = max_iterations
        self.tolerance = tolerance

    def compute_heat_kernel(self, graph, t: float = 1.0) -> Dict[str, Dict[str, float]]:
        kernel = defaultdict(dict)
        laplacian = Graph.compute_laplacian(graph)
        
        for start_node in graph.nodes:
            vector = {node: 1.0 if node == start_node else 0.0 for node in graph.nodes}
            result = vector.copy()
            factorial = 1
            power = vector.copy()
            
            for k in range(1, self.max_iterations):
                factorial *= k
                power = Graph.multiply_matrix_vector(laplacian, power)
                term = {node: ((-t) ** k) * val / factorial 
                       for node, val in power.items()}
                
                max_change = 0.0
                for node in graph.nodes:
                    result[node] += term[node]
                    max_change = max(max_change, abs(term[node]))
                
                if max_change < self.tolerance:
                    break
            
            kernel[start_node] = result
        
        return kernel
    
    def compute_random_walk_kernel(self, graph, lambda_param: float = 0.1) -> Dict[str, Dict[str, float]]:
        transition = graph.get_transition_matrix()
        kernel = defaultdict(dict)
        
        for start_node in graph.nodes:
            vector = {node: 1.0 if node == start_node else 0.0 for node in graph.nodes}
            result = {node: lambda_param * val for node, val in vector.items()}
            current = vector.copy()
            current_lambda = lambda_param
            
            for _ in range(self.max_iterations):
                current_lambda *= lambda_param
                current = Graph.multiply_matrix_vector(transition, current)
                
                max_change = 0.0
                for node in graph.nodes:
                    change = current_lambda * current[node]
                    result[node] += change
                    max_change = max(max_change, abs(change))
                
                if max_change < self.tolerance:
                    break
            
            kernel[start_node] = result
        
        return kernel
    
    def compute_kernel_distance(self, graph1, graph2, kernel_type: str = 'heat', 
                              **kernel_params) -> float:
        if kernel_type == 'heat':
            t = kernel_params.get('t', 1.0)
            kernel1 = self.compute_heat_kernel(graph1, t)
            kernel2 = self.compute_heat_kernel(graph2, t)
        else:
            lambda_param = kernel_params.get('lambda_param', 0.1)
            kernel1 = self.compute_random_walk_kernel(graph1, lambda_param)
            kernel2 = self.compute_random_walk_kernel(graph2, lambda_param)
        
        common_nodes = graph1.nodes.intersection(graph2.nodes)
        if not common_nodes:
            return float('inf')
        
        distance = 0.0
        for i in common_nodes:
            for j in common_nodes:
                diff = kernel1[i][j] - kernel2[i][j]
                distance += diff * diff
        
        return math.sqrt(distance)
    
    def compute_multiple_kernel_distances(self, graph1, graph2) -> Dict[str, float]:
        return {
            'heat_kernel_t_1': self.compute_kernel_distance(
                graph1, graph2, kernel_type='heat', t=1.0
            ),
            'heat_kernel_t_0.1': self.compute_kernel_distance(
                graph1, graph2, kernel_type='heat', t=0.1
            ),
            'random_walk_lambda_0.1': self.compute_kernel_distance(
                graph1, graph2, kernel_type='random_walk', lambda_param=0.1
            ),
            'random_walk_lambda_0.01': self.compute_kernel_distance(
                graph1, graph2, kernel_type='random_walk', lambda_param=0.01
            )
        }
    def example(self):
    
      # Example usage
      graph1 = Graph(weighted=True)
      graph1.add_edge("A", "B", 1.0)
      graph1.add_edge("B", "C", 2.0)
      graph1.add_edge("C", "A", 1.5)

      graph2 = Graph(weighted=True)
      graph2.add_edge("A", "B", 2.0)
      graph2.add_edge("B", "C", 1.0)
      graph2.add_edge("C", "A", 1.0)

      kernel_distance = GraphKernelDistance()
    
      # Compute single kernel distance
      distance = kernel_distance.compute_kernel_distance(graph1, graph2, 'heat', t=1.0)
      print(f"Heat kernel distance (t=1.0): {distance}")
    
      # Compute multiple kernel distances
      distances = kernel_distance.compute_multiple_kernel_distances(graph1, graph2)
      for name, dist in distances.items():
        print(f"{name}: {dist}")
        
class FrobeniusDistance(Distance):
    def __init__(self)-> None:
        super().__init__()
        self.type='graph'
    '''
    def compute(self, graph1, graph2):
        if len(graph1.nodes) != len(graph2.nodes):
            raise ValueError("Graphs must have the same number of nodes")

        distance = 0
        matrix1 = graph1.adj_list
        matrix2 = graph2.adj_list
        
        for i in range(len(matrix1)):
            for j in range(len(matrix1[i])):
                diff = matrix1[i][j] - matrix2[i][j]
                distance += diff * diff
        
        return distance ** 0.5
    '''
    def compute(self,g1: 'Graph',g2: 'Graph') -> float:
        """
        Calculate the Frobenius distance between this graph and another graph.
        The Frobenius distance is defined as the Frobenius norm of the difference 
        between the adjacency matrices of the two graphs.
        
        Args:
            other (Graph): Another graph to compare with
            
        Returns:
            float: The Frobenius distance between the two graphs
            
        Raises:
            ValueError: If the graphs have different nodes or incompatible properties
            
        Example:
            >>> g1 = Graph()
            >>> g1.add_edge('A', 'B', 1.0)
            >>> g2 = Graph()
            >>> g2.add_edge('A', 'B', 2.0)
            >>> distance = g1.frobenius_distance(g2)
        """
        # Check if graphs have the same properties
        if g1.directed != g2.directed:
            raise ValueError("Cannot compare directed with undirected graphs")
        if g1.weighted != g2.weighted:
            raise ValueError("Cannot compare weighted with unweighted graphs")
            
        # Ensure both graphs have the same nodes
        if g1.nodes != g2.nodes:
            raise ValueError("Graphs must have the same set of nodes")
            
        # Get adjacency matrices
        matrix1 = g1.get_adjacency_matrix()
        matrix2 = g2.get_adjacency_matrix()
        
        # Calculate Frobenius norm of the difference
        sum_squares = 0.0
        for i in range(len(matrix1)):
            for j in range(len(matrix1[0])):
                diff = matrix1[i][j] - matrix2[i][j]
                sum_squares += diff * diff
                
        return sum_squares**0.5
         
    def example(self):
      """
      Test the Frobenius distance calculation between graphs
      """
      # Test 1: Identical graphs
      g1 = Graph(weighted=True)
      g1.add_edge('A', 'B', 1.0)
      g1.add_edge('B', 'C', 2.0)
    
      # Test 3: Different structure
      print("Test : Different structure of graph")
      g4 = Graph(weighted=True)
      g4.add_edge('A', 'B', 1.0)
      g4.add_edge('B', 'C', 2.0)
      g4.add_edge('A', 'C', 1.5)  # Additional edge
    
      g1.add_node('C')  # Ensure both graphs have same nodes
      distance = self.compute(g1,g4)
      print(f"Distance between graphs with different structure: {distance}")
'''ne fonctionne pas avec la structure dict
class PatternBasedDistance(Distance):
    def __init__(self,motif_size=3)-> None:
        super().__init__()
        self.type='graph'

        self.motif_size = motif_size

    def compute(self, graph1, graph2):
        motifs1 = graph1.count_motifs(self.motif_size)
        motifs2 = graph2.count_motifs(self.motif_size)
        return self._calculate_distance(motifs1, motifs2)

    def _calculate_distance(self, motifs1, motifs2):
        all_motifs = set(motifs1.keys()).union(set(motifs2.keys()))
        distance = 0
        for motif in all_motifs:
            freq1 = motifs1.get(motif, 0)
            freq2 = motifs2.get(motif, 0)
            distance += abs(freq1 - freq2)
        return distance
        
    def example(self):
       # Example usage
      graph1 = Graph(weighted=True)
      graph1.add_edge("A", "B", 1.0)
      graph1.add_edge("B", "C", 2.0)
      graph1.add_edge("C", "A", 1.5)

      graph2 = Graph(weighted=True)
      graph2.add_edge("A", "B", 2.0)
      graph2.add_edge("B", "C", 1.0)
      graph2.add_edge("C", "A", 1.0)

      #graph1 = Graph(nodes1, edges1)
      #graph2 = Graph(nodes2, edges2)

      pattern_distance = self.compute(graph1, graph2)

      print(f"La distance basée sur les motifs entre les deux graphes est: {pattern_distance}")
 '''
from typing import Dict, Set, List, Tuple, DefaultDict
from collections import defaultdict, deque

class PatternBasedDistance(Distance):

    def __init__(self, max_pattern_size: int = 4)-> None:
        super().__init__()

        self.max_pattern_size = max_pattern_size
        self.patterns_cache = {}
    
    def _get_neighbors(self, graph, node) -> Set[str]:
        if graph.weighted:
            return set(graph.adj_list[node].keys())
        return graph.adj_list[node]
    
    def _find_cycles(self, graph, node: str, size: int) -> List[Set[str]]:
        cycles = []
        visited = {node}
        path = [node]
        
        def dfs(current: str, start: str, depth: int):
            if depth == size - 1:
                neighbors = self._get_neighbors(graph, current)
                if start in neighbors:
                    cycles.append(set(path))
                return
            
            for neighbor in self._get_neighbors(graph, current):
                if neighbor not in visited:
                    visited.add(neighbor)
                    path.append(neighbor)
                    dfs(neighbor, start, depth + 1)
                    path.pop()
                    visited.remove(neighbor)
        
        dfs(node, node, 0)
        return cycles
    
    def _find_paths(self, graph, node: str, length: int) -> List[List[str]]:
        paths = []
        path = [node]
        
        def dfs(current: str, depth: int):
            if depth == length:
                paths.append(path[:])
                return
            
            for neighbor in self._get_neighbors(graph, current):
                if neighbor not in path:
                    path.append(neighbor)
                    dfs(neighbor, depth + 1)
                    path.pop()
        
        dfs(node, 1)
        return paths
    
    def _find_stars(self, graph, node: str, size: int) -> List[Set[str]]:
        neighbors = self._get_neighbors(graph, node)
        if len(neighbors) < size - 1:
            return []
        
        stars = []
        from itertools import combinations
        for star_neighbors in combinations(neighbors, size - 1):
            stars.append({node} | set(star_neighbors))
        
        return stars
    
    def _compute_pattern_frequency(self, graph, pattern_type: str, size: int) -> DefaultDict[int, float]:
        cache_key = (id(graph), pattern_type, size)
        if cache_key in self.patterns_cache:
            return self.patterns_cache[cache_key]
        
        patterns = defaultdict(float)
        total_patterns = 0
        
        for node in graph.nodes:
            if pattern_type == 'cycle':
                found_patterns = self._find_cycles(graph, node, size)
            elif pattern_type == 'path':
                found_patterns = [set(path) for path in self._find_paths(graph, node, size)]
            else:  # star
                found_patterns = self._find_stars(graph, node, size)
            
            for pattern in found_patterns:
                pattern_hash = len(pattern)  # Simplified hash
                patterns[pattern_hash] += 1
                total_patterns += 1
        
        if total_patterns > 0:
            for key in patterns:
                patterns[key] /= total_patterns
        
        self.patterns_cache[cache_key] = patterns
        return patterns
    
    def _compute_weighted_pattern_frequency(self, graph, pattern_type: str, size: int) -> DefaultDict[int, float]:
        if not graph.weighted:
            return self._compute_pattern_frequency(graph, pattern_type, size)
        
        patterns = defaultdict(float)
        total_weight = 0
        
        for node in graph.nodes:
            if pattern_type == 'cycle':
                found_patterns = self._find_cycles(graph, node, size)
            elif pattern_type == 'path':
                found_patterns = [set(path) for path in self._find_paths(graph, node, size)]
            else:  # star
                found_patterns = self._find_stars(graph, node, size)
            
            for pattern in found_patterns:
                pattern_hash = len(pattern)  # Simplified hash
                pattern_weight = 0
                
                pattern_list = list(pattern)
                for i in range(len(pattern_list)):
                    for j in range(i + 1, len(pattern_list)):
                        u, v = pattern_list[i], pattern_list[j]
                        if v in graph.adj_list[u]:
                            pattern_weight += graph.adj_list[u][v]
                
                patterns[pattern_hash] += pattern_weight
                total_weight += pattern_weight
        
        if total_weight > 0:
            for key in patterns:
                patterns[key] /= total_weight
        
        return patterns
    
    def compute_pattern_vector(self, graph) -> Dict[str, DefaultDict[int, float]]:
        pattern_vector = {}
        
        for pattern_type in ['cycle', 'path', 'star']:
            for size in range(3, self.max_pattern_size + 1):
                key = f"{pattern_type}_{size}"
                pattern_vector[key] = self._compute_weighted_pattern_frequency(
                    graph, pattern_type, size
                )
        
        return pattern_vector
    
    def compute(self, graph1, graph2, 
                        pattern_weights: Dict[str, float] = None) -> Dict[str, float]:
        if pattern_weights is None:
            pattern_weights = {
                'cycle': 1.0,
                'path': 1.0,
                'star': 1.0
            }
        
        vector1 = self.compute_pattern_vector(graph1)
        vector2 = self.compute_pattern_vector(graph2)
        
        distances = {}
        
        # L1 distance
        l1_distance = 0
        for key in vector1:
            pattern_type = key.split('_')[0]
            weight = pattern_weights.get(pattern_type, 1.0)
            
            all_patterns = set(vector1[key].keys()) | set(vector2[key].keys())
            for pattern in all_patterns:
                diff = abs(vector1[key].get(pattern, 0) - vector2[key].get(pattern, 0))
                l1_distance += weight * diff
        distances['l1'] = l1_distance
        
        # L2 distance
        l2_distance = 0
        for key in vector1:
            pattern_type = key.split('_')[0]
            weight = pattern_weights.get(pattern_type, 1.0)
            
            all_patterns = set(vector1[key].keys()) | set(vector2[key].keys())
            for pattern in all_patterns:
                diff = vector1[key].get(pattern, 0) - vector2[key].get(pattern, 0)
                l2_distance += weight * (diff * diff)
        distances['l2'] = math.sqrt(l2_distance)
        
        return distances
    
    def compare_specific_patterns(self, graph1, graph2, 
                                pattern_type: str, 
                                size: int) -> Dict[str, float]:
        freq1 = self._compute_weighted_pattern_frequency(graph1, pattern_type, size)
        freq2 = self._compute_weighted_pattern_frequency(graph2, pattern_type, size)
        
        all_patterns = set(freq1.keys()) | set(freq2.keys())
        
        l1_distance = sum(abs(freq1.get(p, 0) - freq2.get(p, 0)) for p in all_patterns)
        l2_distance = (sum((freq1.get(p, 0) - freq2.get(p, 0)) ** 2 
                                  for p in all_patterns))**0.5
        
        return {
            'l1_distance': l1_distance,
            'l2_distance': l2_distance,
            'pattern_count_1': len(freq1),
            'pattern_count_2': len(freq2)
        }

    def example(self):
    
      # Example usage
      graph1 = Graph(weighted=True)
      graph1.add_edge("A", "B", 1.0)
      graph1.add_edge("B", "C", 2.0)
      graph1.add_edge("C", "D", 1.5)
      graph1.add_edge("D", "A", 1.0)
      graph1.add_edge("A", "C", 2.0)

      graph2 = Graph(weighted=True)
      graph2.add_edge("A", "B", 1.0)
      graph2.add_edge("B", "C", 1.0)
      graph2.add_edge("C", "D", 1.0)
      graph2.add_edge("D", "A", 1.0)

      pattern_distance = PatternBasedDistance(max_pattern_size=4)
    
      # Compare all patterns
      distances = pattern_distance.compute(
        graph1, graph2,
        pattern_weights={'cycle': 1.5, 'path': 1.0, 'star': 0.5}
    )
      print("Overall distances:", distances)
    
      # Compare specific pattern type
      cycle_comparison = pattern_distance.compare_specific_patterns(
        graph1, graph2, 'cycle', 4
    )
      print("Cycle pattern comparison:", cycle_comparison)
    
import zlib

class GraphCompressionDistance(Distance):
    def __init__(self)-> None:
        """
        Initialize the GraphCompressionDistance class with two graphs.
        Each graph is represented as an adjacency matrix, which is a list of lists.

        :param graph1: Adjacency matrix of the first graph
        :param graph2: Adjacency matrix of the second graph
        """
        super().__init__()
        self.type='graph'
        
    def compress(self, data):
        """
        Compress the data using zlib compression and return the compressed size.

        :param data: String representation of the graph
        :return: Length of the compressed data
        """
        compressed_data = zlib.compress(data.encode('utf-8'))
        return len(compressed_data)

    def combined_compression(self,graph1,graph2):
        """
        Compress the combined adjacency matrices of both graphs.

        :return: Length of the compressed combined adjacency matrix
        """
        combined_matrix = graph1.adjacency_to_string() + graph2.adjacency_to_string()
        return self.compress(combined_matrix)

    def compute(self, graph1, graph2):
        """
        Compute the Graph Compression Distance between the two graphs.

        :return: Compression distance between the two graphs
        """
        graph1_compressed_size = self.compress(graph1.adjacency_to_string())
        graph2_compressed_size = self.compress(graph2.adjacency_to_string())
        combined_compressed_size = self.combined_compression(graph1,graph2)

        distance = combined_compressed_size - min(graph1_compressed_size, graph2_compressed_size)
        return distance
        
    def example(self):
      # Example usage
      graph1 = Graph(weighted=True)
      graph1.add_edge("A", "B", 1.0)
      graph1.add_edge("B", "C", 2.0)
      graph1.add_edge("C", "D", 1.5)
      graph1.add_edge("D", "A", 1.0)
      graph1.add_edge("A", "C", 2.0)

      graph2 = Graph(weighted=True)
      graph2.add_edge("A", "B", 1.0)
      graph2.add_edge("B", "C", 1.0)
      graph2.add_edge("C", "D", 1.0)
      graph2.add_edge("D", "A", 1.0)
      
      distance_calculator = GraphCompressionDistance().compute(graph1, graph2)
      print(f"Graph Compression Distance: {distance_calculator}")
      
'''ne fonctionne pas avec la structure de graph dict      
class DegreeDistributionDistance(Distance):
    def __init__(self)-> None:
        """
        Initializes the DegreeDistributionDistance class with two graphs.

        :param graph1: First graph, represented as an adjacency list or edge list.
        :param graph2: Second graph, represented as an adjacency list or edge list.
        """
        super().__init__()
        self.type='graph'


    def compare_distributions(self, dist1, dist2):
        """
        Compares two degree distributions using a simple difference metric.

        :param dist1: Degree distribution of the first graph.
        :param dist2: Degree distribution of the second graph.
        :return: A floating-point value representing the difference between the distributions.
        """
        all_degrees = set(dist1.keys()).union(set(dist2.keys()))
        difference = 0.0
        for degree in all_degrees:
            count1 = dist1.get(degree, 0)
            count2 = dist2.get(degree, 0)
            difference += abs(count1 - count2)
        return difference

    def compute(self, graph1, graph2):
        """
        Computes the degree distribution distance between the two graphs.

        :return: A floating-point value representing the distance between the degree distributions of the two graphs.
        """
        dist1 = Graph.compute_degree_distribution(graph1)
        dist2 = Graph.compute_degree_distribution(graph2)
        return self.compare_distributions(dist1, dist2)
        

'''
from collections import defaultdict
from typing import Dict, Set

class DegreeDistributionDistance(Distance):
    def __init__(self)-> None:
      
        super().__init__()
        self.type='graph'
        self.reset()

    def reset(self):
        self.degrees1 = {}
        self.degrees2 = {}
        self.distance = 0.0

    def compute(self, graph1: Graph, graph2: Graph) -> float:
        self.reset()
        
        # Verify that both graphs are of the same type
        if graph1.weighted != graph2.weighted or graph1.directed != graph2.directed:
            raise ValueError("Graphs must be of the same type (both weighted or both unweighted, both directed or both undirected)")
        
        # Compute degree distributions
        self.degrees1 = Graph.compute_degrees(graph1)
        self.degrees2 = Graph.compute_degrees(graph2)
        
        # Get all unique degrees
        all_degrees = set(list(self.degrees1.keys()) + list(self.degrees2.keys()))
        
        # Calculate L1 distance
        self.distance = 0.0
        for degree in all_degrees:
            prob1 = self.degrees1.get(degree, 0.0)
            prob2 = self.degrees2.get(degree, 0.0)
            self.distance += abs(prob1 - prob2)
        
        return self.distance

    def get_distributions(self):
        return self.degrees1, self.degrees2

    def example(self):
      # Example usage
      graph1 = Graph(weighted=True)
      graph1.add_edge("A", "B", 1.0)
      graph1.add_edge("B", "C", 2.0)
      graph1.add_edge("C", "D", 1.5)
      graph1.add_edge("D", "A", 1.0)
      graph1.add_edge("A", "C", 2.0)

      graph2 = Graph(weighted=True)
      graph2.add_edge("A", "B", 1.0)
      graph2.add_edge("B", "C", 1.0)
      
      distance=self.compute(graph1,graph2)
      print(f"Graph DegreeDistributionDistance: {distance}")

from collections import defaultdict
from typing import Dict, Set, List, Tuple
import random

class CommunityStructureDistance(Distance):
    def __init__(self)-> None:
      
        super().__init__()
        self.type='graph'
        self.reset()

    def reset(self):
        # Reset computed values
        self.communities1 = {}
        self.communities2 = {}
        self.distance = 0.0

    def modularity(self, graph: Graph, communities: Dict[str, int]) -> float:
        # Calculate modularity Q = 1/2m * sum(Aij - kikj/2m)δ(ci,cj)
        if not graph.nodes:
            return 0.0

        total_weight = 0
        node_degrees = defaultdict(float)

        # Calculate total weight and node degrees
        for node in graph.nodes:
            for neighbor, weight in graph.adj_list[node].items():
                if graph.weighted:
                    w = weight
                else:
                    w = 1.0
                node_degrees[node] += w
                if not graph.directed:
                    total_weight += w / 2
                else:
                    total_weight += w

        if total_weight == 0:
            return 0.0

        modularity = 0.0
        for node in graph.nodes:
            for neighbor in graph.adj_list[node]:
                if communities[node] == communities[neighbor]:
                    if graph.weighted:
                        actual = graph.adj_list[node][neighbor]
                    else:
                        actual = 1.0
                    expected = node_degrees[node] * node_degrees[neighbor] / (2.0 * total_weight)
                    modularity += (actual - expected)

        modularity /= (2.0 * total_weight)
        return modularity

    def detect_communities(self, graph: Graph) -> Dict[str, int]:
        # Implementation of Louvain method for community detection
        communities = {node: idx for idx, node in enumerate(graph.nodes)}
        n_communities = len(communities)
        
        improvement = True
        while improvement:
            improvement = False
            
            # Phase 1: Modularity optimization
            for node in graph.nodes:
                current_community = communities[node]
                neighbor_communities = {}
                
                # Calculate gain for moving to each neighbor's community
                for neighbor in graph.adj_list[node]:
                    neighbor_community = communities[neighbor]
                    if neighbor_community not in neighbor_communities:
                        # Remove node from its current community
                        communities[node] = neighbor_community
                        gain = self.modularity(graph, communities)
                        communities[node] = current_community
                        neighbor_communities[neighbor_community] = gain
                
                # Find best community
                best_community = current_community
                best_gain = 0.0
                
                for community, gain in neighbor_communities.items():
                    if gain > best_gain:
                        best_gain = gain
                        best_community = community
                
                if best_community != current_community:
                    communities[node] = best_community
                    improvement = True
            
            if not improvement:
                break
            
            # Phase 2: Community aggregation
            new_communities = {}
            idx = 0
            for old_community in set(communities.values()):
                new_communities[old_community] = idx
                idx += 1
            
            for node in communities:
                communities[node] = new_communities[communities[node]]

        return communities

    def compute(self, graph1: Graph, graph2: Graph) -> float:
        # Reset previous computations
        self.reset()
        
        # Verify graphs are compatible
        if graph1.weighted != graph2.weighted or graph1.directed != graph2.directed:
            raise ValueError("Graphs must be of the same type")

        # Detect communities in both graphs
        self.communities1 = self.detect_communities(graph1)
        self.communities2 = self.detect_communities(graph2)

        # Convert to community size distributions
        dist1 = self._get_community_size_distribution(self.communities1)
        dist2 = self._get_community_size_distribution(self.communities2)

        # Calculate L1 distance between distributions
        all_sizes = set(list(dist1.keys()) + list(dist2.keys()))
        
        distance = 0.0
        for size in all_sizes:
            prob1 = dist1.get(size, 0.0)
            prob2 = dist2.get(size, 0.0)
            distance += abs(prob1 - prob2)

        return distance

    def _get_community_size_distribution(self, communities: Dict[str, int]) -> Dict[int, float]:
        # Count community sizes
        community_sizes = defaultdict(int)
        for community_id in communities.values():
            community_sizes[community_id] += 1
        
        # Convert to size distribution
        size_distribution = defaultdict(float)
        total_nodes = len(communities)
        
        for community_id, size in community_sizes.items():
            size_distribution[size] += 1
        
        # Normalize
        for size in size_distribution:
            size_distribution[size] /= total_nodes
            
        return dict(size_distribution)

    def get_communities(self):
        return self.communities1, self.communities2
        
    def example(self):
      # Example usage
      graph1 = Graph(weighted=True)
      graph1.add_edge("A", "B", 1.0)
      graph1.add_edge("B", "C", 2.0)
      graph1.add_edge("C", "D", 1.5)
      graph1.add_edge("D", "A", 1.0)
      graph1.add_edge("A", "C", 2.0)

      graph2 = Graph(weighted=True)
      graph2.add_edge("A", "B", 1.0)
      graph2.add_edge("B", "C", 1.0)
      graph2.add_edge("C", "D", 1.0)
      # Compare community structures
      csd = CommunityStructureDistance()
      distance = csd.compute(graph1, graph2)
      print(f"Community structure distance: {distance}")

      # Get detected communities if needed
      communities1, communities2 = csd.get_communities()
      print("Communities in graph 1:", communities1)
      print("Communities in graph 2:", communities2)

###########################################
#a terminer claude 	ai
from typing import Dict, List, Set, Tuple
from collections import defaultdict, deque

class GraphBetweennessDistance:
    """
    Calculates distance between two graphs using Betweenness Centrality metrics.
    
    Measures the structural difference between graphs based on how nodes function
    as bridges in their respective networks.
    """
    
    def calculate_distance(
        self,
        graph1: Dict[int, List[int]],
        graph2: Dict[int, List[int]]
    ) -> float:
        """
        Calculate distance between two graphs using betweenness centrality.
        
        Args:
            graph1 (Dict[int, List[int]]): First graph as adjacency list
            graph2 (Dict[int, List[int]]): Second graph as adjacency list
        
        Returns:
            float: Distance measure between the graphs
        """
        # Calculate betweenness centrality for both graphs
        centrality1 = self._compute_betweenness_centrality(graph1)
        centrality2 = self._compute_betweenness_centrality(graph2)
        
        # Compute distance based on centrality differences
        return self._compute_centrality_distance(centrality1, centrality2)
    
    def _compute_betweenness_centrality(
        self,
        graph: Dict[int, List[int]]
    ) -> Dict[int, float]:
        """
        Compute betweenness centrality for all nodes in the graph.
        
        Args:
            graph (Dict[int, List[int]]): Graph as adjacency list
        
        Returns:
            Dict[int, float]: Node betweenness centrality scores
        """
        centrality = defaultdict(float)
        nodes = list(graph.keys())
        n = len(nodes)
        
        for source in nodes:
            # Get shortest paths and path counts
            paths_data = self._get_shortest_paths(graph, source)
            shortest_paths, path_counts = paths_data
            
            # Calculate dependencies
            dependencies = self._compute_dependencies(
                graph, source, shortest_paths, path_counts
            )
            
            # Update centrality scores
            for node, dependency in dependencies.items():
                if node != source:
                    centrality[node] += dependency
        
        # Normalize scores
        if n > 2:
            norm_factor = 1.0 / ((n - 1) * (n - 2))
            for node in centrality:
                centrality[node] *= norm_factor
                
        return dict(centrality)
    
    def _get_shortest_paths(
        self,
        graph: Dict[int, List[int]],
        source: int
    ) -> Tuple[Dict[int, List[int]], Dict[int, int]]:
        """
        Find shortest paths from source node using BFS.
        
        Args:
            graph (Dict[int, List[int]]): Graph structure
            source (int): Starting node
            
        Returns:
            Tuple containing shortest paths and path counts
        """
        shortest_paths = defaultdict(list)
        path_counts = defaultdict(int)
        distances = {source: 0}
        path_counts[source] = 1
        queue = deque([(source, 0)])
        
        while queue:
            node, distance = queue.popleft()
            
            for neighbor in graph[node]:
                # Discover new node
                if neighbor not in distances:
                    distances[neighbor] = distance + 1
                    queue.append((neighbor, distance + 1))
                    path_counts[neighbor] = path_counts[node]
                    shortest_paths[neighbor].append(node)
                # Additional path found
                elif distances[neighbor] == distance + 1:
                    path_counts[neighbor] += path_counts[node]
                    shortest_paths[neighbor].append(node)
        
        return dict(shortest_paths), dict(path_counts)
    
    def _compute_dependencies(
        self,
        graph: Dict[int, List[int]],
        source: int,
        shortest_paths: Dict[int, List[int]],
        path_counts: Dict[int, int]
    ) -> Dict[int, float]:
        """
        Calculate node dependencies for centrality computation.
        
        Args:
            graph (Dict[int, List[int]]): Graph structure
            source (int): Source node
            shortest_paths (Dict[int, List[int]]): Shortest paths from source
            path_counts (Dict[int, int]): Number of shortest paths
            
        Returns:
            Dict[int, float]: Node dependencies
        """
        dependencies = defaultdict(float)
        nodes = sorted(
            shortest_paths.keys(),
            key=lambda x: -len(shortest_paths[x])
        )
        
        for node in nodes:
            coeff = (1.0 + dependencies[node]) / path_counts[node]
            for predecessor in shortest_paths[node]:
                dependencies[predecessor] += path_counts[predecessor] * coeff
                
        return dict(dependencies)
    
    def _compute_centrality_distance(
        self,
        centrality1: Dict[int, float],
        centrality2: Dict[int, float]
    ) -> float:
        """
        Compute distance between two centrality distributions.
        
        Args:
            centrality1 (Dict[int, float]): First graph centrality scores
            centrality2 (Dict[int, float]): Second graph centrality scores
            
        Returns:
            float: Distance measure between centrality distributions
        """
        # Get all nodes from both graphs
        all_nodes = set(centrality1.keys()) | set(centrality2.keys())
        
        # Calculate squared differences in centrality
        squared_diff_sum = sum(
            (centrality1.get(node, 0) - centrality2.get(node, 0)) ** 2
            for node in all_nodes
        )
        
        return math.sqrt(squared_diff_sum)



#####################
from typing import Dict, Set, List, Tuple
from copy import deepcopy

class KCoreDistance:
    """
    A class to calculate the K-Core distance between two weighted graphs.
    K-Core is the largest subgraph where all nodes have at least k neighbors.
    
    Attributes:
        graph1 (Dict[str, Dict[str, float]]): First weighted graph represented as adjacency dict
        graph2 (Dict[str, Dict[str, float]]): Second weighted graph represented as adjacency dict
    """
    
    def __init__(self, graph1: Dict[str, Dict[str, float]], graph2: Dict[str, Dict[str, float]]):
        """
        Initialize the KCoreDistance calculator with two weighted graphs.
        
        Args:
            graph1: First weighted graph as adjacency dictionary
            graph2: Second weighted graph as adjacency dictionary
        """
        self.graph1 = deepcopy(graph1)
        self.graph2 = deepcopy(graph2)
    
    def compute_k_core(self, graph: Dict[str, Dict[str, float]], k: int) -> Set[str]:
        """
        Compute the k-core of a graph using the k-core decomposition algorithm.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            k: Minimum number of neighbors required
            
        Returns:
            Set of nodes that belong to the k-core
        """
        # Make a copy to avoid modifying the original graph
        working_graph = deepcopy(graph)
        
        while True:
            # Find nodes with degree less than k
            to_remove = set()
            for node in working_graph:
                if len(working_graph[node]) < k:
                    to_remove.add(node)
            
            if not to_remove:
                break
                
            # Remove nodes and their edges
            for node in to_remove:
                # Remove edges to this node from all neighbors
                for neighbor in working_graph[node]:
                    if neighbor in working_graph:
                        working_graph[neighbor].pop(node, None)
                # Remove the node itself
                working_graph.pop(node)
        
        return set(working_graph.keys())
    
    def get_max_k_core(self, graph: Dict[str, Dict[str, float]]) -> int:
        """
        Find the maximum k for which a k-core exists in the graph.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Maximum k value for which a k-core exists
        """
        max_k = 0
        k = 1
        
        while True:
            core = self.compute_k_core(graph, k)
            if not core:
                break
            max_k = k
            k += 1
            
        return max_k
    
    def calculate_distance(self) -> Tuple[int, int, float]:
        """
        Calculate the K-Core distance between the two graphs.
        
        Returns:
            Tuple containing:
            - Maximum k-core number of first graph
            - Maximum k-core number of second graph
            - Normalized distance between the two k-core numbers
        """
        max_k1 = self.get_max_k_core(self.graph1)
        max_k2 = self.get_max_k_core(self.graph2)
        
        # Calculate normalized distance between 0 and 1
        max_k = max(max_k1, max_k2)
        if max_k == 0:
            distance = 0.0
        else:
            distance = abs(max_k1 - max_k2) / max_k
            
        return max_k1, max_k2, distance



###############################
from typing import Dict, List, Tuple, Set
from collections import defaultdict
from math import inf
import heapq
from copy import deepcopy

class ClosenessCentralityDistance:
    """
    A class to calculate the Closeness Centrality distance between two weighted graphs.
    Closeness Centrality measures how close a node is to all other nodes in the graph.
    
    Attributes:
        graph1 (Dict[str, Dict[str, float]]): First weighted graph represented as adjacency dict
        graph2 (Dict[str, Dict[str, float]]): Second weighted graph represented as adjacency dict
    """
    
    def __init__(self, graph1: Dict[str, Dict[str, float]], graph2: Dict[str, Dict[str, float]]):
        """
        Initialize the ClosenessCentrality calculator with two weighted graphs.
        
        Args:
            graph1: First weighted graph as adjacency dictionary
            graph2: Second weighted graph as adjacency dictionary
        """
        self.graph1 = deepcopy(graph1)
        self.graph2 = deepcopy(graph2)
    
    def dijkstra(self, graph: Dict[str, Dict[str, float]], start: str) -> Dict[str, float]:
        """
        Compute shortest paths from start node to all other nodes using Dijkstra's algorithm.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            start: Starting node
            
        Returns:
            Dictionary of shortest distances from start node to all other nodes
        """
        distances = {node: inf for node in graph}
        distances[start] = 0
        pq = [(0, start)]
        visited = set()
        
        while pq:
            current_distance, current_node = heapq.heappop(pq)
            
            if current_node in visited:
                continue
                
            visited.add(current_node)
            
            for neighbor, weight in graph[current_node].items():
                distance = current_distance + weight
                
                if distance < distances[neighbor]:
                    distances[neighbor] = distance
                    heapq.heappush(pq, (distance, neighbor))
        
        return distances
    
    def compute_closeness_centrality(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compute closeness centrality for all nodes in the graph.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Dictionary of closeness centrality values for each node
        """
        centrality = {}
        n = len(graph)
        
        for node in graph:
            # Get shortest paths from current node to all others
            shortest_paths = self.dijkstra(graph, node)
            
            # Sum of all finite distances
            total_distance = sum(d for d in shortest_paths.values() if d != inf)
            
            # Count number of reachable nodes (excluding self)
            reachable = sum(1 for d in shortest_paths.values() if d != inf) - 1
            
            if reachable > 0:
                # Compute closeness centrality
                # We use (reachable / (n-1)) term to normalize for disconnected graphs
                centrality[node] = (reachable / (n-1)) * (reachable / total_distance)
            else:
                centrality[node] = 0.0
        
        return centrality
    
    def calculate_distance(self) -> Tuple[Dict[str, float], Dict[str, float], float]:
        """
        Calculate the distance between two graphs based on their closeness centrality distributions.
        
        Returns:
            Tuple containing:
            - Closeness centrality values for first graph
            - Closeness centrality values for second graph
            - Average absolute difference in centrality values
        """
        centrality1 = self.compute_closeness_centrality(self.graph1)
        centrality2 = self.compute_closeness_centrality(self.graph2)
        
        # Calculate average absolute difference in centrality values
        all_nodes = set(centrality1.keys()) | set(centrality2.keys())
        total_diff = 0.0
        
        for node in all_nodes:
            val1 = centrality1.get(node, 0.0)
            val2 = centrality2.get(node, 0.0)
            total_diff += abs(val1 - val2)
            
        avg_distance = total_diff / len(all_nodes)
        
        return centrality1, centrality2, avg_distance
        

##############################
from typing import Dict, List, Set, Tuple
from collections import defaultdict
from copy import deepcopy
from math import inf

class BetweennessCentralityDistance:
    """
    A class to calculate the Betweenness Centrality distance between two weighted graphs.
    Betweenness Centrality quantifies how often a node acts as a bridge along the shortest
    path between two other nodes.
    
    Attributes:
        graph1 (Dict[str, Dict[str, float]]): First weighted graph represented as adjacency dict
        graph2 (Dict[str, Dict[str, float]]): Second weighted graph represented as adjacency dict
    """
    
    def __init__(self, graph1: Dict[str, Dict[str, float]], graph2: Dict[str, Dict[str, float]]):
        """
        Initialize the BetweennessCentrality calculator with two weighted graphs.
        
        Args:
            graph1: First weighted graph as adjacency dictionary
            graph2: Second weighted graph as adjacency dictionary
        """
        self.graph1 = deepcopy(graph1)
        self.graph2 = deepcopy(graph2)
    
    def shortest_paths_dijkstra(self, graph: Dict[str, Dict[str, float]], 
                              start: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]:
        """
        Compute shortest paths and predecessors from start node using Dijkstra's algorithm.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            start: Starting node
            
        Returns:
            Tuple containing:
            - Dictionary of shortest distances
            - Dictionary of predecessor lists for each node
        """
        distances = {node: inf for node in graph}
        distances[start] = 0
        predecessors = {node: [] for node in graph}
        
        unvisited = set(graph.keys())
        
        while unvisited:
            # Find unvisited node with minimum distance
            current = min(unvisited, key=lambda x: distances[x])
            
            if distances[current] == inf:
                break
                
            unvisited.remove(current)
            
            for neighbor, weight in graph[current].items():
                distance = distances[current] + weight
                
                if distance < distances[neighbor]:
                    distances[neighbor] = distance
                    predecessors[neighbor] = [current]
                elif distance == distances[neighbor]:
                    predecessors[neighbor].append(current)
        
        return distances, predecessors
    
    def accumulate_betweenness(self, graph: Dict[str, Dict[str, float]], 
                              start: str) -> Dict[str, float]:
        """
        Compute the contribution to betweenness centrality from paths starting at given node.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            start: Starting node
            
        Returns:
            Dictionary of betweenness contributions for each node
        """
        betweenness = {node: 0.0 for node in graph}
        distances, predecessors = self.shortest_paths_dijkstra(graph, start)
        
        # Initialize dependency accumulation
        dependency = {node: 0.0 for node in graph}
        
        # Process nodes in order of decreasing distance from start
        stack = sorted([(d, n) for n, d in distances.items() if n != start], reverse=True)
        
        for _, node in stack:
            coeff = (1.0 + dependency[node]) / len(predecessors[node])
            for pred in predecessors[node]:
                dependency[pred] += coeff
                betweenness[pred] += coeff
        
        return betweenness
    
    def compute_betweenness_centrality(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compute betweenness centrality for all nodes in the graph.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Dictionary of normalized betweenness centrality values for each node
        """
        betweenness = {node: 0.0 for node in graph}
        
        # Accumulate betweenness from all source nodes
        for start in graph:
            contributions = self.accumulate_betweenness(graph, start)
            for node in graph:
                betweenness[node] += contributions[node]
        
        # Normalize the values
        n = len(graph)
        normalization = (n - 1) * (n - 2)  # Number of possible paths excluding source
        
        if normalization > 0:
            for node in betweenness:
                betweenness[node] /= normalization
        
        return betweenness
    
    def calculate_distance(self) -> Tuple[Dict[str, float], Dict[str, float], float]:
        """
        Calculate the distance between two graphs based on their betweenness centrality distributions.
        
        Returns:
            Tuple containing:
            - Betweenness centrality values for first graph
            - Betweenness centrality values for second graph
            - Average absolute difference in centrality values
        """
        centrality1 = self.compute_betweenness_centrality(self.graph1)
        centrality2 = self.compute_betweenness_centrality(self.graph2)
        
        # Calculate average absolute difference in centrality values
        all_nodes = set(centrality1.keys()) | set(centrality2.keys())
        total_diff = 0.0
        
        for node in all_nodes:
            val1 = centrality1.get(node, 0.0)
            val2 = centrality2.get(node, 0.0)
            total_diff += abs(val1 - val2)
            
        avg_distance = total_diff / len(all_nodes)
        
        return centrality1, centrality2, avg_distance
        

##################################
from typing import Dict, List, Tuple, Set
from copy import deepcopy
from math import sqrt

class EigenvectorCentralityDistance:
    """
    A class to calculate the Eigenvector Centrality distance between two weighted graphs.
    Eigenvector Centrality measures node influence based on connections to other influential nodes.
    
    Attributes:
        graph1 (Dict[str, Dict[str, float]]): First weighted graph represented as adjacency dict
        graph2 (Dict[str, Dict[str, float]]): Second weighted graph represented as adjacency dict
        max_iterations (int): Maximum number of iterations for power iteration method
        tolerance (float): Convergence tolerance for power iteration method
    """
    
    def __init__(self, 
                 graph1: Dict[str, Dict[str, float]], 
                 graph2: Dict[str, Dict[str, float]], 
                 max_iterations: int = 100,
                 tolerance: float = 1e-6):
        """
        Initialize the EigenvectorCentrality calculator with two weighted graphs.
        
        Args:
            graph1: First weighted graph as adjacency dictionary
            graph2: Second weighted graph as adjacency dictionary
            max_iterations: Maximum number of power iterations
            tolerance: Convergence tolerance
        """
        self.graph1 = deepcopy(graph1)
        self.graph2 = deepcopy(graph2)
        self.max_iterations = max_iterations
        self.tolerance = tolerance
    
    def normalize_vector(self, vector: Dict[str, float]) -> Dict[str, float]:
        """
        Normalize a vector using L2 norm.
        
        Args:
            vector: Dictionary representing a vector
            
        Returns:
            Normalized vector
        """
        # Calculate L2 norm
        norm = sqrt(sum(x * x for x in vector.values()))
        
        if norm > 0:
            return {k: v / norm for k, v in vector.items()}
        return vector
    
    def matrix_vector_multiply(self, 
                             graph: Dict[str, Dict[str, float]], 
                             vector: Dict[str, float]) -> Dict[str, float]:
        """
        Multiply adjacency matrix by vector.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            vector: Vector to multiply with
            
        Returns:
            Result vector of the multiplication
        """
        result = {node: 0.0 for node in graph}
        
        for node in graph:
            for neighbor, weight in graph[node].items():
                result[node] += weight * vector[neighbor]
                
        return result
    
    def compute_eigenvector_centrality(self, 
                                     graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compute eigenvector centrality for all nodes using power iteration method.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Dictionary of eigenvector centrality values for each node
        """
        # Initialize eigenvector with equal values
        n = len(graph)
        if n == 0:
            return {}
            
        centrality = {node: 1.0 / sqrt(n) for node in graph}
        
        for _ in range(self.max_iterations):
            # Store previous iteration for convergence check
            prev_centrality = centrality.copy()
            
            # Power iteration step
            centrality = self.matrix_vector_multiply(graph, centrality)
            
            # Normalize the vector
            centrality = self.normalize_vector(centrality)
            
            # Check for convergence
            diff = sum((centrality[node] - prev_centrality[node]) ** 2 
                      for node in centrality)
            if sqrt(diff) < self.tolerance:
                break
        
        # Ensure all values are positive
        min_value = min(centrality.values())
        if min_value < 0:
            centrality = {k: v - min_value for k, v in centrality.items()}
            centrality = self.normalize_vector(centrality)
            
        return centrality
    
    def calculate_distance(self) -> Tuple[Dict[str, float], Dict[str, float], float]:
        """
        Calculate the distance between two graphs based on their eigenvector centrality distributions.
        
        Returns:
            Tuple containing:
            - Eigenvector centrality values for first graph
            - Eigenvector centrality values for second graph
            - Average absolute difference in centrality values
        """
        centrality1 = self.compute_eigenvector_centrality(self.graph1)
        centrality2 = self.compute_eigenvector_centrality(self.graph2)
        
        # Calculate average absolute difference in centrality values
        all_nodes = set(centrality1.keys()) | set(centrality2.keys())
        
        if not all_nodes:
            return {}, {}, 0.0
            
        total_diff = 0.0
        for node in all_nodes:
            val1 = centrality1.get(node, 0.0)
            val2 = centrality2.get(node, 0.0)
            total_diff += abs(val1 - val2)
            
        avg_distance = total_diff / len(all_nodes)
        
        return centrality1, centrality2, avg_distance
        
##########################################

from typing import Dict, List, Tuple, Set
from copy import deepcopy
from math import sqrt

class KatzCentralityDistance:
    """
    A class to calculate the Katz Centrality distance between two weighted graphs.
    Katz Centrality measures node importance considering all possible paths, with 
    longer paths having geometrically decreasing weights.
    
    Attributes:
        graph1 (Dict[str, Dict[str, float]]): First weighted graph represented as adjacency dict
        graph2 (Dict[str, Dict[str, float]]): Second weighted graph represented as adjacency dict
        alpha (float): Damping factor (attenuation factor for path lengths)
        beta (float): Base constant for centrality
        max_iterations (int): Maximum number of iterations for convergence
        tolerance (float): Convergence tolerance
    """
    
    def __init__(self, 
                 graph1: Dict[str, Dict[str, float]], 
                 graph2: Dict[str, Dict[str, float]],
                 alpha: float = 0.1,
                 beta: float = 1.0,
                 max_iterations: int = 100,
                 tolerance: float = 1e-6):
        """
        Initialize the KatzCentrality calculator with two weighted graphs.
        
        Args:
            graph1: First weighted graph as adjacency dictionary
            graph2: Second weighted graph as adjacency dictionary
            alpha: Damping factor (should be less than reciprocal of largest eigenvalue)
            beta: Base constant added to each node's centrality
            max_iterations: Maximum number of iterations
            tolerance: Convergence tolerance
        """
        self.graph1 = deepcopy(graph1)
        self.graph2 = deepcopy(graph2)
        self.alpha = alpha
        self.beta = beta
        self.max_iterations = max_iterations
        self.tolerance = tolerance
    
    def normalize_vector(self, vector: Dict[str, float]) -> Dict[str, float]:
        """
        Normalize a vector using L2 norm.
        
        Args:
            vector: Dictionary representing a vector
            
        Returns:
            Normalized vector
        """
        norm = sqrt(sum(x * x for x in vector.values()))
        if norm > 0:
            return {k: v / norm for k, v in vector.items()}
        return vector
    
    def matrix_vector_multiply(self, 
                             graph: Dict[str, Dict[str, float]], 
                             vector: Dict[str, float]) -> Dict[str, float]:
        """
        Multiply adjacency matrix by vector.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            vector: Vector to multiply with
            
        Returns:
            Result vector of the multiplication
        """
        result = {node: 0.0 for node in graph}
        
        for node in graph:
            for neighbor, weight in graph[node].items():
                result[node] += weight * vector[neighbor]
                
        return result
    
    def compute_katz_centrality(self, 
                              graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compute Katz centrality for all nodes using power iteration method.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Dictionary of Katz centrality values for each node
        """
        if not graph:
            return {}
            
        # Initialize centrality with beta values
        centrality = {node: self.beta for node in graph}
        
        for iteration in range(self.max_iterations):
            # Store previous iteration for convergence check
            prev_centrality = centrality.copy()
            
            # Compute new centralities
            new_centrality = self.matrix_vector_multiply(graph, centrality)
            
            # Apply damping factor and add base constant
            centrality = {
                node: self.alpha * new_centrality[node] + self.beta 
                for node in graph
            }
            
            # Check for convergence
            diff = sum((centrality[node] - prev_centrality[node]) ** 2 
                      for node in centrality)
            if sqrt(diff) < self.tolerance:
                break
        
        # Normalize final values
        return self.normalize_vector(centrality)
    
    def estimate_largest_eigenvalue(self, graph: Dict[str, Dict[str, float]]) -> float:
        """
        Estimate the largest eigenvalue of the adjacency matrix using power iteration.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Estimated largest eigenvalue
        """
        if not graph:
            return 0.0
            
        # Initialize random vector
        vector = {node: 1.0 for node in graph}
        vector = self.normalize_vector(vector)
        
        for _ in range(10):  # Few iterations for estimation
            new_vector = self.matrix_vector_multiply(graph, vector)
            new_vector = self.normalize_vector(new_vector)
            vector = new_vector
        
        # Rayleigh quotient
        Ax = self.matrix_vector_multiply(graph, vector)
        numerator = sum(vector[node] * Ax[node] for node in graph)
        denominator = sum(vector[node] * vector[node] for node in graph)
        
        return numerator / denominator if denominator > 0 else 0.0
    
    def calculate_distance(self) -> Tuple[Dict[str, float], Dict[str, float], float]:
        """
        Calculate the distance between two graphs based on their Katz centrality distributions.
        
        Returns:
            Tuple containing:
            - Katz centrality values for first graph
            - Katz centrality values for second graph
            - Average absolute difference in centrality values
        """
        # Ensure alpha is small enough for convergence
        eigenvalue1 = self.estimate_largest_eigenvalue(self.graph1)
        eigenvalue2 = self.estimate_largest_eigenvalue(self.graph2)
        max_eigenvalue = max(eigenvalue1, eigenvalue2)
        
        if max_eigenvalue > 0:
            self.alpha = min(self.alpha, 0.9 / max_eigenvalue)
        
        centrality1 = self.compute_katz_centrality(self.graph1)
        centrality2 = self.compute_katz_centrality(self.graph2)
        
        # Calculate average absolute difference in centrality values
        all_nodes = set(centrality1.keys()) | set(centrality2.keys())
        
        if not all_nodes:
            return {}, {}, 0.0
            
        total_diff = 0.0
        for node in all_nodes:
            val1 = centrality1.get(node, 0.0)
            val2 = centrality2.get(node, 0.0)
            total_diff += abs(val1 - val2)
            
        avg_distance = total_diff / len(all_nodes)
        
        return centrality1, centrality2, avg_distance
        


from typing import Dict, List, Tuple, Set
from copy import deepcopy

class PageRankDistance:
    """
    A class to calculate the PageRank distance between two weighted graphs.
    PageRank measures node importance based on the structure of incoming links
    and their weights.
    
    Attributes:
        graph1 (Dict[str, Dict[str, float]]): First weighted graph represented as adjacency dict
        graph2 (Dict[str, Dict[str, float]]): Second weighted graph represented as adjacency dict
        damping_factor (float): Random walk continuation probability
        max_iterations (int): Maximum number of iterations for convergence
        tolerance (float): Convergence tolerance
    """
    
    def __init__(self, 
                 graph1: Dict[str, Dict[str, float]], 
                 graph2: Dict[str, Dict[str, float]],
                 damping_factor: float = 0.85,
                 max_iterations: int = 100,
                 tolerance: float = 1e-6):
        """
        Initialize the PageRank calculator with two weighted graphs.
        
        Args:
            graph1: First weighted graph as adjacency dictionary
            graph2: Second weighted graph as adjacency dictionary
            damping_factor: Probability of continuing the random walk (typically 0.85)
            max_iterations: Maximum number of iterations
            tolerance: Convergence tolerance
        """
        self.graph1 = deepcopy(graph1)
        self.graph2 = deepcopy(graph2)
        self.damping_factor = damping_factor
        self.max_iterations = max_iterations
        self.tolerance = tolerance
    
    def normalize_weights(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, Dict[str, float]]:
        """
        Normalize edge weights so outgoing weights from each node sum to 1.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Graph with normalized weights
        """
        normalized = deepcopy(graph)
        
        for node in normalized:
            total_weight = sum(normalized[node].values())
            if total_weight > 0:
                normalized[node] = {
                    neighbor: weight / total_weight 
                    for neighbor, weight in normalized[node].items()
                }
                
        return normalized
    
    def get_incoming_edges(self, 
                          graph: Dict[str, Dict[str, float]]) -> Dict[str, Dict[str, float]]:
        """
        Create a dictionary of incoming edges for each node.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Dictionary mapping nodes to their incoming edges and weights
        """
        incoming = {node: {} for node in graph}
        
        for source in graph:
            for target, weight in graph[source].items():
                if target not in incoming:
                    incoming[target] = {}
                incoming[target][source] = weight
                
        return incoming
    
    def compute_pagerank(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compute PageRank values for all nodes in the graph.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Dictionary of PageRank values for each node
        """
        if not graph:
            return {}
            
        # Normalize weights
        normalized_graph = self.normalize_weights(graph)
        
        # Get incoming edges for efficient updates
        incoming = self.get_incoming_edges(normalized_graph)
        
        # Initialize PageRank values
        n = len(graph)
        pagerank = {node: 1.0 / n for node in graph}
        
        # Random jump probability distribution
        base_probability = (1 - self.damping_factor) / n
        
        for iteration in range(self.max_iterations):
            prev_pagerank = pagerank.copy()
            
            # Update PageRank for each node
            for node in graph:
                rank_sum = sum(
                    prev_pagerank[source] * weight 
                    for source, weight in incoming[node].items()
                )
                
                pagerank[node] = base_probability + self.damping_factor * rank_sum
            
            # Check for convergence
            diff = sum(abs(pagerank[node] - prev_pagerank[node]) 
                      for node in pagerank)
            if diff < self.tolerance:
                break
        
        return pagerank
    
    def handle_dangling_nodes(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, Dict[str, float]]:
        """
        Handle dangling nodes (nodes with no outgoing edges) by adding uniform
        transitions to all other nodes.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Graph with handled dangling nodes
        """
        modified_graph = deepcopy(graph)
        n = len(graph)
        
        for node in graph:
            if not graph[node]:  # If node has no outgoing edges
                modified_graph[node] = {
                    other_node: 1.0 / (n - 1)
                    for other_node in graph
                    if other_node != node
                }
                
        return modified_graph
    
    def calculate_distance(self) -> Tuple[Dict[str, float], Dict[str, float], float]:
        """
        Calculate the distance between two graphs based on their PageRank distributions.
        
        Returns:
            Tuple containing:
            - PageRank values for first graph
            - PageRank values for second graph
            - Average absolute difference in PageRank values
        """
        # Handle dangling nodes
        graph1_modified = self.handle_dangling_nodes(self.graph1)
        graph2_modified = self.handle_dangling_nodes(self.graph2)
        
        # Compute PageRank for both graphs
        pagerank1 = self.compute_pagerank(graph1_modified)
        pagerank2 = self.compute_pagerank(graph2_modified)
        
        # Calculate average absolute difference in PageRank values
        all_nodes = set(pagerank1.keys()) | set(pagerank2.keys())
        
        if not all_nodes:
            return {}, {}, 0.0
            
        total_diff = 0.0
        for node in all_nodes:
            val1 = pagerank1.get(node, 0.0)
            val2 = pagerank2.get(node, 0.0)
            total_diff += abs(val1 - val2)
            
        avg_distance = total_diff / len(all_nodes)
        
        return pagerank1, pagerank2, avg_distance
        


##########################################
from typing import Dict, List, Tuple, Set, DefaultDict
from collections import defaultdict
from copy import deepcopy
from math import inf
import heapq

class EdgeBetweennessDistance:
    """
    A class to calculate the Edge Betweenness distance between two weighted graphs.
    Edge Betweenness measures the number of shortest paths passing through each edge.
    
    Attributes:
        graph1 (Dict[str, Dict[str, float]]): First weighted graph represented as adjacency dict
        graph2 (Dict[str, Dict[str, float]]): Second weighted graph represented as adjacency dict
    """
    
    def __init__(self, graph1: Dict[str, Dict[str, float]], graph2: Dict[str, Dict[str, float]]):
        """
        Initialize the EdgeBetweenness calculator with two weighted graphs.
        
        Args:
            graph1: First weighted graph as adjacency dictionary
            graph2: Second weighted graph as adjacency dictionary
        """
        self.graph1 = deepcopy(graph1)
        self.graph2 = deepcopy(graph2)
    
    def dijkstra_with_paths(self, 
                           graph: Dict[str, Dict[str, float]], 
                           start: str) -> Tuple[Dict[str, float], Dict[str, List[List[str]]]]:
        """
        Compute shortest paths from start node to all other nodes using Dijkstra's algorithm.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            start: Starting node
            
        Returns:
            Tuple containing:
            - Dictionary of shortest distances
            - Dictionary mapping each node to its list of shortest paths from start
        """
        distances = {node: inf for node in graph}
        distances[start] = 0
        
        # Initialize paths
        paths: DefaultDict[str, List[List[str]]] = defaultdict(list)
        paths[start] = [[start]]
        
        # Priority queue for Dijkstra
        pq = [(0, start)]
        visited = set()
        
        while pq:
            current_distance, current = heapq.heappop(pq)
            
            if current in visited:
                continue
                
            visited.add(current)
            
            for neighbor, weight in graph[current].items():
                distance = current_distance + weight
                
                if distance < distances[neighbor]:
                    # New shortest path found
                    distances[neighbor] = distance
                    paths[neighbor] = [path + [neighbor] for path in paths[current]]
                    heapq.heappush(pq, (distance, neighbor))
                elif distance == distances[neighbor]:
                    # Additional shortest path found
                    paths[neighbor].extend(path + [neighbor] for path in paths[current])
        
        return distances, paths
    
    def compute_edge_betweenness(self, graph: Dict[str, Dict[str, float]]) -> Dict[Tuple[str, str], float]:
        """
        Compute edge betweenness centrality for all edges.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Dictionary mapping edge tuples to their betweenness values
        """
        betweenness: DefaultDict[Tuple[str, str], float] = defaultdict(float)
        n = len(graph)
        
        if n < 2:
            return dict(betweenness)
        
        # Compute shortest paths from each node
        for start in graph:
            _, paths = self.dijkstra_with_paths(graph, start)
            
            # Count paths through each edge
            for target in graph:
                if target == start:
                    continue
                    
                num_paths = len(paths[target])
                if num_paths == 0:
                    continue
                    
                # Calculate contribution of each path
                contribution = 1.0 / num_paths
                
                # Add contribution to each edge in each path
                for path in paths[target]:
                    for i in range(len(path) - 1):
                        edge = tuple(sorted([path[i], path[i + 1]]))
                        betweenness[edge] += contribution
        
        # Normalize by number of possible paths
        normalization = (n - 1) * (n - 2)
        if normalization > 0:
            betweenness = {
                edge: value / normalization 
                for edge, value in betweenness.items()
            }
        
        return dict(betweenness)
    
    def calculate_distance(self) -> Tuple[Dict[Tuple[str, str], float], 
                                        Dict[Tuple[str, str], float], 
                                        float]:
        """
        Calculate the distance between two graphs based on their edge betweenness distributions.
        
        Returns:
            Tuple containing:
            - Edge betweenness values for first graph
            - Edge betweenness values for second graph
            - Average absolute difference in edge betweenness values
        """
        betweenness1 = self.compute_edge_betweenness(self.graph1)
        betweenness2 = self.compute_edge_betweenness(self.graph2)
        
        # Get all edges from both graphs
        all_edges = set(betweenness1.keys()) | set(betweenness2.keys())
        
        if not all_edges:
            return {}, {}, 0.0
            
        # Calculate average absolute difference in betweenness values
        total_diff = 0.0
        for edge in all_edges:
            val1 = betweenness1.get(edge, 0.0)
            val2 = betweenness2.get(edge, 0.0)
            total_diff += abs(val1 - val2)
            
        avg_distance = total_diff / len(all_edges)
        
        return betweenness1, betweenness2, avg_distance


#################################
from typing import Dict, Set, Tuple, List
from copy import deepcopy

class EdgeWeightDistance:
    """
    A class to calculate the distance between two weighted graphs based on edge weights.
    Compares the weights of corresponding edges and handles cases where edges exist
    in one graph but not the other.
    
    Attributes:
        graph1 (Dict[str, Dict[str, float]]): First weighted graph represented as adjacency dict
        graph2 (Dict[str, Dict[str, float]]): Second weighted graph represented as adjacency dict
    """
    
    def __init__(self, graph1: Dict[str, Dict[str, float]], graph2: Dict[str, Dict[str, float]]):
        """
        Initialize the EdgeWeight calculator with two weighted graphs.
        
        Args:
            graph1: First weighted graph as adjacency dictionary
            graph2: Second weighted graph as adjacency dictionary
        """
        self.graph1 = deepcopy(graph1)
        self.graph2 = deepcopy(graph2)
    
    def get_all_edges(self, graph: Dict[str, Dict[str, float]]) -> Set[Tuple[str, str]]:
        """
        Get all unique edges from a graph.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Set of tuples representing edges (sorted node pairs)
        """
        edges = set()
        for source in graph:
            for target in graph[source]:
                # Store edges as sorted tuples to ensure consistent representation
                edge = tuple(sorted([source, target]))
                edges.add(edge)
        return edges
    
    def get_edge_weight(self, 
                       graph: Dict[str, Dict[str, float]], 
                       edge: Tuple[str, str]) -> float:
        """
        Get the weight of an edge in the graph.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            edge: Tuple of (source, target) nodes
            
        Returns:
            Edge weight or 0.0 if edge doesn't exist
        """
        node1, node2 = edge
        if node1 in graph and node2 in graph[node1]:
            return graph[node1][node2]
        if node2 in graph and node1 in graph[node2]:
            return graph[node2][node1]
        return 0.0
    
    def get_edge_statistics(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Calculate various statistics about edge weights in the graph.
        
        Args:
            graph: Weighted graph as adjacency dictionary
            
        Returns:
            Dictionary containing edge weight statistics
        """
        weights = []
        for source in graph:
            weights.extend(graph[source].values())
            
        if not weights:
            return {
                'min': 0.0,
                'max': 0.0,
                'avg': 0.0,
                'total': 0.0,
                'count': 0
            }
            
        return {
            'min': min(weights),
            'max': max(weights),
            'avg': sum(weights) / len(weights),
            'total': sum(weights),
            'count': len(weights)
        }
    
    def calculate_absolute_distance(self) -> Tuple[Dict[Tuple[str, str], float], float]:
        """
        Calculate absolute differences between edge weights.
        
        Returns:
            Tuple containing:
            - Dictionary mapping edges to their weight differences
            - Average absolute difference across all edges
        """
        edges1 = self.get_all_edges(self.graph1)
        edges2 = self.get_all_edges(self.graph2)
        all_edges = edges1 | edges2
        
        edge_differences = {}
        total_difference = 0.0
        
        for edge in all_edges:
            weight1 = self.get_edge_weight(self.graph1, edge)
            weight2 = self.get_edge_weight(self.graph2, edge)
            difference = abs(weight1 - weight2)
            edge_differences[edge] = difference
            total_difference += difference
            
        avg_difference = total_difference / len(all_edges) if all_edges else 0.0
        
        return edge_differences, avg_difference
    
    def calculate_distance(self) -> Tuple[Dict[str, float], Dict[str, float], Dict[str, float]]:
        """
        Calculate comprehensive distance metrics between the two graphs.
        
        Returns:
            Tuple containing:
            - Statistics for first graph
            - Statistics for second graph
            - Dictionary of distance metrics
        """
        # Get statistics for both graphs
        stats1 = self.get_edge_statistics(self.graph1)
        stats2 = self.get_edge_statistics(self.graph2)
        
        # Calculate edge-by-edge differences
        differences, avg_difference = self.calculate_absolute_distance()
        
        # Calculate additional distance metrics
        metrics = {
            'average_absolute_difference': avg_difference,
            'total_weight_difference': abs(stats1['total'] - stats2['total']),
            'max_weight_difference': abs(stats1['max'] - stats2['max']),
            'edge_count_difference': abs(stats1['count'] - stats2['count']),
            'average_weight_difference': abs(stats1['avg'] - stats2['avg']),
            'number_of_different_edges': len(differences)
        }
        
        return stats1, stats2, metrics
        

##########################################
from typing import Dict, Set

class GraphDensity:
    """
    A class to calculate and compare graph densities for weighted graphs.
    Graphs are represented as adjacency lists using nested dictionaries.
    
    Graph structure format:
    {
        'node1': {'node2': weight1, 'node3': weight2, ...},
        'node2': {'node1': weight1, 'node4': weight3, ...},
        ...
    }
    """
    
    def __init__(self) -> None:
        """Initialize the GraphDensity calculator."""
        pass
    
    def get_nodes(self, graph: Dict[str, Dict[str, float]]) -> Set[str]:
        """
        Extract all unique nodes from the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph as adjacency list
            
        Returns:
            Set[str]: Set of all unique nodes in the graph
        """
        nodes = set(graph.keys())
        for edges in graph.values():
            nodes.update(edges.keys())
        return nodes
    
    def calculate_density(self, graph: Dict[str, Dict[str, float]]) -> float:
        """
        Calculate the density of a weighted graph.
        Density = number of actual edges / maximum possible edges
        For a directed graph with n vertices, maximum edges = n * (n-1)
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph as adjacency list
            
        Returns:
            float: Density value between 0 and 1
        """
        nodes = self.get_nodes(graph)
        n = len(nodes)
        
        if n <= 1:
            return 0.0
        
        # Count actual edges
        actual_edges = sum(len(edges) for edges in graph.values())
        
        # Calculate maximum possible edges for a directed graph
        max_possible_edges = n * (n - 1)
        
        # Compute density
        density = actual_edges / max_possible_edges
        return density
    
    def compare_densities(self, graph1: Dict[str, Dict[str, float]], 
                         graph2: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compare the densities of two weighted graphs.
        
        Args:
            graph1 (Dict[str, Dict[str, float]]): First input graph
            graph2 (Dict[str, Dict[str, float]]): Second input graph
            
        Returns:
            Dict[str, float]: Dictionary containing both densities and their difference
        """
        density1 = self.calculate_density(graph1)
        density2 = self.calculate_density(graph2)
        
        return {
            'density_graph1': density1,
            'density_graph2': density2,
            'density_difference': abs(density1 - density2)
        }


########################################
from typing import Dict, List, Set, Tuple
import math

class GraphDiameter:
    """
    A class to calculate the diameter of weighted graphs and compare diameters between graphs.
    The diameter is defined as the longest shortest path between any two nodes.
    Uses Floyd-Warshall algorithm to compute all-pairs shortest paths.
    
    Graph structure format:
    {
        'node1': {'node2': weight1, 'node3': weight2, ...},
        'node2': {'node1': weight1, 'node4': weight3, ...},
        ...
    }
    """
    
    def __init__(self) -> None:
        """Initialize the GraphDiameter calculator."""
        pass
    
    def get_nodes(self, graph: Dict[str, Dict[str, float]]) -> Set[str]:
        """
        Extract all unique nodes from the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph as adjacency list
            
        Returns:
            Set[str]: Set of all unique nodes in the graph
        """
        nodes = set(graph.keys())
        for edges in graph.values():
            nodes.update(edges.keys())
        return nodes
    
    def initialize_distance_matrix(self, graph: Dict[str, Dict[str, float]], 
                                 nodes: List[str]) -> List[List[float]]:
        """
        Initialize the distance matrix for Floyd-Warshall algorithm.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            nodes (List[str]): List of all nodes
            
        Returns:
            List[List[float]]: Initial distance matrix
        """
        n = len(nodes)
        # Create node to index mapping
        node_to_idx = {node: idx for idx, node in enumerate(nodes)}
        
        # Initialize with infinity
        dist = [[float('inf')] * n for _ in range(n)]
        
        # Set diagonal to 0
        for i in range(n):
            dist[i][i] = 0
            
        # Fill known distances
        for node1, edges in graph.items():
            for node2, weight in edges.items():
                i, j = node_to_idx[node1], node_to_idx[node2]
                dist[i][j] = weight
                
        return dist
    
    def floyd_warshall(self, graph: Dict[str, Dict[str, float]]) -> Tuple[float, List[str]]:
        """
        Implement Floyd-Warshall algorithm to find all-pairs shortest paths
        and determine the diameter of the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            Tuple[float, List[str]]: Tuple containing:
                - diameter (float): Length of the longest shortest path
                - path (List[str]): Nodes in the diameter path
        """
        nodes = list(self.get_nodes(graph))
        if not nodes:
            return 0.0, []
            
        n = len(nodes)
        dist = self.initialize_distance_matrix(graph, nodes)
        
        # Store path information
        next_node = [[None] * n for _ in range(n)]
        for i in range(n):
            for j in range(n):
                if dist[i][j] < float('inf'):
                    next_node[i][j] = j
        
        # Floyd-Warshall algorithm
        for k in range(n):
            for i in range(n):
                for j in range(n):
                    if dist[i][k] < float('inf') and dist[k][j] < float('inf'):
                        if dist[i][j] > dist[i][k] + dist[k][j]:
                            dist[i][j] = dist[i][k] + dist[k][j]
                            next_node[i][j] = next_node[i][k]
        
        # Find diameter and corresponding path
        diameter = 0.0
        start_idx = end_idx = 0
        
        for i in range(n):
            for j in range(n):
                if dist[i][j] < float('inf') and dist[i][j] > diameter:
                    diameter = dist[i][j]
                    start_idx, end_idx = i, j
        
        # Reconstruct diameter path
        if diameter == 0.0:
            return 0.0, []
            
        path = []
        current = start_idx
        while current is not None and current != end_idx:
            path.append(nodes[current])
            current = next_node[current][end_idx]
        path.append(nodes[end_idx])
        
        return diameter, path
    
    def calculate_diameter(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, any]:
        """
        Calculate the diameter of a weighted graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            Dict[str, any]: Dictionary containing the diameter value and the path
        """
        diameter, path = self.floyd_warshall(graph)
        return {
            'diameter': diameter,
            'diameter_path': path
        }
    
    def compare_diameters(self, graph1: Dict[str, Dict[str, float]], 
                         graph2: Dict[str, Dict[str, float]]) -> Dict[str, any]:
        """
        Compare the diameters of two weighted graphs.
        
        Args:
            graph1 (Dict[str, Dict[str, float]]): First input graph
            graph2 (Dict[str, Dict[str, float]]): Second input graph
            
        Returns:
            Dict[str, any]: Dictionary containing both diameters, their paths and difference
        """
        result1 = self.calculate_diameter(graph1)
        result2 = self.calculate_diameter(graph2)
        
        return {
            'diameter_graph1': result1['diameter'],
            'path_graph1': result1['diameter_path'],
            'diameter_graph2': result2['diameter'],
            'path_graph2': result2['diameter_path'],
            'diameter_difference': abs(result1['diameter'] - result2['diameter'])
        }
        


#################################################
from typing import Dict, List, Set, Tuple
from collections import defaultdict

class AveragePathLength:
    """
    A class to calculate the average path length in weighted graphs and compare
    between graphs. The average path length is the mean of all shortest paths
    between all pairs of nodes in the graph.
    
    Graph structure format:
    {
        'node1': {'node2': weight1, 'node3': weight2, ...},
        'node2': {'node1': weight1, 'node4': weight3, ...},
        ...
    }
    """
    
    def __init__(self) -> None:
        """Initialize the AveragePathLength calculator."""
        pass

    def get_nodes(self, graph: Dict[str, Dict[str, float]]) -> Set[str]:
        """
        Extract all unique nodes from the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph as adjacency list
            
        Returns:
            Set[str]: Set of all unique nodes in the graph
        """
        nodes = set(graph.keys())
        for edges in graph.values():
            nodes.update(edges.keys())
        return nodes

    def dijkstra(self, graph: Dict[str, Dict[str, float]], 
                 start: str) -> Dict[str, float]:
        """
        Implement Dijkstra's algorithm to find shortest paths from a start node
        to all other nodes.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            start (str): Starting node
            
        Returns:
            Dict[str, float]: Dictionary of shortest distances to each node
        """
        nodes = self.get_nodes(graph)
        distances = {node: float('inf') for node in nodes}
        distances[start] = 0
        unvisited = set(nodes)
        
        while unvisited:
            # Find unvisited node with minimum distance
            current = min(unvisited, key=lambda node: distances[node])
            
            if distances[current] == float('inf'):
                break
                
            unvisited.remove(current)
            
            # Update distances to neighbors
            for neighbor, weight in graph.get(current, {}).items():
                if neighbor in unvisited:
                    new_distance = distances[current] + weight
                    if new_distance < distances[neighbor]:
                        distances[neighbor] = new_distance
        
        return distances

    def calculate_average_path(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Calculate the average path length of a weighted graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            Dict[str, float]: Dictionary containing average path length and total paths count
        """
        nodes = list(self.get_nodes(graph))
        if len(nodes) <= 1:
            return {
                'average_path_length': 0.0,
                'total_paths': 0,
                'connected_paths': 0
            }
        
        total_distance = 0.0
        total_possible_paths = len(nodes) * (len(nodes) - 1)  # Excluding self-paths
        connected_paths = 0
        
        # Calculate shortest paths between all pairs
        for start_node in nodes:
            distances = self.dijkstra(graph, start_node)
            for end_node, distance in distances.items():
                if start_node != end_node:  # Exclude self-paths
                    if distance != float('inf'):
                        total_distance += distance
                        connected_paths += 1
        
        # Calculate average path length
        average_length = (total_distance / connected_paths) if connected_paths > 0 else float('inf')
        
        return {
            'average_path_length': average_length,
            'total_possible_paths': total_possible_paths,
            'connected_paths': connected_paths
        }

    def compare_average_paths(self, graph1: Dict[str, Dict[str, float]], 
                            graph2: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compare the average path lengths of two weighted graphs.
        
        Args:
            graph1 (Dict[str, Dict[str, float]]): First input graph
            graph2 (Dict[str, Dict[str, float]]): Second input graph
            
        Returns:
            Dict[str, float]: Dictionary containing both averages and their difference
        """
        result1 = self.calculate_average_path(graph1)
        result2 = self.calculate_average_path(graph2)
        
        return {
            'avg_path_length_graph1': result1['average_path_length'],
            'connected_paths_graph1': result1['connected_paths'],
            'avg_path_length_graph2': result2['average_path_length'],
            'connected_paths_graph2': result2['connected_paths'],
            'avg_length_difference': abs(result1['average_path_length'] - 
                                      result2['average_path_length'])
        }
        


#####################################################

from typing import Dict, Set, List
from itertools import combinations

class ClusteringCoefficient:
    """
    A class to calculate the clustering coefficient of weighted graphs and compare
    between graphs. The clustering coefficient measures the degree to which nodes
    tend to cluster together.
    
    For weighted graphs, we use a generalized version that takes edge weights
    into account when calculating triangles.
    
    Graph structure format:
    {
        'node1': {'node2': weight1, 'node3': weight2, ...},
        'node2': {'node1': weight1, 'node4': weight3, ...},
        ...
    }
    """
    
    def __init__(self) -> None:
        """Initialize the ClusteringCoefficient calculator."""
        pass

    def get_neighbors(self, graph: Dict[str, Dict[str, float]], 
                     node: str) -> Set[str]:
        """
        Get all neighbors of a given node.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            node (str): Node to find neighbors for
            
        Returns:
            Set[str]: Set of neighboring nodes
        """
        return set(graph.get(node, {}).keys())

    def calculate_node_clustering(self, graph: Dict[str, Dict[str, float]], 
                                node: str) -> float:
        """
        Calculate the clustering coefficient for a single node.
        For weighted graphs, we use the geometric mean of edge weights
        in triangles.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            node (str): Node to calculate clustering for
            
        Returns:
            float: Local clustering coefficient for the node
        """
        neighbors = self.get_neighbors(graph, node)
        if len(neighbors) < 2:
            return 0.0
        
        possible_triangles = len(neighbors) * (len(neighbors) - 1) / 2
        if possible_triangles == 0:
            return 0.0
        
        actual_triangles = 0.0
        
        # Check all possible pairs of neighbors
        for n1, n2 in combinations(neighbors, 2):
            # Check if there's an edge between neighbors
            n1_neighbors = self.get_neighbors(graph, n1)
            if n2 in n1_neighbors:
                # Calculate weighted contribution using geometric mean
                weight1 = graph[node][n1]
                weight2 = graph[node][n2]
                weight3 = graph[n1][n2]
                triangle_weight = (weight1 * weight2 * weight3) ** (1/3)
                actual_triangles += triangle_weight
        
        return actual_triangles / possible_triangles

    def calculate_average_clustering(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Calculate the average clustering coefficient for the entire graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            Dict[str, float]: Dictionary containing average clustering coefficient
                             and individual node coefficients
        """
        if not graph:
            return {
                'average_clustering': 0.0,
                'node_coefficients': {},
                'valid_nodes': 0
            }
        
        node_coefficients = {}
        valid_nodes = 0
        
        for node in graph:
            coeff = self.calculate_node_clustering(graph, node)
            node_coefficients[node] = coeff
            if len(self.get_neighbors(graph, node)) > 1:
                valid_nodes += 1
        
        average_clustering = (sum(node_coefficients.values()) / valid_nodes 
                            if valid_nodes > 0 else 0.0)
        
        return {
            'average_clustering': average_clustering,
            'node_coefficients': node_coefficients,
            'valid_nodes': valid_nodes
        }

    def compare_clustering(self, graph1: Dict[str, Dict[str, float]], 
                         graph2: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compare the clustering coefficients of two weighted graphs.
        
        Args:
            graph1 (Dict[str, Dict[str, float]]): First input graph
            graph2 (Dict[str, Dict[str, float]]): Second input graph
            
        Returns:
            Dict[str, float]: Dictionary containing both coefficients and their difference
        """
        result1 = self.calculate_average_clustering(graph1)
        result2 = self.calculate_average_clustering(graph2)
        
        return {
            'clustering_graph1': result1['average_clustering'],
            'valid_nodes_graph1': result1['valid_nodes'],
            'clustering_graph2': result2['average_clustering'],
            'valid_nodes_graph2': result2['valid_nodes'],
            'clustering_difference': abs(result1['average_clustering'] - 
                                      result2['average_clustering'])
        }

    def get_highly_clustered_nodes(self, graph: Dict[str, Dict[str, float]], 
                                 threshold: float = 0.7) -> List[str]:
        """
        Find nodes with clustering coefficient above a given threshold.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            threshold (float): Minimum clustering coefficient (default: 0.7)
            
        Returns:
            List[str]: List of nodes with high clustering coefficients
        """
        result = self.calculate_average_clustering(graph)
        return [node for node, coeff in result['node_coefficients'].items() 
                if coeff >= threshold]
                


############################################
from typing import Dict, List, Tuple
from collections import defaultdict

class GraphAssortativity:
    """
    A class to calculate degree assortativity in weighted graphs.
    Assortativity measures the tendency of nodes to connect to nodes with similar degrees.
    Values range from -1 (disassortative) to 1 (assortative).
    
    Graph structure format:
    {
        'node1': {'node2': weight1, 'node3': weight2, ...},
        'node2': {'node1': weight1, 'node4': weight3, ...},
        ...
    }
    """
    
    def __init__(self) -> None:
        """Initialize the GraphAssortativity calculator."""
        pass

    def calculate_node_degree(self, graph: Dict[str, Dict[str, float]], 
                            weighted: bool = True) -> Dict[str, float]:
        """
        Calculate the degree of each node in the graph.
        For weighted graphs, returns the sum of edge weights.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            weighted (bool): If True, use edge weights; if False, count edges
            
        Returns:
            Dict[str, float]: Dictionary mapping nodes to their degrees
        """
        degrees = defaultdict(float)
        
        for node, edges in graph.items():
            if weighted:
                degrees[node] = sum(edges.values())
            else:
                degrees[node] = len(edges)
                
            # Add incoming edges
            for target_node, weight in edges.items():
                if weighted:
                    degrees[target_node] += weight
                else:
                    degrees[target_node] += 1
                    
        return dict(degrees)

    def get_edge_pairs(self, graph: Dict[str, Dict[str, float]]) -> List[Tuple[str, str, float]]:
        """
        Get all edges in the graph as (source, target, weight) tuples.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            List[Tuple[str, str, float]]: List of edge tuples
        """
        edges = []
        for source, targets in graph.items():
            for target, weight in targets.items():
                edges.append((source, target, weight))
        return edges

    def calculate_assortativity(self, graph: Dict[str, Dict[str, float]], 
                              weighted: bool = True) -> Dict[str, float]:
        """
        Calculate the degree assortativity coefficient of the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            weighted (bool): If True, use weighted degrees
            
        Returns:
            Dict[str, float]: Dictionary containing assortativity metrics
        """
        if not graph:
            return {
                'assortativity': 0.0,
                'edge_count': 0,
                'average_degree': 0.0
            }

        # Calculate node degrees
        degrees = self.calculate_node_degree(graph, weighted)
        edges = self.get_edge_pairs(graph)
        
        if not edges:
            return {
                'assortativity': 0.0,
                'edge_count': 0,
                'average_degree': 0.0
            }

        # Calculate sums for assortativity formula
        sum_ji = 0.0  # Sum of source * target degrees
        sum_j = 0.0   # Sum of source degrees
        sum_i = 0.0   # Sum of target degrees
        sum_sq_j = 0.0  # Sum of squared source degrees
        sum_sq_i = 0.0  # Sum of squared target degrees
        
        M = len(edges)  # Number of edges
        
        for source, target, weight in edges:
            source_degree = degrees[source]
            target_degree = degrees[target]
            
            if weighted:
                weight_factor = weight
            else:
                weight_factor = 1.0
                
            sum_ji += source_degree * target_degree * weight_factor
            sum_j += source_degree * weight_factor
            sum_i += target_degree * weight_factor
            sum_sq_j += source_degree * source_degree * weight_factor
            sum_sq_i += target_degree * target_degree * weight_factor

        # Normalize sums
        sum_ji /= M
        sum_j /= M
        sum_i /= M
        sum_sq_j /= M
        sum_sq_i /= M
        
        # Calculate assortativity coefficient
        numerator = sum_ji - sum_j * sum_i
        denominator = (sum_sq_j - sum_j * sum_j) * (sum_sq_i - sum_i * sum_i)
        denominator = denominator ** 0.5  # Square root
        
        assortativity = numerator / denominator if denominator != 0 else 0.0
        
        return {
            'assortativity': assortativity,
            'edge_count': M,
            'average_degree': sum(degrees.values()) / len(degrees)
        }

    def compare_assortativity(self, graph1: Dict[str, Dict[str, float]], 
                            graph2: Dict[str, Dict[str, float]], 
                            weighted: bool = True) -> Dict[str, float]:
        """
        Compare the assortativity of two graphs.
        
        Args:
            graph1 (Dict[str, Dict[str, float]]): First input graph
            graph2 (Dict[str, Dict[str, float]]): Second input graph
            weighted (bool): If True, use weighted calculations
            
        Returns:
            Dict[str, float]: Dictionary containing comparison metrics
        """
        result1 = self.calculate_assortativity(graph1, weighted)
        result2 = self.calculate_assortativity(graph2, weighted)
        
        return {
            'assortativity_graph1': result1['assortativity'],
            'assortativity_graph2': result2['assortativity'],
            'assortativity_difference': abs(result1['assortativity'] - 
                                          result2['assortativity']),
            'avg_degree_graph1': result1['average_degree'],
            'avg_degree_graph2': result2['average_degree']
        }
        


################################################
from typing import Dict, Set, Tuple
import math

class GlobalEfficiency:
    """
    A class to calculate the global efficiency of weighted graphs.
    Global efficiency measures how efficiently information is exchanged across the network,
    calculated as the average of the inverse shortest path lengths between all pairs of nodes.
    
    Graph structure format:
    {
        'node1': {'node2': weight1, 'node3': weight2, ...},
        'node2': {'node1': weight1, 'node4': weight3, ...},
        ...
    }
    """
    
    def __init__(self) -> None:
        """Initialize the GlobalEfficiency calculator."""
        pass

    def get_nodes(self, graph: Dict[str, Dict[str, float]]) -> Set[str]:
        """
        Extract all unique nodes from the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph as adjacency list
            
        Returns:
            Set[str]: Set of all unique nodes in the graph
        """
        nodes = set(graph.keys())
        for edges in graph.values():
            nodes.update(edges.keys())
        return nodes

    def dijkstra(self, graph: Dict[str, Dict[str, float]], 
                 start: str) -> Dict[str, float]:
        """
        Implement Dijkstra's algorithm to find shortest paths from a start node
        to all other nodes.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            start (str): Starting node
            
        Returns:
            Dict[str, float]: Dictionary of shortest distances to each node
        """
        nodes = self.get_nodes(graph)
        distances = {node: float('inf') for node in nodes}
        distances[start] = 0
        unvisited = set(nodes)
        
        while unvisited:
            # Find unvisited node with minimum distance
            current = min(unvisited, key=lambda node: distances[node])
            
            if distances[current] == float('inf'):
                break
                
            unvisited.remove(current)
            
            # Update distances to neighbors
            for neighbor, weight in graph.get(current, {}).items():
                if neighbor in unvisited:
                    new_distance = distances[current] + weight
                    if new_distance < distances[neighbor]:
                        distances[neighbor] = new_distance
        
        return distances

    def calculate_global_efficiency(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Calculate the global efficiency of a weighted graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            Dict[str, float]: Dictionary containing efficiency metrics
        """
        nodes = list(self.get_nodes(graph))
        n = len(nodes)
        
        if n <= 1:
            return {
                'global_efficiency': 0.0,
                'average_path_length': float('inf'),
                'connected_pairs': 0
            }

        total_efficiency = 0.0
        total_distance = 0.0
        connected_pairs = 0
        possible_pairs = n * (n - 1)  # Total possible pairs excluding self-pairs
        
        # Calculate shortest paths and efficiencies between all pairs
        for start_node in nodes:
            distances = self.dijkstra(graph, start_node)
            for end_node, distance in distances.items():
                if start_node != end_node:  # Exclude self-pairs
                    if distance != float('inf'):
                        efficiency = 1.0 / distance
                        total_efficiency += efficiency
                        total_distance += distance
                        connected_pairs += 1
        
        # Calculate global efficiency as average of inverse path lengths
        global_efficiency = total_efficiency / possible_pairs if possible_pairs > 0 else 0.0
        
        # Calculate average path length for connected pairs
        average_path_length = (total_distance / connected_pairs 
                             if connected_pairs > 0 else float('inf'))
        
        return {
            'global_efficiency': global_efficiency,
            'average_path_length': average_path_length,
            'connected_pairs': connected_pairs,
            'possible_pairs': possible_pairs,
            'connectivity_ratio': connected_pairs / possible_pairs if possible_pairs > 0 else 0.0
        }

    def calculate_node_efficiency(self, graph: Dict[str, Dict[str, float]], 
                                node: str) -> float:
        """
        Calculate the efficiency of a single node (local efficiency).
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            node (str): Node to calculate efficiency for
            
        Returns:
            float: Local efficiency of the node
        """
        distances = self.dijkstra(graph, node)
        total_efficiency = 0.0
        valid_pairs = 0
        
        for target, distance in distances.items():
            if target != node and distance != float('inf'):
                total_efficiency += 1.0 / distance
                valid_pairs += 1
                
        return total_efficiency / valid_pairs if valid_pairs > 0 else 0.0

    def compare_efficiency(self, graph1: Dict[str, Dict[str, float]], 
                         graph2: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compare the global efficiency of two weighted graphs.
        
        Args:
            graph1 (Dict[str, Dict[str, float]]): First input graph
            graph2 (Dict[str, Dict[str, float]]): Second input graph
            
        Returns:
            Dict[str, float]: Dictionary containing comparison metrics
        """
        result1 = self.calculate_global_efficiency(graph1)
        result2 = self.calculate_global_efficiency(graph2)
        
        return {
            'efficiency_graph1': result1['global_efficiency'],
            'efficiency_graph2': result2['global_efficiency'],
            'efficiency_difference': abs(result1['global_efficiency'] - 
                                      result2['global_efficiency']),
            'connectivity_ratio_graph1': result1['connectivity_ratio'],
            'connectivity_ratio_graph2': result2['connectivity_ratio']
        }


############################################
from typing import Dict, List, Set, DefaultDict
from collections import defaultdict
import random

class GraphModularity:
    """
    A class to calculate the modularity of weighted graphs using the Louvain method.
    Modularity measures how well a network is divided into communities.
    Values range from -1 to 1, where higher values indicate better community structure.
    
    Graph structure format:
    {
        'node1': {'node2': weight1, 'node3': weight2, ...},
        'node2': {'node1': weight1, 'node4': weight3, ...},
        ...
    }
    """
    
    def __init__(self) -> None:
        """Initialize the GraphModularity calculator."""
        self.MIN_IMPROVEMENT = 1e-6
    
    def get_nodes(self, graph: Dict[str, Dict[str, float]]) -> Set[str]:
        """
        Extract all unique nodes from the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            Set[str]: Set of all nodes
        """
        nodes = set(graph.keys())
        for edges in graph.values():
            nodes.update(edges.keys())
        return nodes

    def calculate_total_weight(self, graph: Dict[str, Dict[str, float]]) -> float:
        """
        Calculate the total weight of all edges in the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            float: Total edge weight
        """
        return sum(weight for edges in graph.values() for weight in edges.values())

    def get_node_strength(self, graph: Dict[str, Dict[str, float]], 
                         node: str) -> float:
        """
        Calculate the total weight of edges connected to a node.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            node (str): Target node
            
        Returns:
            float: Total weight of connected edges
        """
        strength = sum(graph.get(node, {}).values())
        # Add incoming edges
        strength += sum(edges[node] for edges in graph.values() if node in edges)
        return strength

    def initialize_communities(self, nodes: Set[str]) -> Dict[str, str]:
        """
        Initialize each node in its own community.
        
        Args:
            nodes (Set[str]): Set of all nodes
            
        Returns:
            Dict[str, str]: Mapping of nodes to their communities
        """
        return {node: str(idx) for idx, node in enumerate(nodes)}

    def calculate_modularity(self, graph: Dict[str, Dict[str, float]], 
                           communities: Dict[str, str]) -> float:
        """
        Calculate the modularity of the graph given a community assignment.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            communities (Dict[str, str]): Node to community mapping
            
        Returns:
            float: Modularity value
        """
        total_weight = self.calculate_total_weight(graph)
        if total_weight == 0:
            return 0.0
        
        modularity = 0.0
        
        for node1, edges in graph.items():
            node1_strength = self.get_node_strength(graph, node1)
            
            for node2, weight in edges.items():
                node2_strength = self.get_node_strength(graph, node2)
                expected = node1_strength * node2_strength / (2 * total_weight)
                
                if communities[node1] == communities[node2]:
                    modularity += weight - expected
                    
        return modularity / (2 * total_weight)

    def find_communities(self, graph: Dict[str, Dict[str, float]], 
                        max_iterations: int = 100) -> Dict[str, any]:
        """
        Find communities using the Louvain method.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            max_iterations (int): Maximum number of iterations
            
        Returns:
            Dict[str, any]: Dictionary containing community assignments and metrics
        """
        nodes = self.get_nodes(graph)
        communities = self.initialize_communities(nodes)
        
        total_weight = self.calculate_total_weight(graph)
        if total_weight == 0:
            return {
                'communities': communities,
                'modularity': 0.0,
                'num_communities': len(set(communities.values())),
                'iterations': 0
            }
        
        best_modularity = self.calculate_modularity(graph, communities)
        iteration = 0
        improved = True
        
        while improved and iteration < max_iterations:
            improved = False
            iteration += 1
            
            # Randomize node order for each iteration
            nodes_list = list(nodes)
            random.shuffle(nodes_list)
            
            for node in nodes_list:
                # Find best community for this node
                current_community = communities[node]
                best_gain = 0.0
                best_community = current_community
                
                # Calculate gains for moving to each neighbor's community
                neighbor_communities = set()
                for neighbor in graph.get(node, {}):
                    neighbor_communities.add(communities[neighbor])
                
                for target_community in neighbor_communities:
                    if target_community != current_community:
                        # Temporarily move node to new community
                        communities[node] = target_community
                        new_modularity = self.calculate_modularity(graph, communities)
                        gain = new_modularity - best_modularity
                        
                        if gain > best_gain:
                            best_gain = gain
                            best_community = target_community
                        
                        # Move back to original community
                        communities[node] = current_community
                
                # Make the best move if it improves modularity
                if best_gain > self.MIN_IMPROVEMENT:
                    communities[node] = best_community
                    best_modularity = best_modularity + best_gain
                    improved = True
        
        return {
            'communities': communities,
            'modularity': best_modularity,
            'num_communities': len(set(communities.values())),
            'iterations': iteration
        }

    def compare_modularity(self, graph1: Dict[str, Dict[str, float]], 
                          graph2: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Compare the modularity of two graphs.
        
        Args:
            graph1 (Dict[str, Dict[str, float]]): First input graph
            graph2 (Dict[str, Dict[str, float]]): Second input graph
            
        Returns:
            Dict[str, float]: Dictionary containing comparison metrics
        """
        result1 = self.find_communities(graph1)
        result2 = self.find_communities(graph2)
        
        return {
            'modularity_graph1': result1['modularity'],
            'modularity_graph2': result2['modularity'],
            'modularity_difference': abs(result1['modularity'] - result2['modularity']),
            'communities_graph1': result1['num_communities'],
            'communities_graph2': result2['num_communities']
        }


###################################################
from typing import Dict, List, Set, Tuple, Optional
from collections import defaultdict
import random
import math

class ModularityOptimization:
    """
    A class to optimize community detection in weighted graphs using multiple
    methods and metrics. Implements several optimization strategies to find
    the best community structure.
    
    Graph structure format:
    {
        'node1': {'node2': weight1, 'node3': weight2, ...},
        'node2': {'node1': weight1, 'node4': weight3, ...},
        ...
    }
    """
    
    def __init__(self) -> None:
        """Initialize the ModularityOptimization calculator."""
        self.MIN_IMPROVEMENT = 1e-6
        self.MAX_ITERATIONS = 100
    
    def get_nodes(self, graph: Dict[str, Dict[str, float]]) -> Set[str]:
        """
        Extract all unique nodes from the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            Set[str]: Set of all nodes
        """
        nodes = set(graph.keys())
        for edges in graph.values():
            nodes.update(edges.keys())
        return nodes

    def calculate_edge_weights(self, graph: Dict[str, Dict[str, float]]) -> Dict[str, float]:
        """
        Calculate total edge weights for each node.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            
        Returns:
            Dict[str, float]: Dictionary of node weights
        """
        weights = defaultdict(float)
        for node, edges in graph.items():
            weights[node] = sum(edges.values())
            # Add incoming edges
            for source, targets in graph.items():
                if node in targets:
                    weights[node] += targets[node]
        return dict(weights)

    def calculate_modularity_gain(self, graph: Dict[str, Dict[str, float]],
                                node: str,
                                community: str,
                                communities: Dict[str, str],
                                total_weight: float,
                                node_weights: Dict[str, float]) -> float:
        """
        Calculate the modularity gain for moving a node to a community.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            node (str): Node to move
            community (str): Target community
            communities (Dict[str, str]): Current community assignments
            total_weight (float): Total edge weight in graph
            node_weights (Dict[str, float]): Precomputed node weights
            
        Returns:
            float: Modularity gain
        """
        k_i = node_weights[node]
        k_i_in = 0.0
        
        # Calculate connections to target community
        for neighbor, weight in graph.get(node, {}).items():
            if communities[neighbor] == community:
                k_i_in += weight
        
        # Add incoming edges
        for source, edges in graph.items():
            if node in edges and communities[source] == community:
                k_i_in += edges[node]
        
        # Calculate total weight of target community
        k_c = sum(node_weights[n] for n, c in communities.items() if c == community)
        
        return (k_i_in - k_i * k_c / (2 * total_weight))

    def optimize_communities(self, graph: Dict[str, Dict[str, float]], 
                           initial_communities: Optional[Dict[str, str]] = None,
                           method: str = "louvain") -> Dict[str, any]:
        """
        Optimize community assignments using specified method.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            initial_communities (Optional[Dict[str, str]]): Initial community assignments
            method (str): Optimization method ("louvain" or "fast_greedy")
            
        Returns:
            Dict[str, any]: Optimization results
        """
        nodes = self.get_nodes(graph)
        if initial_communities is None:
            initial_communities = {node: str(i) for i, node in enumerate(nodes)}
            
        total_weight = sum(sum(edges.values()) for edges in graph.values())
        node_weights = self.calculate_edge_weights(graph)
        
        communities = initial_communities.copy()
        best_modularity = self.calculate_total_modularity(graph, communities)
        
        iterations = 0
        improved = True
        
        while improved and iterations < self.MAX_ITERATIONS:
            improved = False
            iterations += 1
            
            if method == "louvain":
                improved = self._louvain_iteration(graph, communities, total_weight, 
                                                 node_weights, best_modularity)
            elif method == "fast_greedy":
                improved = self._fast_greedy_iteration(graph, communities, total_weight, 
                                                     node_weights, best_modularity)
            
            if improved:
                best_modularity = self.calculate_total_modularity(graph, communities)
        
        # Calculate additional quality metrics
        metrics = self.calculate_quality_metrics(graph, communities)
        
        return {
            'communities': communities,
            'modularity': best_modularity,
            'num_communities': len(set(communities.values())),
            'iterations': iterations,
            'quality_metrics': metrics
        }

    def _louvain_iteration(self, graph: Dict[str, Dict[str, float]], 
                          communities: Dict[str, str],
                          total_weight: float,
                          node_weights: Dict[str, float],
                          current_modularity: float) -> bool:
        """
        Perform one iteration of Louvain optimization.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            communities (Dict[str, str]): Current community assignments
            total_weight (float): Total edge weight
            node_weights (Dict[str, float]): Node weights
            current_modularity (float): Current modularity value
            
        Returns:
            bool: True if improvements were made
        """
        improved = False
        nodes = list(self.get_nodes(graph))
        random.shuffle(nodes)
        
        for node in nodes:
            current_community = communities[node]
            best_community = current_community
            best_gain = 0
            
            # Try moving to neighboring communities
            neighbor_communities = set()
            for neighbor in graph.get(node, {}):
                neighbor_communities.add(communities[neighbor])
            
            for target_community in neighbor_communities:
                if target_community != current_community:
                    gain = self.calculate_modularity_gain(
                        graph, node, target_community, communities,
                        total_weight, node_weights
                    )
                    
                    if gain > best_gain:
                        best_gain = gain
                        best_community = target_community
            
            if best_gain > self.MIN_IMPROVEMENT:
                communities[node] = best_community
                improved = True
        
        return improved

    def calculate_quality_metrics(self, graph: Dict[str, Dict[str, float]], 
                                communities: Dict[str, str]) -> Dict[str, float]:
        """
        Calculate various quality metrics for community assignment.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            communities (Dict[str, str]): Community assignments
            
        Returns:
            Dict[str, float]: Quality metrics
        """
        # Calculate internal density of communities
        internal_density = defaultdict(float)
        external_density = defaultdict(float)
        community_sizes = defaultdict(int)
        
        for node, edges in graph.items():
            comm = communities[node]
            community_sizes[comm] += 1
            
            for target, weight in edges.items():
                if communities[target] == comm:
                    internal_density[comm] += weight
                else:
                    external_density[comm] += weight
        
        # Calculate metrics
        conductance = sum(
            external_density[c] / (internal_density[c] + external_density[c])
            for c in community_sizes if internal_density[c] + external_density[c] > 0
        ) / len(community_sizes) if community_sizes else 0
        
        coverage = sum(internal_density.values()) / (
            sum(internal_density.values()) + sum(external_density.values())
        ) if sum(internal_density.values()) + sum(external_density.values()) > 0 else 0
        
        return {
            'conductance': conductance,
            'coverage': coverage,
            'num_communities': len(community_sizes),
            'avg_community_size': sum(community_sizes.values()) / len(community_sizes)
                if community_sizes else 0
        }

    def calculate_total_modularity(self, graph: Dict[str, Dict[str, float]], 
                                 communities: Dict[str, str]) -> float:
        """
        Calculate total modularity of the graph.
        
        Args:
            graph (Dict[str, Dict[str, float]]): Input graph
            communities (Dict[str, str]): Community assignments
            
        Returns:
            float: Total modularity value
        """
        total_weight = sum(sum(edges.values()) for edges in graph.values())
        if total_weight == 0:
            return 0.0
            
        node_weights = self.calculate_edge_weights(graph)
        modularity = 0.0
        
        for node1, edges in graph.items():
            for node2, weight in edges.items():
                if communities[node1] == communities[node2]:
                    expected = node_weights[node1] * node_weights[node2] / (2 * total_weight)
                    modularity += weight - expected
                    
        return modularity / (2 * total_weight)
        


#############################################
from typing import Dict, Set, List, Tuple, Optional
from collections import defaultdict
import math

class GraphDistanceCalculator:
    """
    A class to calculate various distance metrics between two weighted graphs.
    Graphs are represented as nested dictionaries where:
    - Outer key: source node
    - Inner key: target node
    - Value: edge weight
    
    Example:
    {
        'A': {'B': 0.5, 'C': 0.3},
        'B': {'A': 0.5, 'C': 0.2},
        'C': {'A': 0.3, 'B': 0.2}
    }
    """

    def __init__(self) -> None:
        """Initialize the calculator."""
        pass

    def get_all_nodes(self, graph1: Dict[str, Dict[str, float]], 
                      graph2: Dict[str, Dict[str, float]]) -> Set[str]:
        """
        Get the union of all nodes from both graphs.
        
        Args:
            graph1: First weighted graph
            graph2: Second weighted graph
            
        Returns:
            Set of all unique nodes from both graphs
        """
        nodes1 = set(graph1.keys()).union(*[set(edges.keys()) for edges in graph1.values()])
        nodes2 = set(graph2.keys()).union(*[set(edges.keys()) for edges in graph2.values()])
        return nodes1.union(nodes2)

    def euclidean_distance(self, graph1: Dict[str, Dict[str, float]], 
                          graph2: Dict[str, Dict[str, float]]) -> float:
        """
        Calculate the Euclidean distance between two graphs.
        
        Args:
            graph1: First weighted graph
            graph2: Second weighted graph
            
        Returns:
            Euclidean distance between the graphs
        """
        all_nodes = self.get_all_nodes(graph1, graph2)
        squared_diff_sum = 0.0
        
        for source in all_nodes:
            for target in all_nodes:
                weight1 = graph1.get(source, {}).get(target, 0.0)
                weight2 = graph2.get(source, {}).get(target, 0.0)
                squared_diff_sum += (weight1 - weight2) ** 2
                
        return math.sqrt(squared_diff_sum)

    def calculate_modularity(self, graph: Dict[str, Dict[str, float]], 
                           communities: Dict[str, int]) -> float:
        """
        Calculate the modularity score for a graph given its community structure.
        
        Args:
            graph: Weighted graph
            communities: Dictionary mapping node to community ID
            
        Returns:
            Modularity score between -1 and 1
        """
        total_weight = sum(sum(edges.values()) for edges in graph.values())
        modularity = 0.0
        
        if total_weight == 0:
            return 0.0
            
        for i, (node1, edges1) in enumerate(graph.items()):
            node1_strength = sum(edges1.values())
            
            for node2, weight in edges1.items():
                if node2 not in graph:
                    continue
                    
                node2_strength = sum(graph[node2].values())
                
                if communities[node1] == communities[node2]:
                    modularity += (weight - (node1_strength * node2_strength) / total_weight)
                    
        return modularity / total_weight

    def modularity_distance(self, graph1: Dict[str, Dict[str, float]], 
                          graph2: Dict[str, Dict[str, float]],
                          communities1: Dict[str, int],
                          communities2: Dict[str, int]) -> float:
        """
        Calculate the difference in modularity scores between two graphs.
        
        Args:
            graph1: First weighted graph
            graph2: Second weighted graph
            communities1: Community structure of first graph
            communities2: Community structure of second graph
            
        Returns:
            Absolute difference between modularity scores
        """
        mod1 = self.calculate_modularity(graph1, communities1)
        mod2 = self.calculate_modularity(graph2, communities2)
        return abs(mod1 - mod2)
        

