Current section

Files

Jump to
yog_ex lib yog operation.ex
Raw

lib/yog/operation.ex

defmodule Yog.Operation do
@moduledoc """
Graph operations - Set-theoretic operations, composition, and structural comparison.
This module implements binary operations that treat graphs as sets of nodes and edges,
following NetworkX's "Graph as a Set" philosophy. These operations allow you to combine,
compare, and analyze structural differences between graphs.
## Set-Theoretic Operations
| Function | Description | Use Case |
|----------|-------------|----------|
| `union/2` | All nodes and edges from both graphs | Combine graph data |
| `intersection/2` | Only nodes and edges common to both | Find common structure |
| `difference/2` | Nodes/edges in first but not second | Find unique structure |
| `symmetric_difference/2` | Edges in exactly one graph | Find differing structure |
## Composition & Joins
| Function | Description | Use Case |
|----------|-------------|----------|
| `disjoint_union/2` | Combine with automatic ID re-indexing | Safe graph combination |
| `cartesian_product/4` | Multiply graphs (grids, hypercubes) | Generate complex structures |
| `compose/2` | Merge overlapping graphs with combined edges | Layered systems |
| `power/2` | k-th power (connect nodes within distance k) | Reachability analysis |
## Structural Comparison
| Function | Description | Use Case |
|----------|-------------|----------|
| `subgraph?/2` | Check if first is subset of second | Validation, pattern matching |
| `isomorphic?/2` | Check if graphs are structurally identical | Graph comparison |
## Examples
# Two triangle graphs with overlapping IDs
iex> triangle1 = Yog.undirected()
...> |> Yog.add_node(0, nil)
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 0, to: 1, with: 1)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
...> |> Yog.add_edge_ensure(from: 2, to: 0, with: 1)
iex> triangle2 = Yog.undirected()
...> |> Yog.add_node(0, nil)
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 0, to: 1, with: 1)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
...> |> Yog.add_edge_ensure(from: 2, to: 0, with: 1)
iex> # disjoint_union re-indexes the second graph automatically
...> combined = Yog.Operation.disjoint_union(triangle1, triangle2)
iex> # Result: 6 nodes (0-5), two separate triangles
...> Yog.Model.order(combined)
6
# Finding common structure
iex> graph_a = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> graph_b = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> common = Yog.Operation.intersection(graph_a, graph_b)
iex> Yog.Model.order(common)
2
"""
alias Yog.Graph
# =============================================================================
# SET-THEORETIC OPERATIONS
# =============================================================================
@doc """
Returns a graph containing all nodes and edges from both input graphs.
Node data and edge weights from `other` take precedence on conflicts.
Both graphs must have the same kind (`:directed` or `:undirected`);
the result inherits the kind from `base`.
**Time Complexity:** O(V₁ + V₂ + E₁ + E₂)
## Examples
iex> g1 = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> g2 = Yog.undirected()
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1)
iex> union = Yog.Operation.union(g1, g2)
iex> Yog.Model.order(union)
3
"""
@spec union(Graph.t(), Graph.t()) :: Graph.t()
def union(base, other) do
Yog.Transform.merge(base, other)
end
@doc """
Returns a graph containing only nodes and edges that exist in both input graphs.
For directed graphs, a directed edge must exist in both graphs to be kept.
For undirected graphs, an undirected edge must exist in both graphs.
**Time Complexity:** O(V + E)
## Examples
iex> g1 = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1)
iex> g2 = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> intersection = Yog.Operation.intersection(g1, g2)
iex> Yog.Model.order(intersection)
3
"""
@spec intersection(Graph.t(), Graph.t()) :: Graph.t()
def intersection(first, second) do
common_nodes =
MapSet.intersection(
MapSet.new(Map.keys(first.nodes)),
MapSet.new(Map.keys(second.nodes))
)
first
|> Yog.Transform.subgraph(MapSet.to_list(common_nodes))
# Direct edge check using second's out_edges
|> Yog.Transform.filter_edges(fn u, v, _w ->
case Map.fetch(second.out_edges, u) do
{:ok, inner} -> Map.has_key?(inner, v)
:error -> false
end
end)
end
@doc """
Returns a graph containing nodes and edges that exist in the first graph
but not in the second.
Any node that appears in `second` is removed from the result, along with
all its incident edges. Of the remaining nodes, only edges that do not
appear in `second` are kept.
**Time Complexity:** O(V + E)
## Examples
iex> g1 = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> g2 = Yog.undirected()
...> |> Yog.add_node(3, nil)
iex> diff = Yog.Operation.difference(g1, g2)
iex> Yog.Model.order(diff)
2
iex> Yog.Model.has_edge?(diff, 1, 2)
true
"""
@spec difference(Graph.t(), Graph.t()) :: Graph.t()
def difference(first, second) do
second_node_set = MapSet.new(Map.keys(second.nodes))
nodes_v1_minus_v2 =
Map.keys(first.nodes)
|> Enum.reject(&MapSet.member?(second_node_set, &1))
first
|> Yog.Transform.subgraph(nodes_v1_minus_v2)
|> Yog.Transform.filter_edges(fn u, v, _w ->
case Map.fetch(second.out_edges, u) do
{:ok, inner} -> not Map.has_key?(inner, v)
:error -> true
end
end)
end
@doc """
Returns a graph containing edges that exist in exactly one of the input graphs.
The result is the union of `difference(first, second)` and
`difference(second, first)`. Nodes that have no incident unique edges
will not appear in the result.
**Time Complexity:** O(V₁ + V₂ + E₁ + E₂)
## Examples
iex> g1 = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> g2 = Yog.undirected()
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1)
iex> sym_diff = Yog.Operation.symmetric_difference(g1, g2)
iex> Yog.Model.order(sym_diff)
1
iex> Yog.Model.edge_count(sym_diff)
0
"""
@spec symmetric_difference(Graph.t(), Graph.t()) :: Graph.t()
def symmetric_difference(first, second) do
first_only = difference(first, second)
second_only = difference(second, first)
union(first_only, second_only)
end
# =============================================================================
# COMPOSITION & JOINS
# =============================================================================
@doc """
Computes the disjoint union of two graphs.
Unlike a simple join, this function guarantees that nodes from Graph A
and Graph B remain distinct by tagging their IDs as `{0, id}` and `{1, id}`,
even if they share the same original ID.
The resulting graph uses the kind (`:directed` or `:undirected`) from
`graph_a`. Combining graphs of different kinds may lead to unexpected
edge behavior.
**Time Complexity:** O(V₁ + V₂ + E₁ + E₂)
## Example
iex> g1 = Yog.directed() |> Yog.add_node("root", "Data A")
iex> g2 = Yog.directed() |> Yog.add_node("root", "Data B")
iex> union = Yog.Operation.disjoint_union(g1, g2)
iex> Yog.Model.node_count(union)
2
iex> Yog.Model.node(union, {0, "root"})
"Data A"
"""
@spec disjoint_union(Graph.t(), Graph.t()) :: Graph.t()
def disjoint_union(graph_a, graph_b) do
Yog.Graph.new(graph_a.kind)
|> add_tagged_component(graph_a, 0)
|> add_tagged_component(graph_b, 1)
end
@doc """
Returns the Cartesian product of two graphs.
Creates a new graph where each node represents a pair of nodes from the
input graphs. Useful for generating grids, hypercubes, and other
complex structures.
**Time Complexity:** O(V₁ × V₂ + E₁ × V₂ + E₂ × V₁)
## Parameters
- `first` - First input graph
- `second` - Second input graph
- `default_first` - Default edge data for edges derived from `first`
- `default_second` - Default edge data for edges derived from `second`
## Examples
iex> g1 = Yog.undirected()
...> |> Yog.add_node(0, nil)
...> |> Yog.add_node(1, nil)
...> |> Yog.add_edge_ensure(from: 0, to: 1, with: 1)
iex> g2 = Yog.undirected()
...> |> Yog.add_node(0, nil)
...> |> Yog.add_node(1, nil)
...> |> Yog.add_edge_ensure(from: 0, to: 1, with: 1)
iex> product = Yog.Operation.cartesian_product(g1, g2, 0, 0)
iex> # 2x2 grid structure: 4 nodes
...> Yog.Model.order(product)
4
"""
@spec cartesian_product(Graph.t(), Graph.t(), any(), any()) :: Graph.t()
def cartesian_product(first, second, default_first, default_second) do
first_nodes = Map.keys(first.nodes)
second_nodes = Map.keys(second.nodes)
second_order = map_size(second.nodes)
# Rank nodes to create stable integer re-indexing
u_map = Enum.with_index(first_nodes) |> Enum.into(%{})
v_map = Enum.with_index(second_nodes) |> Enum.into(%{})
# Create empty graph with same kind
init_graph = Yog.Graph.new(first.kind)
# Add nodes: new_id = rank(u) * second_order + rank(v)
graph_with_nodes =
Enum.reduce(first_nodes, init_graph, fn u, g_acc ->
u_data = Map.get(first.nodes, u)
u_idx = Map.fetch!(u_map, u)
List.foldl(second_nodes, g_acc, fn v, g ->
v_idx = Map.fetch!(v_map, v)
new_id = u_idx * second_order + v_idx
v_data = Map.get(second.nodes, v)
Yog.add_node(g, new_id, {u_data, v_data})
end)
end)
# Add edges from second graph (vertical)
graph_with_second_edges =
Enum.reduce(first_nodes, graph_with_nodes, fn u, g_acc ->
u_idx = Map.fetch!(u_map, u)
List.foldl(second_nodes, g_acc, fn v, g ->
v_idx = Map.fetch!(v_map, v)
successors =
case Map.fetch(second.out_edges, v) do
{:ok, edges} -> Map.to_list(edges)
:error -> []
end
List.foldl(successors, g, fn {v_succ, weight}, g_inner ->
v_succ_idx = Map.fetch!(v_map, v_succ)
src_id = u_idx * second_order + v_idx
dst_id = u_idx * second_order + v_succ_idx
{:ok, new_g} = Yog.add_edge(g_inner, src_id, dst_id, {default_second, weight})
new_g
end)
end)
end)
# Add edges from first graph (horizontal)
List.foldl(second_nodes, graph_with_second_edges, fn v, g_acc ->
v_idx = Map.fetch!(v_map, v)
List.foldl(first_nodes, g_acc, fn u, g ->
u_idx = Map.fetch!(u_map, u)
successors =
case Map.fetch(first.out_edges, u) do
{:ok, edges} -> Map.to_list(edges)
:error -> []
end
List.foldl(successors, g, fn {u_succ, weight}, g_inner ->
u_succ_idx = Map.fetch!(u_map, u_succ)
src_id = u_idx * second_order + v_idx
dst_id = u_succ_idx * second_order + v_idx
{:ok, new_g} = Yog.add_edge(g_inner, src_id, dst_id, {weight, default_first})
new_g
end)
end)
end)
end
@doc """
Composes two graphs by merging overlapping nodes and combining their edges.
This is equivalent to `union/2` - both graphs are merged together with
`other`'s data taking precedence on conflicts.
**Time Complexity:** O(V₁ + V₂ + E₁ + E₂)
## Examples
iex> g1 = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> g2 = Yog.undirected()
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1)
iex> composed = Yog.Operation.compose(g1, g2)
iex> Yog.Model.order(composed)
3
"""
@spec compose(Graph.t(), Graph.t()) :: Graph.t()
def compose(first, second) do
union(first, second)
end
@doc """
Returns the line graph of a graph.
The line graph L(G) is a graph where each node represents an edge of G,
and two nodes are adjacent if and only if their corresponding edges share
a common endpoint in G.
For **directed graphs**, two edges `(u, v)` and `(x, y)` are adjacent in the
line graph if and only if `v == x` (the head of the first edge matches the
tail of the second edge). This is the standard line digraph definition.
For **undirected graphs**, two edges `{u, v}` and `{x, y}` are adjacent if
and only if they share at least one endpoint.
Line graph nodes are represented as `{u, v}` tuples. For undirected graphs,
the tuple follows the same ordering convention as `Yog.Model.all_edges/1`
(`u <= v` using Erlang term ordering).
**Time Complexity:** O(E²) where E is the number of edges in the original graph
## Parameters
- `graph` - The input graph
- `default_weight` - Weight for edges in the line graph (default: 1)
## Examples
iex> path = Yog.undirected()
...> |> Yog.add_node(0, nil)
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 0, to: 1, with: 10)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 20)
iex> lg = Yog.Operation.line_graph(path, 1)
iex> # Line graph of a path has 2 nodes ({0,1} and {1,2}) and 1 edge
iex> Yog.Model.order(lg)
2
iex> Yog.Model.has_edge?(lg, {0, 1}, {1, 2})
true
"""
@spec line_graph(Graph.t(), term()) :: Graph.t()
def line_graph(%Graph{kind: kind} = graph, default_weight \\ 1) do
# Get all edges from out_edges directly, with deduping for undirected graphs
edges_i =
List.foldl(Map.to_list(graph.out_edges), [], fn {u, inner}, acc ->
List.foldl(Map.to_list(inner), acc, fn {v, w}, inner_acc ->
[{u, v, w} | inner_acc]
end)
end)
edges =
case kind do
:undirected ->
edges_i
|> Enum.map(fn {u, v, w} ->
if u <= v, do: {u, v, w}, else: {v, u, w}
end)
|> Enum.uniq_by(fn {u, v, _} -> {u, v} end)
:directed ->
edges_i
end
lg =
List.foldl(edges, Graph.new(kind), fn {u, v, w}, acc ->
Yog.add_node(acc, {u, v}, w)
end)
case kind do
:directed ->
List.foldl(edges, lg, fn {u, v, _w}, acc ->
List.foldl(edges, acc, fn {x, y, _w2}, inner_acc ->
if v == x and {u, v} != {x, y} do
{:ok, new_g} = Yog.add_edge(inner_acc, {u, v}, {x, y}, default_weight)
new_g
else
inner_acc
end
end)
end)
:undirected ->
List.foldl(edges, lg, fn {u, v, _w}, acc ->
List.foldl(edges, acc, fn {x, y, _w2}, inner_acc ->
if {u, v} != {x, y} and shares_endpoint?(u, v, x, y) do
{:ok, new_g} = Yog.add_edge(inner_acc, {u, v}, {x, y}, default_weight)
new_g
else
inner_acc
end
end)
end)
end
end
@doc """
Returns the k-th power of a graph.
The k-th power of a graph G, denoted G^k, is a graph where two nodes are
adjacent if and only if their distance in G is at most k.
Self-loops are never added.
**Time Complexity:** O(V × (V + E)) in the worst case
## Parameters
- `graph` - The input graph
- `k` - The power (distance threshold)
- `default_weight` - Weight for newly created edges
## Examples
iex> path = Yog.undirected()
...> |> Yog.add_node(0, nil)
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 0, to: 1, with: 1)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> # G^2 connects nodes at distance <= 2
...> power = Yog.Operation.power(path, 2, 1)
iex> # Node 0 and 2 should now be connected (distance 2 in original)
...> Yog.Model.has_edge?(power, 0, 2)
true
"""
@spec power(Graph.t(), integer(), any()) :: Graph.t()
def power(graph, k, default_weight) do
if k <= 1 do
graph
else
nodes = Map.keys(graph.nodes)
List.foldl(nodes, graph, fn src, acc_graph ->
reachable = nodes_within_distance(acc_graph, src, k)
List.foldl(reachable, acc_graph, fn dst, g ->
if src == dst do
g
else
# Check if edge already exists
already_has_edge =
case Map.fetch(g.out_edges, src) do
{:ok, inner} -> Map.has_key?(inner, dst)
:error -> false
end
if already_has_edge do
g
else
case Yog.add_edge(g, src, dst, default_weight) do
{:ok, new_g} -> new_g
{:error, _} -> g
end
end
end
end)
end)
end
end
# =============================================================================
# STRUCTURAL COMPARISON
# =============================================================================
@doc """
Checks if the first graph is a subgraph of the second graph.
Returns `true` if all nodes and edges in the first graph exist in the second.
**Time Complexity:** O(Vₚ + Eₚ) where Vₚ and Eₚ are the nodes and edges of the potential subgraph
## Examples
iex> container = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_node(3, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
...> |> Yog.add_edge_ensure(from: 2, to: 3, with: 1)
iex> potential = Yog.undirected()
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> Yog.Operation.subgraph?(potential, container)
true
iex> not_subgraph = Yog.undirected()
...> |> Yog.add_node(4, nil)
...> |> Yog.add_node(5, nil)
...> |> Yog.add_edge_ensure(from: 4, to: 5, with: 1)
iex> Yog.Operation.subgraph?(not_subgraph, container)
false
"""
@spec subgraph?(Graph.t(), Graph.t()) :: boolean()
def subgraph?(potential, container) do
potential_nodes = Map.keys(potential.nodes)
container_nodes = MapSet.new(Map.keys(container.nodes))
all_nodes_exist =
Enum.all?(potential_nodes, fn node ->
MapSet.member?(container_nodes, node)
end)
if all_nodes_exist do
List.foldl(potential_nodes, true, fn src, valid? ->
if valid? do
potential_successors =
case Map.fetch(potential.out_edges, src) do
{:ok, edges} -> Map.to_list(edges)
:error -> []
end
List.foldl(potential_successors, true, fn {dst, _weight}, edge_valid? ->
if edge_valid? do
case Map.fetch(container.out_edges, src) do
{:ok, inner} -> Map.has_key?(inner, dst)
:error -> false
end
else
false
end
end)
else
false
end
end)
else
false
end
end
@doc """
Checks if two graphs are isomorphic (structurally identical).
Two graphs are isomorphic if there exists a bijection between their node sets
that preserves adjacency. This implementation uses degree sequence comparison
and backtracking to test for isomorphism.
**Time Complexity:** O(V log V + E) for the fast checks; exponential in the
worst case due to backtracking (not recommended for large graphs).
## Examples
# Two identical triangles are isomorphic
iex> g1 = Yog.undirected()
...> |> Yog.add_node(0, nil)
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 0, to: 1, with: 1)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
...> |> Yog.add_edge_ensure(from: 2, to: 0, with: 1)
iex> g2 = Yog.undirected()
...> |> Yog.add_node(10, nil)
...> |> Yog.add_node(20, nil)
...> |> Yog.add_node(30, nil)
...> |> Yog.add_edge_ensure(from: 10, to: 20, with: 1)
...> |> Yog.add_edge_ensure(from: 20, to: 30, with: 1)
...> |> Yog.add_edge_ensure(from: 30, to: 10, with: 1)
iex> Yog.Operation.isomorphic?(g1, g2)
true
iex> # Triangle is not isomorphic to a path
iex> path = Yog.undirected()
...> |> Yog.add_node(0, nil)
...> |> Yog.add_node(1, nil)
...> |> Yog.add_node(2, nil)
...> |> Yog.add_edge_ensure(from: 0, to: 1, with: 1)
...> |> Yog.add_edge_ensure(from: 1, to: 2, with: 1)
iex> Yog.Operation.isomorphic?(g1, path)
false
"""
@spec isomorphic?(Graph.t(), Graph.t()) :: boolean()
def isomorphic?(first, second) do
first_order = map_size(first.nodes)
second_order = map_size(second.nodes)
if first_order != second_order do
false
else
first_edges = Yog.Graph.edge_count(first)
second_edges = Yog.Graph.edge_count(second)
if first_edges != second_edges do
false
else
first_degrees = degree_sequence(first) |> Enum.sort()
second_degrees = degree_sequence(second) |> Enum.sort()
if first_degrees != second_degrees do
false
else
attempt_isomorphism(first, second)
end
end
end
end
# =============================================================================
# Private Helper Functions
# =============================================================================
# Returns true if two edges share at least one endpoint
defp shares_endpoint?(u, v, x, y) do
u == x or u == y or v == x or v == y
end
# Reindex edges with a tag to avoid ID collisions
defp add_tagged_component(target_graph, source_graph, tag) do
target_graph =
List.foldl(Map.to_list(source_graph.nodes), target_graph, fn {node_id, data}, acc ->
Yog.add_node(acc, {tag, node_id}, data)
end)
# Get edges directly from out_edges
edges =
List.foldl(Map.to_list(source_graph.out_edges), [], fn {u, inner}, acc ->
List.foldl(Map.to_list(inner), acc, fn {v, data}, inner_acc ->
[{u, v, data} | inner_acc]
end)
end)
List.foldl(edges, target_graph, fn {u, v, data}, acc ->
{:ok, new_g} = Yog.add_edge(acc, {tag, u}, {tag, v}, data)
new_g
end)
end
# Finds all nodes within distance k from a source node using BFS
defp nodes_within_distance(graph, src, max_dist) do
Yog.Traversal.fold_walk(
over: graph,
from: src,
using: :breadth_first,
initial: [],
with: fn acc, node_id, meta ->
if meta.depth <= max_dist do
{:continue, [node_id | acc]}
else
{:stop, acc}
end
end
)
end
# Computes the degree sequence of a graph as {in_degree, out_degree} pairs
defp degree_sequence(graph) do
nodes = Map.keys(graph.nodes)
out_edges = graph.out_edges
in_edges = graph.in_edges
List.foldl(nodes, [], fn node, acc ->
out_deg =
case Map.fetch(out_edges, node) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
in_deg =
case Map.fetch(in_edges, node) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
[{in_deg, out_deg} | acc]
end)
end
# Attempts to find an isomorphism between two graphs using backtracking
defp attempt_isomorphism(first, second) do
# Sort nodes by degree (descending) for better pruning
first_out = first.out_edges
first_in = first.in_edges
first_nodes =
Map.keys(first.nodes)
|> Enum.sort(fn a, b ->
out_deg_a =
case Map.fetch(first_out, a) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
in_deg_a =
case Map.fetch(first_in, a) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
out_deg_b =
case Map.fetch(first_out, b) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
in_deg_b =
case Map.fetch(first_in, b) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
out_deg_b + in_deg_b <= out_deg_a + in_deg_a
end)
second_nodes = Map.keys(second.nodes)
try_mapping(first, second, first_nodes, second_nodes, %{})
end
defp try_mapping(_first, _second, [], _available, _mapping), do: true
defp try_mapping(first, second, [src | rest], available, mapping) do
first_out = first.out_edges
first_in = first.in_edges
second_out = second.out_edges
second_in = second.in_edges
src_in =
case Map.fetch(first_in, src) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
src_out =
case Map.fetch(first_out, src) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
valid_candidates =
Enum.filter(available, fn candidate ->
cand_in =
case Map.fetch(second_in, candidate) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
cand_out =
case Map.fetch(second_out, candidate) do
{:ok, inner} -> map_size(inner)
:error -> 0
end
src_in == cand_in && src_out == cand_out
end)
Enum.any?(valid_candidates, fn candidate ->
if mapping_valid?(first, second, src, candidate, mapping) do
new_mapping = Map.put(mapping, src, candidate)
new_available = Enum.filter(available, fn n -> n != candidate end)
try_mapping(first, second, rest, new_available, new_mapping)
else
false
end
end)
end
# Checks if mapping src -> candidate is consistent with current mapping
defp mapping_valid?(first, second, src, candidate, mapping) do
src_successors =
case Map.fetch(first.out_edges, src) do
{:ok, inner} -> Map.keys(inner)
:error -> []
end
candidate_successors =
case Map.fetch(second.out_edges, candidate) do
{:ok, inner} -> Map.keys(inner)
:error -> []
end
inconsistent_edges =
List.foldl(Map.to_list(mapping), 0, fn {src_neighbor, candidate_neighbor}, count ->
edge_in_first? = src_neighbor in src_successors
edge_in_second? = candidate_neighbor in candidate_successors
if edge_in_first? == edge_in_second? do
count
else
count + 1
end
end)
src_predecessors =
case Map.fetch(first.in_edges, src) do
{:ok, inner} -> Map.keys(inner)
:error -> []
end
candidate_predecessors =
case Map.fetch(second.in_edges, candidate) do
{:ok, inner} -> Map.keys(inner)
:error -> []
end
inconsistent_incoming =
List.foldl(Map.to_list(mapping), 0, fn {src_neighbor, candidate_neighbor}, count ->
edge_in_first? = src_neighbor in src_predecessors
edge_in_second? = candidate_neighbor in candidate_predecessors
if edge_in_first? == edge_in_second? do
count
else
count + 1
end
end)
inconsistent_edges == 0 && inconsistent_incoming == 0
end
end