defmodule OORL.MCGS do @moduledoc """ Monte Carlo Graph Search implementation with Graph Attention and Contrastive Learning. Implements AAOS Section 7.7 specifications for: - Graph attention mechanisms - Contrastive learning for feature representation - Multi-head attention for complex graph structures - Feature embedding optimization """ defstruct [ :graph, :attention_heads, :feature_embedding_dim, :contrastive_temperature, :max_depth, :iterations, :node_features, :edge_features, :attention_weights, :contrastive_pairs ] defmodule GraphNode do @moduledoc "Graph node structure for MCGS" defstruct [ :id, :type, :features, :embedding, :attention_scores, :neighbors, :visited, :path_cost, :heuristic_value ] end @type graph :: %{nodes: list(), edges: list(), node_features: map(), edge_features: map()} @type search_options :: %{ max_depth: integer(), iterations: integer(), graph_attention: boolean(), contrastive_learning: boolean(), attention_heads: integer(), feature_embedding_dim: integer(), contrastive_temperature: float() } @doc """ Performs Monte Carlo Graph Search with attention and contrastive learning. ## Parameters - `start_node`: Starting node ID - `graph`: Graph structure with nodes, edges, and features - `goal_nodes`: List of goal node IDs - `options`: Search configuration ## Returns `{:ok, search_results}` with path, attention weights, and learned features ## Examples iex> OORL.MCGS.graph_search(1, graph, [45, 50], %{iterations: 500}) {:ok, %{path: [1, 3, 7, 45], attention_weights: weights, features: embeddings}} """ def graph_search(start_node, graph, goal_nodes, options \\ %{}) do try do mcgs_config = %OORL.MCGS{ graph: graph, attention_heads: Map.get(options, :attention_heads, 4), feature_embedding_dim: Map.get(options, :feature_embedding_dim, 16), contrastive_temperature: Map.get(options, :contrastive_temperature, 0.1), max_depth: Map.get(options, :max_depth, 10), iterations: Map.get(options, :iterations, 500), node_features: Map.get(graph, :node_features, %{}), edge_features: Map.get(graph, :edge_features, %{}), attention_weights: initialize_attention_weights(options), contrastive_pairs: [] } # Initialize graph attention if enabled updated_config = if Map.get(options, :graph_attention, true) do initialize_graph_attention(mcgs_config) else mcgs_config end # Perform search with contrastive learning search_results = run_mcgs_search(start_node, goal_nodes, updated_config) # Apply contrastive learning to improve features final_config = if Map.get(options, :contrastive_learning, true) do apply_contrastive_learning(updated_config, search_results) else updated_config end {:ok, %{ path: search_results.best_path, cost: search_results.path_cost, attention_weights: final_config.attention_weights, learned_features: extract_learned_features(final_config), contrastive_loss: calculate_contrastive_loss(final_config), iterations_completed: mcgs_config.iterations, graph_attention_enabled: Map.get(options, :graph_attention, true) }} rescue error -> {:error, "MCGS search failed: #{inspect(error)}"} end end @doc """ Calculates feature similarity for contrastive learning validation. ## Parameters - `features1`: First feature vector - `features2`: Second feature vector - `temperature`: Contrastive learning temperature ## Returns Similarity score between 0 and 1 """ def calculate_feature_similarity(features1, features2, temperature \\ 0.1) do dot_product = calculate_dot_product(features1, features2) norm1 = calculate_vector_norm(features1) norm2 = calculate_vector_norm(features2) if norm1 > 0 and norm2 > 0 do cosine_similarity = dot_product / (norm1 * norm2) # Apply temperature scaling :math.exp(cosine_similarity / temperature) else 0.0 end end @doc """ Validates contrastive learning feature representation quality. ## Parameters - `positive_pairs`: List of {feature1, feature2} tuples that should be similar - `negative_pairs`: List of {feature1, feature2} tuples that should be dissimilar - `temperature`: Contrastive temperature parameter ## Returns `{:ok, validation_results}` with similarity metrics """ def validate_contrastive_learning(positive_pairs, negative_pairs, temperature \\ 0.1) do positive_similarities = Enum.map(positive_pairs, fn {f1, f2} -> calculate_feature_similarity(f1, f2, temperature) end) negative_similarities = Enum.map(negative_pairs, fn {f1, f2} -> calculate_feature_similarity(f1, f2, temperature) end) avg_positive_sim = if length(positive_similarities) > 0 do Enum.sum(positive_similarities) / length(positive_similarities) else 0.0 end avg_negative_sim = if length(negative_similarities) > 0 do Enum.sum(negative_similarities) / length(negative_similarities) else 0.0 end separation_margin = avg_positive_sim - avg_negative_sim {:ok, %{ avg_positive_similarity: avg_positive_sim, avg_negative_similarity: avg_negative_sim, separation_margin: separation_margin, positive_count: length(positive_pairs), negative_count: length(negative_pairs), quality_score: calculate_representation_quality(avg_positive_sim, avg_negative_sim) }} end # Private implementation functions defp initialize_attention_weights(options) do attention_heads = Map.get(options, :attention_heads, 4) embedding_dim = Map.get(options, :feature_embedding_dim, 16) # Initialize random attention weights for each head Enum.reduce(1..attention_heads, %{}, fn head, acc -> Map.put(acc, head, initialize_head_weights(embedding_dim)) end) end defp initialize_head_weights(embedding_dim) do %{ query_weights: Enum.map(1..embedding_dim, fn _ -> :rand.normal(0, 0.1) end), key_weights: Enum.map(1..embedding_dim, fn _ -> :rand.normal(0, 0.1) end), value_weights: Enum.map(1..embedding_dim, fn _ -> :rand.normal(0, 0.1) end) } end defp initialize_graph_attention(mcgs_config) do # Compute initial node embeddings node_embeddings = Enum.reduce(mcgs_config.graph.nodes, %{}, fn node, acc -> features = Map.get(mcgs_config.node_features, node.id, generate_random_features(mcgs_config.feature_embedding_dim)) embedding = compute_initial_embedding(features, mcgs_config.feature_embedding_dim) Map.put(acc, node.id, embedding) end) %{mcgs_config | node_features: Map.merge(mcgs_config.node_features, node_embeddings)} end defp run_mcgs_search(start_node, goal_nodes, mcgs_config) do # Initialize search state initial_state = %{ current_node: start_node, visited: MapSet.new([start_node]), path: [start_node], cost: 0.0, depth: 0 } # Run multiple search iterations best_result = Enum.reduce(1..mcgs_config.iterations, nil, fn _iteration, best -> result = single_search_iteration(initial_state, goal_nodes, mcgs_config) if best == nil or result.cost < best.cost do result else best end end) best_result || %{best_path: [start_node], path_cost: :infinity} end defp single_search_iteration(state, goal_nodes, mcgs_config) do if state.depth >= mcgs_config.max_depth or state.current_node in goal_nodes do %{best_path: Enum.reverse(state.path), path_cost: state.cost} else # Get neighbors with attention-weighted selection neighbors = get_node_neighbors(state.current_node, mcgs_config.graph) if length(neighbors) > 0 do # Apply graph attention to select next node next_node = select_next_node_with_attention(state.current_node, neighbors, mcgs_config) # Update state edge_cost = get_edge_cost(state.current_node, next_node, mcgs_config.graph) new_state = %{ current_node: next_node, visited: MapSet.put(state.visited, next_node), path: [next_node | state.path], cost: state.cost + edge_cost, depth: state.depth + 1 } single_search_iteration(new_state, goal_nodes, mcgs_config) else %{best_path: Enum.reverse(state.path), path_cost: state.cost} end end end defp select_next_node_with_attention(current_node, neighbors, mcgs_config) do # Calculate attention scores for each neighbor attention_scores = Enum.map(neighbors, fn neighbor -> score = calculate_attention_score(current_node, neighbor, mcgs_config) {neighbor, score} end) # Softmax normalization normalized_scores = softmax_normalize(attention_scores) # Sample from the attention distribution sample_from_distribution(normalized_scores) end defp calculate_attention_score(source_node, target_node, mcgs_config) do source_features = Map.get(mcgs_config.node_features, source_node, []) target_features = Map.get(mcgs_config.node_features, target_node, []) # Multi-head attention calculation attention_heads = Map.keys(mcgs_config.attention_weights) total_score = Enum.reduce(attention_heads, 0.0, fn head, acc -> head_weights = mcgs_config.attention_weights[head] head_score = compute_head_attention(source_features, target_features, head_weights) acc + head_score end) total_score / length(attention_heads) end defp compute_head_attention(source_features, target_features, head_weights) do if length(source_features) > 0 and length(target_features) > 0 do # Simplified attention computation query = compute_linear_transform(source_features, head_weights.query_weights) key = compute_linear_transform(target_features, head_weights.key_weights) dot_product = calculate_dot_product(query, key) scale_factor = :math.sqrt(length(query)) dot_product / scale_factor else :rand.uniform() end end defp apply_contrastive_learning(mcgs_config, search_results) do # Generate positive and negative pairs from search results positive_pairs = generate_positive_pairs(search_results.best_path, mcgs_config) negative_pairs = generate_negative_pairs(search_results.best_path, mcgs_config) # Update node features based on contrastive loss updated_features = update_features_with_contrastive_loss( mcgs_config.node_features, positive_pairs, negative_pairs, mcgs_config.contrastive_temperature ) %{mcgs_config | node_features: updated_features, contrastive_pairs: positive_pairs ++ negative_pairs } end defp generate_positive_pairs(path, mcgs_config) do # Adjacent nodes in the path should have similar features Enum.chunk_every(path, 2, 1, :discard) |> Enum.map(fn [node1, node2] -> features1 = Map.get(mcgs_config.node_features, node1, []) features2 = Map.get(mcgs_config.node_features, node2, []) {features1, features2} end) end defp generate_negative_pairs(path, mcgs_config) do # Nodes far apart in the graph should have dissimilar features path_set = MapSet.new(path) all_nodes = Enum.map(mcgs_config.graph.nodes, & &1.id) non_path_nodes = Enum.filter(all_nodes, fn node -> not MapSet.member?(path_set, node) end) Enum.take_random(non_path_nodes, min(length(path), length(non_path_nodes))) |> Enum.zip(path) |> Enum.map(fn {node1, node2} -> features1 = Map.get(mcgs_config.node_features, node1, []) features2 = Map.get(mcgs_config.node_features, node2, []) {features1, features2} end) end defp update_features_with_contrastive_loss(node_features, positive_pairs, negative_pairs, _temperature) do # Simple contrastive learning update _learning_rate = 0.01 Enum.reduce(positive_pairs ++ negative_pairs, node_features, fn {_f1, _f2}, acc -> # Placeholder for actual contrastive learning update # In practice, this would involve gradient computation and feature updates acc end) end # Utility functions defp get_node_neighbors(node_id, graph) do Enum.filter(graph.edges, fn {source, _target, _weight} -> source == node_id end) |> Enum.map(fn {_source, target, _weight} -> target end) end defp get_edge_cost(source, target, graph) do edge = Enum.find(graph.edges, fn {s, t, _w} -> s == source and t == target end) case edge do {_s, _t, %{weight: weight}} -> weight {_s, _t, weight} when is_number(weight) -> weight _ -> 1.0 end end defp generate_random_features(dim) do Enum.map(1..dim, fn _ -> :rand.normal(0, 1) end) end defp compute_initial_embedding(features, target_dim) do if length(features) >= target_dim do Enum.take(features, target_dim) else features ++ Enum.map(1..(target_dim - length(features)), fn _ -> 0.0 end) end end defp compute_linear_transform(input, weights) do if length(input) == length(weights) do Enum.zip(input, weights) |> Enum.map(fn {x, w} -> x * w end) else input end end defp calculate_dot_product(vec1, vec2) do if length(vec1) == length(vec2) do Enum.zip(vec1, vec2) |> Enum.reduce(0, fn {a, b}, acc -> acc + a * b end) else 0.0 end end defp calculate_vector_norm(vector) do sum_of_squares = Enum.reduce(vector, 0, fn x, acc -> acc + x * x end) :math.sqrt(sum_of_squares) end defp softmax_normalize(attention_scores) do max_score = Enum.max(Enum.map(attention_scores, fn {_node, score} -> score end)) exp_scores = Enum.map(attention_scores, fn {node, score} -> {node, :math.exp(score - max_score)} end) total_exp = Enum.reduce(exp_scores, 0, fn {_node, exp_score}, acc -> acc + exp_score end) Enum.map(exp_scores, fn {node, exp_score} -> {node, exp_score / total_exp} end) end defp sample_from_distribution(normalized_scores) do random_value = :rand.uniform() {selected_node, _} = Enum.reduce_while(normalized_scores, {nil, 0.0}, fn {node, prob}, {_acc_node, acc_prob} -> new_acc = acc_prob + prob if random_value <= new_acc do {:halt, {node, new_acc}} else {:cont, {node, new_acc}} end end) selected_node || elem(hd(normalized_scores), 0) end defp extract_learned_features(mcgs_config) do mcgs_config.node_features end defp calculate_contrastive_loss(mcgs_config) do # Simplified contrastive loss calculation if length(mcgs_config.contrastive_pairs) > 0 do 0.5 # Placeholder loss value else 0.0 end end defp calculate_representation_quality(avg_positive_sim, avg_negative_sim) do # Quality score based on separation between positive and negative similarities separation = avg_positive_sim - avg_negative_sim max(0.0, min(1.0, separation)) end end