diff --git a/.gitignore b/.gitignore index 735ed1d..a2eb72c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ erl_crash.dump # QuickCheck files /.eqc-info /*.eqc +.elixir_ls/ \ No newline at end of file diff --git a/README.md b/README.md index 8976bc3..7dae0aa 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,97 @@ keeps the initial graph; duplicate identifiers keep the first vertex and labels. `Graph.add_vertices/2` continues to treat every item as a vertex, so tuples and lists are never implicitly interpreted as labeling instructions. +## Multigraphs + +Libgraph supports multigraphs — graphs where multiple edges with different labels can exist between +the same pair of vertices. When `multigraph: true` is enabled, an edge adjacency index is maintained +that lets partition queries start from matching endpoint pairs, avoiding scans of unrelated +edges. Query cost depends on the selected pairs and their parallel labels. The index uses +additional memory and adds maintenance work to edge mutations. + +### Creating a multigraph + +```elixir +g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :uses}, + {:a, :b, label: :contains}, + {:b, :c, label: :uses}, + {:b, :c, label: :owns, weight: 3} + ]) +``` + +### Querying by partition + +By default, edges are partitioned by their label (via `Graph.Utils.by_edge_label/1`). You can +query edges belonging to a specific partition: + +```elixir +# Get only :uses edges +Graph.edges(g, by: :uses) +#=> [%Graph.Edge{v1: :a, v2: :b, label: :uses}, %Graph.Edge{v1: :b, v2: :c, label: :uses}] + +# Get out edges from :a with label :contains +Graph.out_edges(g, :a, by: :contains) +#=> [%Graph.Edge{v1: :a, v2: :b, label: :contains}] + +# Filter edges with a predicate +Graph.edges(g, where: fn edge -> edge.weight > 2 end) +#=> [%Graph.Edge{v1: :b, v2: :c, label: :owns, weight: 3}] +``` + +### Custom partition functions + +You can provide a custom `partition_by` function to control how edges are indexed: + +```elixir +g = Graph.new(multigraph: true, partition_by: fn edge -> [edge.weight] end) +|> Graph.add_edges([{:a, :b, weight: 1}, {:b, :c, weight: 2}]) + +Graph.edges(g, by: 1) +#=> [%Graph.Edge{v1: :a, v2: :b, weight: 1}] +``` + +### Partition-filtered traversals + +BFS, DFS, Dijkstra, A*, and Bellman-Ford all support a `by:` option to restrict traversal to +edges in specific partitions: + +```elixir +g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :fast, weight: 1}, + {:a, :c, label: :slow, weight: 10}, + {:b, :d, label: :fast, weight: 1}, + {:c, :d, label: :slow, weight: 1} + ]) + +# Shortest path using only :fast edges +Graph.dijkstra(g, :a, :d, by: :fast) +#=> [:a, :b, :d] + +# BFS following only :fast edges +Graph.Reducers.Bfs.map(g, & &1, by: :fast) +#=> [:a, :b, :d] +``` + +### Edge properties + +Edges support an arbitrary `properties` map for storing additional metadata. The existing +`%{label => weight}` edge records are retained; properties are stored separately in +`edge_properties` only when populated: + +```elixir +g = Graph.new() +|> Graph.add_edge(:a, :b, label: :link, properties: %{color: "red", style: :dashed}) + +[edge] = Graph.edges(g) +edge.properties +#=> %{color: "red", style: :dashed} +``` + ## Rationale The original motivation for me to start working on this library is the fact that `:digraph` requires a diff --git a/bench/multigraph.exs b/bench/multigraph.exs new file mode 100644 index 0000000..2513116 --- /dev/null +++ b/bench/multigraph.exs @@ -0,0 +1,53 @@ +defmodule MultigraphBench.Helpers do + def build_graphs(num_vertices, num_edges, num_labels) do + labels = Enum.map(1..num_labels, fn i -> :"label_#{i}" end) + + edges = + for _ <- 1..num_edges do + v1 = :rand.uniform(num_vertices) + v2 = :rand.uniform(num_vertices) + label = Enum.random(labels) + {v1, v2, label: label, weight: :rand.uniform(100)} + end + + plain = + Enum.reduce(edges, Graph.new(), fn {v1, v2, opts}, g -> + Graph.add_edge(g, v1, v2, opts) + end) + + multi = + Enum.reduce(edges, Graph.new(multigraph: true), fn {v1, v2, opts}, g -> + Graph.add_edge(g, v1, v2, opts) + end) + + target_label = Enum.random(labels) + some_vertex = :rand.uniform(num_vertices) + + {plain, multi, target_label, some_vertex} + end +end + +alias MultigraphBench.Helpers + +Benchee.run( + %{ + "scan all edges + filter (no multigraph)" => fn {g_plain, _g_multi, target_label, _v} -> + g_plain |> Graph.edges() |> Enum.filter(fn e -> e.label == target_label end) + end, + "indexed lookup (multigraph by:)" => fn {_g_plain, g_multi, target_label, _v} -> + Graph.edges(g_multi, by: target_label) + end, + "scan out_edges + filter (no multigraph)" => fn {g_plain, _g_multi, target_label, v} -> + g_plain |> Graph.out_edges(v) |> Enum.filter(fn e -> e.label == target_label end) + end, + "indexed out_edges (multigraph by:)" => fn {_g_plain, g_multi, target_label, v} -> + Graph.out_edges(g_multi, v, by: target_label) + end + }, + inputs: %{ + "1k vertices, 5k edges, 10 labels" => Helpers.build_graphs(1_000, 5_000, 10), + "10k vertices, 50k edges, 50 labels" => Helpers.build_graphs(10_000, 50_000, 50) + }, + time: 10, + memory_time: 5 +) diff --git a/bench/multigraph_creation.exs b/bench/multigraph_creation.exs new file mode 100644 index 0000000..13519df --- /dev/null +++ b/bench/multigraph_creation.exs @@ -0,0 +1,35 @@ +defmodule MultigraphCreationBench.Helpers do + def build_edges(size) do + labels = Enum.map(1..10, fn i -> :"label_#{i}" end) + + for i <- 1..size do + v1 = :rand.uniform(div(size, 5)) + v2 = :rand.uniform(div(size, 5)) + label = Enum.random(labels) + {v1, v2, label: label, weight: :rand.uniform(100)} + end + end +end + +alias MultigraphCreationBench.Helpers + +Benchee.run( + %{ + "plain graph (no index)" => fn edges -> + Enum.reduce(edges, Graph.new(), fn {v1, v2, opts}, g -> + Graph.add_edge(g, v1, v2, opts) + end) + end, + "multigraph (indexed)" => fn edges -> + Enum.reduce(edges, Graph.new(multigraph: true), fn {v1, v2, opts}, g -> + Graph.add_edge(g, v1, v2, opts) + end) + end + }, + inputs: %{ + "10k edges" => Helpers.build_edges(10_000), + "100k edges" => Helpers.build_edges(100_000) + }, + time: 10, + memory_time: 5 +) diff --git a/bench/multigraph_memory.exs b/bench/multigraph_memory.exs new file mode 100644 index 0000000..2dce126 --- /dev/null +++ b/bench/multigraph_memory.exs @@ -0,0 +1,49 @@ +defmodule MultigraphMemoryBench.Helpers do + def build_graphs(num_edges, num_labels) do + num_vertices = div(num_edges, 5) + labels = Enum.map(1..num_labels, fn i -> :"label_#{i}" end) + + edges = + for _ <- 1..num_edges do + v1 = :rand.uniform(num_vertices) + v2 = :rand.uniform(num_vertices) + label = Enum.random(labels) + {v1, v2, label: label, weight: :rand.uniform(100)} + end + + plain = + Enum.reduce(edges, Graph.new(), fn {v1, v2, opts}, g -> + Graph.add_edge(g, v1, v2, opts) + end) + + multi = + Enum.reduce(edges, Graph.new(multigraph: true), fn {v1, v2, opts}, g -> + Graph.add_edge(g, v1, v2, opts) + end) + + {plain, multi} + end +end + +alias MultigraphMemoryBench.Helpers + +IO.puts("Multigraph Memory Overhead Report") +IO.puts("==================================\n") + +for {name, {size, labels}} <- [ + {"1k edges / 5 labels", {1_000, 5}}, + {"10k edges / 10 labels", {10_000, 10}}, + {"10k edges / 100 labels", {10_000, 100}}, + {"100k edges / 50 labels", {100_000, 50}} + ] do + {plain, multi} = Helpers.build_graphs(size, labels) + + plain_info = Graph.info(plain) + multi_info = Graph.info(multi) + + ratio = multi_info.size_in_bytes / plain_info.size_in_bytes + + IO.puts( + "#{name}: plain=#{plain_info.size_in_bytes}B, multi=#{multi_info.size_in_bytes}B, ratio=#{Float.round(ratio, 2)}x" + ) +end diff --git a/bench/partition_indexes.exs b/bench/partition_indexes.exs new file mode 100644 index 0000000..6474c3e --- /dev/null +++ b/bench/partition_indexes.exs @@ -0,0 +1,101 @@ +# Run the identical script against baseline and optimized dependencies. +# Compilation/setup and explicit GC are outside timing. No concurrent runs. +# Example: ERL_FLAGS='+S 4:4' mix run bench/partition_indexes.exs 128 512 2048 8192 +# Query loops hold selected degree at one while unrelated history grows. +# A single relabel starts from the same immutable graph on every repetition. +# consume_all changes n distinct edges and retains the resulting history. +defmodule Graph.Bench.PartitionIndexes do + + @queries 500 + + def run(sizes) do + IO.puts( + "operation,n,trial,iterations,microseconds,reductions,post_gc_bytes,result_flat_words" + ) + + for n <- sizes do + graph = build(n) + + jobs = [ + {"incoming_partition", @queries, + fn -> repeat(@queries, fn -> Graph.in_edges(graph, :hub, by: :fan_in) end) end}, + {"outgoing_partition", @queries, + fn -> repeat(@queries, fn -> Graph.out_edges(graph, :hub, by: :flow) end) end}, + {"incident_partition", @queries, + fn -> repeat(@queries, fn -> Graph.edges(graph, :hub, by: :flow) end) end}, + {"single_relabel", @queries, + fn -> + repeat(@queries, fn -> + Graph.update_labelled_edge(graph, {:pending, 1}, :hub, :runnable, label: :ran) + end) + end}, + {"consume_all", n, + fn -> + Enum.reduce(1..n, graph, fn i, acc -> + Graph.update_labelled_edge(acc, {:pending, i}, :hub, :runnable, label: :ran) + end) + end} + ] + + for {name, iterations, work} <- jobs do + isolated(work) + + for trial <- 1..3 do + {time, reductions, memory, words} = isolated(work) + IO.puts(Enum.join([name, n, trial, iterations, time, reductions, memory, words], ",")) + end + end + end + end + + defp build(n) do + Enum.reduce(1..n, Graph.new(multigraph: true), fn i, g -> + g + |> Graph.add_edge({:history, i}, :hub, label: :ran) + |> Graph.add_edge(:hub, {:output, i}, label: :produced) + |> Graph.add_edge({:pending, i}, :hub, label: :runnable) + end) + |> Graph.add_edge(:source, :hub, label: :fan_in) + |> Graph.add_edge(:hub, :sink, label: :flow) + end + + defp repeat(n, work), do: Enum.reduce(1..n, nil, fn _, _ -> work.() end) + + defp isolated(work) do + caller = self() + ref = make_ref() + + {pid, monitor} = + spawn_monitor(fn -> + :erlang.garbage_collect() + {:reductions, before} = Process.info(self(), :reductions) + {time, result} = :timer.tc(work) + {:reductions, after_work} = Process.info(self(), :reductions) + :erlang.garbage_collect() + {:memory, memory} = Process.info(self(), :memory) + words = :erts_debug.flat_size(result) + send(caller, {ref, time, after_work - before, memory, words}) + end) + + receive do + {^ref, time, reductions, memory, words} -> + Process.demonitor(monitor, [:flush]) + {time, reductions, memory, words} + + {:DOWN, ^monitor, :process, ^pid, reason} -> + raise "probe failed: #{inspect(reason)}" + after + 120_000 -> + Process.exit(pid, :kill) + raise "probe exceeded 120 seconds" + end + end +end + +sizes = + case System.argv() do + [] -> [128, 512, 2048, 8192] + args -> Enum.map(args, &String.to_integer/1) + end + +Graph.Bench.PartitionIndexes.run(sizes) diff --git a/lib/edge.ex b/lib/edge.ex index 4563ccc..630b6ec 100644 --- a/lib/edge.ex +++ b/lib/edge.ex @@ -8,27 +8,38 @@ defmodule Graph.Edge do defstruct v1: nil, v2: nil, weight: 1, - label: nil + label: nil, + properties: %{} @type t :: %__MODULE__{ v1: Graph.vertex(), v2: Graph.vertex(), weight: integer | float, - label: term + label: term, + properties: map } @type edge_opt :: {:weight, integer | float} | {:label, term} + | {:properties, map} @type edge_opts :: [edge_opt] @doc """ - Defines a new edge and accepts optional values for weight and label. - The defaults of a weight of 1 and no label will be used if the options do - not specify otherwise. + Defines a new edge and accepts optional values for weight, label, and properties. + + ## Options + + - `:weight` - the weight of the edge (integer or float, default: `1`) + - `:label` - the label for the edge (default: `nil`) + - `:properties` - an arbitrary map of additional metadata (default: `%{}`) An error will be thrown if weight is not an integer or float. - ## Example + ## Examples + + iex> edge = Graph.Edge.new(:a, :b, label: :foo, weight: 2, properties: %{color: "red"}) + ...> {edge.label, edge.weight, edge.properties} + {:foo, 2, %{color: "red"}} iex> Graph.new |> Graph.add_edge(Graph.Edge.new(:a, :b, weight: "1")) ** (ArgumentError) invalid value for :weight, must be an integer @@ -36,11 +47,15 @@ defmodule Graph.Edge do @spec new(Graph.vertex(), Graph.vertex()) :: t @spec new(Graph.vertex(), Graph.vertex(), [edge_opt]) :: t | no_return def new(v1, v2, opts \\ []) when is_list(opts) do + {weight, opts} = Keyword.pop(opts, :weight, 1) + {label, opts} = Keyword.pop(opts, :label) + %__MODULE__{ v1: v1, v2: v2, - weight: Keyword.get(opts, :weight, 1), - label: Keyword.get(opts, :label) + weight: weight, + label: label, + properties: Keyword.get(opts, :properties, %{}) } end diff --git a/lib/graph.ex b/lib/graph.ex index 42cc923..ce79b97 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -24,14 +24,39 @@ defmodule Graph do quite efficient for manipulating the graph as well. For example, splitting an edge and introducing a new vertex on that edge can be done with very little effort. We use vertex ids everywhere because we can generate them without any lookups, we don't incur any copies of the vertex structure, and they are very efficient as keys in a map. + + ## Multigraphs + + When `multigraph: true` is passed to `Graph.new/1`, an edge adjacency index (`edge_index`) is maintained + alongside the standard graph structure. This index partitions edges by a key derived from a `partition_by` + function (defaulting to `fn edge -> [edge.label] end`, which partitions by edge label). + + The index structure is `%{partition_key => %{vertex_id => MapSet.t(edge_key)}}`. + Partition queries start from indexed endpoint pairs instead of scanning unrelated edges. + Query cost still depends on the selected pairs and their parallel labels; the index adds + storage and maintenance work when edges change. + + Query functions such as `edges/2`, `out_edges/3`, and `in_edges/3` accept `:by` and `:where` options + to filter edges by partition or predicate. Traversal and pathfinding algorithms (`Graph.Reducers.Bfs`, + `Graph.Reducers.Dfs`, `dijkstra/4`, `a_star/5`, `bellman_ford/3`) also accept a `:by` option to + restrict traversal to edges in specific partitions. + + ## Edge Properties + + Edges support an arbitrary `properties` map (default `%{}`) for storing additional metadata beyond + weight and label. Properties can be set via `add_edge/4` and are preserved through all graph operations. """ defstruct in_edges: %{}, out_edges: %{}, edges: %{}, + edge_index: %{}, + edge_properties: %{}, vertex_labels: %{}, vertices: %{}, type: :directed, - vertex_identifier: &Graph.Utils.vertex_id/1 + vertex_identifier: &Graph.Utils.vertex_id/1, + partition_by: &Graph.Utils.by_edge_label/1, + multigraph: false alias Graph.{Edge, EdgeSpecificationError} @@ -45,16 +70,21 @@ defmodule Graph do @type edge_weight :: integer | float @type edge_key :: {vertex_id, vertex_id} @type edge_value :: %{label => edge_weight} + @type edge_index_key :: label | term @type graph_type :: :directed | :undirected @type vertices :: %{vertex_id => vertex} @type t :: %__MODULE__{ in_edges: %{vertex_id => MapSet.t()}, out_edges: %{vertex_id => MapSet.t()}, edges: %{edge_key => edge_value}, + edge_index: %{edge_index_key => %{vertex_id => MapSet.t(edge_key)}}, + edge_properties: %{edge_key => %{label => map()}}, vertex_labels: %{vertex_id => term}, vertices: %{vertex_id => vertex}, type: graph_type, - vertex_identifier: (vertex() -> term()) + vertex_identifier: (vertex() -> term()), + partition_by: (Edge.t() -> list(edge_index_key)), + multigraph: boolean() } @type graph_info :: %{ :num_edges => non_neg_integer(), @@ -71,6 +101,19 @@ defmodule Graph do - `type: :directed | :undirected`, specifies what type of graph this is. Defaults to a `:directed` graph. - `vertex_identifier`: a function which accepts a vertex and returns a unique identifier of said vertex. Defaults to a 32-bit `:erlang.phash2/2` hash of the whole vertex. + - `multigraph: true | false`, enables edge indexing for efficient partition-based edge retrieval. + - When `true`, an `edge_index` is maintained that maps partition keys to sets of edge keys. + - When `false` (default), no additional memory is used for the index. + - `partition_by`: a function which accepts an `%Edge{}` and returns a list of unique identifiers used as the partition keys. + Defaults to `fn edge -> [edge.label] end`, which partitions edges by the label when multigraphs are enabled. + + ### Multigraph Edge Indexing + + When `multigraph: true` is enabled the `partition_by` function maintains sets of edges for the partition. + This option enables a space for time trade-off for Map access retrieval partitioned edges of a kind i.e. [multigraph](https://en.wikipedia.org/wiki/Multigraph) capabilities. + + This edge adjacency index can be useful for graphs where many different kinds of edges exist between the same vertices and + iteration over all edges is prohibitive. ## Vertex identity @@ -107,6 +150,10 @@ defmodule Graph do ...> Graph.edges(g) [%Graph.Edge{v1: :a, v2: :b}, %Graph.Edge{v1: :b, v2: :a}] + iex> g = Graph.new(multigraph: true, partition_by: fn edge -> [edge.weight] end) |> Graph.add_edges([{:a, :b, weight: 1}, {:b, :a, weight: 2}]) + ...> Graph.edges(g, by: 1) + [%Graph.Edge{v1: :a, v2: :b, weight: 1}] + iex> g = Graph.new(vertex_identifier: fn vertex -> vertex end) ...> g = Graph.add_vertices(g, [33_702, 44_741]) ...> Graph.num_vertices(g) @@ -115,7 +162,15 @@ defmodule Graph do def new(opts \\ []) do type = Keyword.get(opts, :type) || :directed vertex_identifier = Keyword.get(opts, :vertex_identifier) || (&Graph.Utils.vertex_id/1) - %__MODULE__{type: type, vertex_identifier: vertex_identifier} + partition_by = Keyword.get(opts, :partition_by) || (&Graph.Utils.by_edge_label/1) + multigraph = Keyword.get(opts, :multigraph, false) + + %__MODULE__{ + type: type, + vertex_identifier: vertex_identifier, + partition_by: partition_by, + multigraph: multigraph + } end @doc """ @@ -359,6 +414,28 @@ defmodule Graph do @spec dijkstra(t, vertex, vertex) :: [vertex] | nil defdelegate dijkstra(g, a, b), to: Graph.Pathfinding + @doc """ + Like `dijkstra/3`, but accepts options for multigraph partition filtering. + + ## Options + + - `:by` - a partition key or list of partition keys to restrict edge traversal + + ## Example + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([ + ...> {:a, :b, label: :fast, weight: 1}, + ...> {:a, :c, label: :slow, weight: 10}, + ...> {:b, :d, label: :fast, weight: 1}, + ...> {:c, :d, label: :slow, weight: 1} + ...> ]) + ...> Graph.dijkstra(g, :a, :d, by: :fast) + [:a, :b, :d] + """ + @spec dijkstra(t, vertex, vertex, keyword) :: [vertex] | nil + def dijkstra(g, a, b, opts) when is_list(opts), + do: Graph.Pathfinding.dijkstra(g, a, b, opts) + @doc """ ## Example @@ -377,6 +454,28 @@ defmodule Graph do @spec bellman_ford(t, vertex) :: %{vertex => number | :infinity} | nil defdelegate bellman_ford(g, a), to: Graph.Pathfinding + @doc """ + Like `bellman_ford/2`, but accepts options for multigraph partition filtering. + + ## Options + + - `:by` - a partition key or list of partition keys to restrict edge relaxation + + ## Example + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([ + ...> {:a, :b, label: :fast, weight: 1}, + ...> {:b, :c, label: :fast, weight: 2}, + ...> {:a, :c, label: :slow, weight: 100} + ...> ]) + ...> distances = Graph.bellman_ford(g, :a, by: :fast) + ...> distances[:c] + 3 + """ + @spec bellman_ford(t, vertex, keyword) :: %{vertex => number | :infinity} | nil + def bellman_ford(g, a, opts) when is_list(opts), + do: Graph.Pathfinding.bellman_ford(g, a, opts) + @doc """ Gets the shortest path between `a` and `b`. @@ -403,6 +502,28 @@ defmodule Graph do @spec a_star(t, vertex, vertex, (vertex -> number)) :: [vertex] | nil defdelegate a_star(g, a, b, hfun), to: Graph.Pathfinding + @doc """ + Like `a_star/4`, but accepts options for multigraph partition filtering. + + ## Options + + - `:by` - a partition key or list of partition keys to restrict edge traversal + + ## Example + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([ + ...> {:a, :b, label: :fast, weight: 1}, + ...> {:a, :c, label: :slow, weight: 10}, + ...> {:b, :d, label: :fast, weight: 1}, + ...> {:c, :d, label: :slow, weight: 1} + ...> ]) + ...> Graph.a_star(g, :a, :d, fn _ -> 0 end, by: :fast) + [:a, :b, :d] + """ + @spec a_star(t, vertex, vertex, (vertex -> number), keyword) :: [vertex] | nil + def a_star(g, a, b, hfun, opts) when is_list(opts), + do: Graph.Pathfinding.a_star(g, a, b, hfun, opts) + @doc """ Builds a list of paths between vertex `a` and vertex `b`. @@ -444,12 +565,12 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_vertex(:a) |> Graph.add_vertex(:b) |> Graph.add_vertex(:c) ...> g = g |> Graph.add_edge(:a, :c) |> Graph.add_edge(:b, :c) - ...> Graph.edges(g) + ...> Graph.edges(g) |> Enum.sort_by(& {&1.v1, &1.v2, &1.label}) [%Graph.Edge{v1: :a, v2: :c}, %Graph.Edge{v1: :b, v2: :c}] """ @spec edges(t) :: [Edge.t()] - def edges(%__MODULE__{out_edges: edges, edges: meta, vertices: vs}) do + def edges(%__MODULE__{out_edges: edges, edges: meta, vertices: vs, edge_properties: ep}) do edges |> Enum.flat_map(fn {source_id, out_neighbors} -> source = Map.get(vs, source_id) @@ -457,36 +578,68 @@ defmodule Graph do out_neighbors |> Enum.flat_map(fn out_neighbor -> target = Map.get(vs, out_neighbor) - meta = Map.get(meta, {source_id, out_neighbor}) + edge_key = {source_id, out_neighbor} + meta = Map.get(meta, edge_key) Enum.map(meta, fn {label, weight} -> - Edge.new(source, target, label: label, weight: weight) + props = get_edge_props(ep, edge_key, label) + Edge.new(source, target, label: label, weight: weight, properties: props) end) end) end) end @doc """ - Returns a list of all edges inbound or outbound from vertex `v`. + Returns a list of all edges inbound or outbound from vertex `v` or by multigraph traversal options. + + ## Options when `multigraph: true` + + - `:where` - a function that accepts an edge and must return a boolean to include the edge. + - `:by` - a keyword list of partitions to traverse. If not provided, all edges are traversed. ## Example iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:b, :c}]) - ...> Graph.edges(g, :b) + ...> Graph.edges(g, :b) |> Enum.sort_by(& {&1.v1, &1.v2, &1.label}) [%Graph.Edge{v1: :a, v2: :b}, %Graph.Edge{v1: :b, v2: :c}] iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:b, :c}]) ...> Graph.edges(g, :d) [] + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([{:a, :b}, {:b, :c}]) + ...> g = Graph.add_edge(g, :a, :b, label: :contains) + ...> Graph.edges(g, :a, by: [:contains]) + [%Graph.Edge{v1: :a, v2: :b, label: :contains}] + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([{:a, :b}, {:b, :c}]) + ...> g = Graph.add_edge(g, :a, :b, label: :contains, weight: 2) + ...> Graph.edges(g, :a, where: fn edge -> edge.weight == 2 end) + [%Graph.Edge{v1: :a, v2: :b, label: :contains, weight: 2}] """ - @spec edges(t, vertex) :: [Edge.t()] + @spec edges(t, vertex | keyword()) :: [Edge.t()] + + def edges(%__MODULE__{multigraph: true} = g, opts) when is_list(opts) do + where_fun = opts[:where] + + if Keyword.has_key?(opts, :by) do + partitions = partition_for_opts(opts[:by]) + edges_in_partitions(g, partitions, where_fun) + else + g + |> edges() + |> filter_edges(where_fun) + end + end + def edges( %__MODULE__{ in_edges: ie, out_edges: oe, edges: meta, vertices: vs, - vertex_identifier: vertex_identifier + vertex_identifier: vertex_identifier, + edge_properties: ep }, v ) do @@ -498,7 +651,9 @@ defmodule Graph do # Self-loops are emitted by the outgoing pass below. e_in = Enum.flat_map(MapSet.delete(v_all, v_id), fn v2_id -> - case Map.get(meta, {v2_id, v_id}) do + edge_key = {v2_id, v_id} + + case Map.get(meta, edge_key) do nil -> [] @@ -506,14 +661,17 @@ defmodule Graph do v2 = Map.get(vs, v2_id) for {label, weight} <- edge_meta do - Edge.new(v2, v, label: label, weight: weight) + props = get_edge_props(ep, edge_key, label) + Edge.new(v2, v, label: label, weight: weight, properties: props) end end end) e_out = Enum.flat_map(v_all, fn v2_id -> - case Map.get(meta, {v_id, v2_id}) do + edge_key = {v_id, v2_id} + + case Map.get(meta, edge_key) do nil -> [] @@ -521,7 +679,8 @@ defmodule Graph do v2 = Map.get(vs, v2_id) for {label, weight} <- edge_meta do - Edge.new(v, v2, label: label, weight: weight) + props = get_edge_props(ep, edge_key, label) + Edge.new(v, v2, label: label, weight: weight, properties: props) end end end) @@ -530,7 +689,12 @@ defmodule Graph do end @doc """ - Returns a list of all edges between `v1` and `v2`. + Returns a list of all edges between `v1` and `v2` or connected to `v1` given multigraph options. + + ## Options when `multigraph: true` + + - `:where` - a function that accepts an edge and must return a boolean to include the edge. + - `:by` - a single partition or list of partitions to traverse. If not provided, all edges are traversed. ## Example @@ -543,8 +707,36 @@ defmodule Graph do ...> g = Graph.add_edge(g, :a, :b, label: :contains) ...> Graph.edges(g, :a, :b) |> Enum.sort_by(&{&1.v1, &1.v2, &1.label}) [%Graph.Edge{v1: :a, v2: :b, label: :contains}, %Graph.Edge{v1: :a, v2: :b, label: :uses}] + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([{:a, :b}, {:b, :c}]) + ...> g = Graph.add_edge(g, :a, :b, label: :contains) + ...> Graph.edges(g, :a, by: :contains) + [%Graph.Edge{v1: :a, v2: :b, label: :contains}] + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([{:a, :b}, {:b, :c}]) + ...> g = Graph.add_edge(g, :a, :b, label: :contains, weight: 2) + ...> Graph.edges(g, :a, by: :contains, where: fn edge -> edge.weight == 2 end) + [%Graph.Edge{v1: :a, v2: :b, label: :contains, weight: 2}] """ - @spec edges(t, vertex, vertex) :: [Edge.t()] + @spec edges(t, vertex, vertex | keyword()) :: [Edge.t()] + def edges( + %__MODULE__{multigraph: true} = g, + v1, + opts + ) + when is_list(opts) do + where_fun = opts[:where] + + if Keyword.has_key?(opts, :by) do + partitions = partition_for_opts(opts[:by]) + edges_in_partitions(g, v1, partitions, where_fun) + else + g + |> edges(v1) + |> filter_edges(where_fun) + end + end + def edges( %__MODULE__{type: type, edges: meta, vertex_identifier: vertex_identifier} = g, v1, @@ -567,17 +759,109 @@ defmodule Graph do end end - defp edge_list(v1, v2, edge_meta, %__MODULE__{type: :undirected} = g) do - {v1, v2} = ordered_vertices(g, v1, v2) + defp edges_in_partitions(g, partitions, where_fun) do + partitions + |> Enum.reduce(MapSet.new(), fn partition, acc -> + g.edge_index + |> Map.get(partition, %{}) + |> Map.values() + |> Enum.reduce(acc, fn partitioned_set, pacc -> + MapSet.union(partitioned_set, pacc) + end) + end) + |> Enum.flat_map(fn {v1_id, v2_id} = edge_key -> + v1 = Map.get(g.vertices, v1_id) + v2 = Map.get(g.vertices, v2_id) + + g.edges + |> Map.get(edge_key, []) + |> Enum.reduce([], fn {label, weight}, acc -> + props = get_edge_props(g.edge_properties, edge_key, label) + edge = Edge.new(v1, v2, label: label, weight: weight, properties: props) + edge_partitions = g.partition_by.(edge) + + if include_edge_for_filtered_partitions?(edge, edge_partitions, partitions, where_fun) do + [edge | acc] + else + acc + end + end) + end) + end - for {label, weight} <- edge_meta do - Edge.new(v1, v2, label: label, weight: weight) + defp edges_in_partitions(g, v, partitions, where_fun) do + incident_edges_in_partitions(g, v, partitions, where_fun, :both) + end + + # The index already stores the endpoint pair at each incident vertex. Starting + # here avoids materializing the complete unfiltered adjacency (including + # unrelated history) merely to intersect it with a small structural partition. + # Direction checks happen before constructing edges or invoking predicates. + defp incident_edges_in_partitions(g, v, partitions, where_fun, direction) do + v_id = g.vertex_identifier.(v) + + partitions + |> Enum.reduce(MapSet.new(), fn partition, acc -> + g.edge_index + |> Map.get(partition, %{}) + |> Map.get(v_id, MapSet.new()) + |> MapSet.union(acc) + end) + |> Enum.flat_map(fn {v1_id, v2_id} = edge_key -> + if g.type == :undirected or direction == :both or + (direction == :in and v2_id === v_id) or + (direction == :out and v1_id === v_id) do + g.edges + |> Map.get(edge_key, %{}) + |> Enum.reduce([], fn {label, weight}, acc -> + # Ignore stale index candidates without canonical records before + # resolving endpoints (e.g. older changed-ID replace_vertex results). + v1 = Map.fetch!(g.vertices, v1_id) + v2 = Map.fetch!(g.vertices, v2_id) + props = get_edge_props(g.edge_properties, edge_key, label) + edge = Edge.new(v1, v2, label: label, weight: weight, properties: props) + edge_partitions = g.partition_by.(edge) + + if include_edge_for_filtered_partitions?(edge, edge_partitions, partitions, where_fun) do + [edge | acc] + else + acc + end + end) + else + [] + end + end) + end + + defp filter_edges(edges, nil), do: edges + + defp filter_edges(edges, where_fun) do + Enum.filter(edges, where_fun) + end + + defp get_edge_props(edge_properties, edge_key, label) do + case edge_properties do + %{^edge_key => %{^label => props}} -> props + _ -> %{} end end - defp edge_list(v1, v2, edge_meta, _) do + defp delete_edge_properties(properties, edge_key, label) do + remaining = properties |> Map.get(edge_key, %{}) |> Map.delete(label) + + if map_size(remaining) == 0, + do: Map.delete(properties, edge_key), + else: Map.put(properties, edge_key, remaining) + end + + defp edge_list(v1, v2, edge_meta, %__MODULE__{} = g) do + {v1, v2} = if g.type == :undirected, do: ordered_vertices(g, v1, v2), else: {v1, v2} + key = {g.vertex_identifier.(v1), g.vertex_identifier.(v2)} + for {label, weight} <- edge_meta do - Edge.new(v1, v2, label: label, weight: weight) + props = get_edge_props(g.edge_properties, key, label) + Edge.new(v1, v2, label: label, weight: weight, properties: props) end end @@ -634,13 +918,19 @@ defmodule Graph do do_edge(g, v1, v2, label) end - defp do_edge(%__MODULE__{edges: meta, vertex_identifier: vertex_identifier}, v1, v2, label) do + defp do_edge( + %__MODULE__{edges: meta, vertex_identifier: vertex_identifier, edge_properties: ep}, + v1, + v2, + label + ) do with v1_id <- vertex_identifier.(v1), v2_id <- vertex_identifier.(v2), edge_key <- {v1_id, v2_id}, {:ok, edge_meta} <- Map.fetch(meta, edge_key), {:ok, weight} <- Map.fetch(edge_meta, label) do - Edge.new(v1, v2, label: label, weight: weight) + props = get_edge_props(ep, edge_key, label) + Edge.new(v1, v2, label: label, weight: weight, properties: props) else _ -> nil @@ -858,10 +1148,17 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_vertices([:a, :b, :c, :d]) ...> g = Graph.add_edges(g, [{:a, :b}, {:b, :c}, {:c, :a}, {:c, :d}]) - ...> [:a, :b, :c, :d] = Graph.vertices(g) + ...> Graph.vertices(g) |> Enum.sort() + [:a, :b, :c, :d] + iex> g = Graph.new |> Graph.add_vertices([:a, :b, :c, :d]) + ...> g = Graph.add_edges(g, [{:a, :b}, {:b, :c}, {:c, :a}, {:c, :d}]) ...> g = Graph.replace_vertex(g, :a, :e) - ...> [:b, :c, :d, :e] = Graph.vertices(g) - ...> Graph.edges(g) + ...> Graph.vertices(g) |> Enum.sort() + [:b, :c, :d, :e] + iex> g = Graph.new |> Graph.add_vertices([:a, :b, :c, :d]) + ...> g = Graph.add_edges(g, [{:a, :b}, {:b, :c}, {:c, :a}, {:c, :d}]) + ...> g = Graph.replace_vertex(g, :a, :e) + ...> Graph.edges(g) |> Enum.sort_by(& {&1.v1, &1.v2, &1.label}) [%Graph.Edge{v1: :b, v2: :c}, %Graph.Edge{v1: :c, v2: :d}, %Graph.Edge{v1: :c, v2: :e}, %Graph.Edge{v1: :e, v2: :b}] """ @spec replace_vertex(t, vertex, vertex) :: t | {:error, :no_such_vertex} @@ -907,15 +1204,33 @@ defmodule Graph do true <- Map.has_key?(vs, v_id), rv_id <- vertex_identifier.(rv), vs <- Map.put(Map.delete(vs, v_id), rv_id, rv) do - meta = - em - |> Stream.map(fn - {{^v_id, ^v_id}, meta} -> {{rv_id, rv_id}, meta} - {{^v_id, v2_id}, meta} -> {{rv_id, v2_id}, meta} - {{v1_id, ^v_id}, meta} -> {{v1_id, rv_id}, meta} - edge -> edge + # Keep each winning edge record and its properties together when keys coalesce. + {meta, properties} = + Enum.reduce(em, {%{}, %{}}, fn {{from, to} = key, value}, {meta, properties} -> + new_key = + {if(from === v_id, do: rv_id, else: from), if(to === v_id, do: rv_id, else: to)} + + new_key = + if g.type == :undirected do + {from, to} = new_key + + {v1, v2} = + ordered_vertices(%{g | edges: %{}}, Map.fetch!(vs, from), Map.fetch!(vs, to)) + + {vertex_identifier.(v1), vertex_identifier.(v2)} + else + new_key + end + + props = Map.get(g.edge_properties, key, %{}) + + properties = + if map_size(props) == 0, + do: Map.delete(properties, new_key), + else: Map.put(properties, new_key, props) + + {Map.put(meta, new_key, value), properties} end) - |> Enum.into(%{}) # Coalescing may combine two adjacency entries. Rebuild from the retained # edge metadata so neither neighborhood is lost to duplicate map keys. @@ -940,13 +1255,27 @@ defmodule Graph do out_edges: oe, in_edges: ie, edges: meta, + edge_properties: properties, vertex_labels: labels } + |> rebuild_edge_index() else _ -> {:error, :no_such_vertex} end end + defp rebuild_edge_index(%__MODULE__{multigraph: false} = g), do: g + + defp rebuild_edge_index(%__MODULE__{} = g) do + Enum.reduce(edges(g), %{g | edge_index: %{}}, fn edge, acc -> + index_multigraph_edge( + acc, + {g.vertex_identifier.(edge.v1), g.vertex_identifier.(edge.v2)}, + edge + ) + end) + end + @doc """ Removes a vertex from the graph, as well as any edges which refer to that vertex. If the vertex does not exist in the graph, it is a no-op. @@ -963,8 +1292,7 @@ defmodule Graph do """ @spec delete_vertex(t, vertex) :: t def delete_vertex( - %__MODULE__{out_edges: oe, in_edges: ie, edges: em, vertex_identifier: vertex_identifier} = - g, + %__MODULE__{edges: em, vertex_identifier: vertex_identifier} = g, v ) do vs = g.vertices @@ -972,8 +1300,10 @@ defmodule Graph do with v_id <- vertex_identifier.(v), true <- Map.has_key?(vs, v_id) do - {outs, oe} = Map.pop(oe, v_id, MapSet.new()) - {ins, ie} = Map.pop(ie, v_id, MapSet.new()) + g = %__MODULE__{} = prune_vertex_from_edge_index(g, v_id, v) + + {outs, oe} = Map.pop(g.out_edges, v_id, MapSet.new()) + {ins, ie} = Map.pop(g.in_edges, v_id, MapSet.new()) {ie, em} = Enum.reduce(outs, {ie, em}, fn id, {ie, em} -> @@ -987,13 +1317,17 @@ defmodule Graph do {oe, Map.delete(em, {id, v_id})} end) + incident_keys = Enum.map(outs, &{v_id, &1}) ++ Enum.map(ins, &{&1, v_id}) + ep = Map.drop(g.edge_properties, incident_keys) + %__MODULE__{ g | vertices: Map.delete(vs, v_id), vertex_labels: Map.delete(ls, v_id), out_edges: oe, in_edges: ie, - edges: em + edges: em, + edge_properties: ep } else _ -> g @@ -1026,8 +1360,14 @@ defmodule Graph do [%Graph.Edge{v1: :a, v2: :b}] """ @spec add_edge(t, Edge.t()) :: t - def add_edge(%__MODULE__{} = g, %Edge{v1: v1, v2: v2, label: label, weight: weight}) do - add_edge(g, v1, v2, label: label, weight: weight) + def add_edge(%__MODULE__{} = g, %Edge{ + v1: v1, + v2: v2, + label: label, + weight: weight, + properties: properties + }) do + add_edge(g, v1, v2, label: label, weight: weight, properties: properties) end @doc """ @@ -1073,7 +1413,7 @@ defmodule Graph do v1_id = vertex_identifier.(v1) v2_id = vertex_identifier.(v2) - %__MODULE__{in_edges: ie, out_edges: oe, edges: meta} = + %__MODULE__{in_edges: ie, out_edges: oe, edges: meta, edge_properties: ep} = g = g |> add_vertex(v1) |> add_vertex(v2) out_neighbors = @@ -1092,20 +1432,82 @@ defmodule Graph do {label, weight} = Edge.options_to_meta(opts) edge_meta = Map.put(edge_meta, label, weight) + properties = Keyword.get(opts, :properties, %{}) + edge_key = {v1_id, v2_id} + + ep = + if properties == %{} do + ep + else + key_props = Map.get(ep, edge_key, %{}) + Map.put(ep, edge_key, Map.put(key_props, label, properties)) + end + + g = + %__MODULE__{} = + if g.multigraph do + edge = + Edge.new(v1, v2, + label: label, + weight: weight, + properties: get_edge_props(ep, edge_key, label) + ) + + g + |> prune_edge_index({v1_id, v1}, {v2_id, v2}, label) + |> index_multigraph_edge(edge_key, edge) + else + g + end + %__MODULE__{ g | in_edges: Map.put(ie, v2_id, in_neighbors), out_edges: Map.put(oe, v1_id, out_neighbors), - edges: Map.put(meta, {v1_id, v2_id}, edge_meta) + edges: Map.put(meta, edge_key, edge_meta), + edge_properties: ep } end + defp index_multigraph_edge( + %__MODULE__{multigraph: true} = graph, + {v1_id, v2_id}, + %Edge{} = edge + ) do + partitions = graph.partition_by.(edge) + + Enum.reduce(partitions, graph, fn partition, %__MODULE__{} = g -> + edge_partition = Map.get(g.edge_index, partition, %{}) + + v1_set = Map.get(edge_partition, v1_id, MapSet.new()) + v2_set = Map.get(edge_partition, v2_id, MapSet.new()) + + new_edge_partition = + edge_partition + |> Map.put( + v1_id, + MapSet.put(v1_set, {v1_id, v2_id}) + ) + |> Map.put( + v2_id, + MapSet.put(v2_set, {v1_id, v2_id}) + ) + + %__MODULE__{ + g + | edge_index: + g.edge_index + |> Map.put(partition, new_edge_partition) + } + end) + end + @doc """ This function is like `add_edge/3`, but for multiple edges at once, it also accepts edge specifications in a few different ways to make it easy to generate graphs succinctly. Edges must be provided as a list of `Edge` structs, `{vertex, vertex}` pairs, or - `{vertex, vertex, edge_opts :: [label: term, weight: integer]}`. + `{vertex, vertex, edge_opts :: [label: term, weight: integer, properties: map]}`. See the docs for `Graph.Edge.new/2` or `Graph.Edge.new/3` for more info on creating Edge structs, and `add_edge/3` for information on edge options. @@ -1117,7 +1519,7 @@ defmodule Graph do iex> alias Graph.Edge ...> edges = [Edge.new(:a, :b), Edge.new(:b, :c, weight: 2)] ...> g = Graph.new |> Graph.add_vertices([:a, :b, :c]) |> Graph.add_edges(edges) - ...> Graph.edges(g) + ...> Graph.edges(g) |> Enum.sort_by(& {&1.v1, &1.v2, &1.label}) [%Graph.Edge{v1: :a, v2: :b}, %Graph.Edge{v1: :b, v2: :c, weight: 2}] iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:a, :b, label: :foo}, {:a, :b, label: :foo, weight: 2}]) @@ -1188,19 +1590,25 @@ defmodule Graph do meta <- Map.get(em, {v1_id, v2_id}), v1_out <- MapSet.delete(v1_out, v2_id), v2_in <- MapSet.delete(v2_in, v1_id) do + g = %__MODULE__{} = prune_all_edge_indexes(g, {v1_id, v1}, {v2_id, v2}) + g = %__MODULE__{ g - | in_edges: Map.put(ie, v2_id, v2_in), - out_edges: Map.put(oe, v1_id, v1_out), - edges: Map.delete(em, {v1_id, v2_id}) + | in_edges: Map.put(g.in_edges, v2_id, v2_in), + out_edges: Map.put(g.out_edges, v1_id, v1_out), + edges: Map.delete(g.edges, {v1_id, v2_id}) } + properties = g.edge_properties + g = %{g | edge_properties: Map.delete(properties, {v1_id, v2_id})} g = add_vertex(g, v3) Enum.reduce(meta, g, fn {label, weight}, acc -> + props = get_edge_props(properties, {v1_id, v2_id}, label) + acc - |> add_edge(v1, v3, label: label, weight: weight) - |> add_edge(v3, v2, label: label, weight: weight) + |> add_edge(v1, v3, label: label, weight: weight, properties: props) + |> add_edge(v3, v2, label: label, weight: weight, properties: props) end) else _ -> {:error, :no_such_edge} @@ -1267,18 +1675,49 @@ defmodule Graph do {:ok, meta} <- Map.fetch(em, edge_key), {:ok, _} <- Map.fetch(meta, old_label), {new_label, new_weight} <- Edge.options_to_meta(opts) do + new_properties = Keyword.get(opts, :properties, %{}) + + ep = + if new_properties == %{} do + g.edge_properties + else + key_props = Map.get(g.edge_properties, edge_key, %{}) + target_label = if new_label == nil, do: old_label, else: new_label + Map.put(g.edge_properties, edge_key, Map.put(key_props, target_label, new_properties)) + end + case new_label do ^old_label -> new_meta = Map.put(meta, old_label, new_weight) - %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + replace_edge_metadata(g, {v1_id, v1}, {v2_id, v2}, new_meta, ep) nil -> new_meta = Map.put(meta, old_label, new_weight) - %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + replace_edge_metadata(g, {v1_id, v1}, {v2_id, v2}, new_meta, ep) _ -> new_meta = Map.put(Map.delete(meta, old_label), new_label, new_weight) - %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + + # Remove old label's properties, add new label's + ep = + case Map.get(ep, edge_key) do + nil -> + ep + + label_props -> + label_props = Map.delete(label_props, old_label) + + label_props = + if new_properties == %{}, + do: label_props, + else: Map.put(label_props, new_label, new_properties) + + if label_props == %{}, + do: Map.delete(ep, edge_key), + else: Map.put(ep, edge_key, label_props) + end + + replace_edge_metadata(g, {v1_id, v1}, {v2_id, v2}, new_meta, ep) end else _ -> @@ -1286,6 +1725,29 @@ defmodule Graph do end end + # Rebuild only the changed endpoint pair's memberships. A relabel can replace + # an existing label whose property-based partitions also need to be removed. + defp replace_edge_metadata(g, {v1_id, v1} = from, {v2_id, v2} = to, meta, properties) do + edge_key = {v1_id, v2_id} + g = prune_all_edge_indexes(g, from, to) + g = %{g | edges: Map.put(g.edges, edge_key, meta), edge_properties: properties} + + if g.multigraph do + Enum.reduce(meta, g, fn {label, weight}, acc -> + edge = + Edge.new(v1, v2, + label: label, + weight: weight, + properties: get_edge_props(properties, edge_key, label) + ) + + index_multigraph_edge(acc, edge_key, edge) + end) + else + g + end + end + @doc """ Removes all edges connecting `v1` to `v2`, regardless of label. @@ -1330,6 +1792,7 @@ defmodule Graph do edge_key <- {v1_id, v2_id}, {:ok, v1_out} <- Map.fetch(oe, v1_id), {:ok, v2_in} <- Map.fetch(ie, v2_id) do + g = %__MODULE__{} = prune_all_edge_indexes(g, {v1_id, v1}, {v2_id, v2}) v1_out = MapSet.delete(v1_out, v2_id) v2_in = MapSet.delete(v2_in, v1_id) meta = Map.delete(meta, edge_key) @@ -1338,13 +1801,149 @@ defmodule Graph do g | in_edges: Map.put(ie, v2_id, v2_in), out_edges: Map.put(oe, v1_id, v1_out), - edges: meta + edges: meta, + edge_properties: Map.delete(g.edge_properties, edge_key) } else _ -> g end end + # Prunes ALL edge index entries for every label between v1 and v2. + # Used by delete_edge/3 (all labels), split_edge, and delete_vertex. + defp prune_all_edge_indexes(%__MODULE__{multigraph: false} = g, _v1, _v2), do: g + + defp prune_all_edge_indexes( + %__MODULE__{ + multigraph: true, + edges: meta, + partition_by: partition_by, + edge_properties: ep + } = + g, + {v1_id, v1}, + {v2_id, v2} + ) do + edge_key = {v1_id, v2_id} + + meta + |> Map.get(edge_key, %{}) + |> Enum.reduce(g, fn {label, weight}, acc -> + props = get_edge_props(ep, edge_key, label) + edge = Edge.new(v1, v2, label: label, weight: weight, properties: props) + prune_edge_key_from_partitions(acc, edge_key, v1_id, v2_id, partition_by.(edge)) + end) + end + + # Prunes edge index entries for a single labeled edge between v1 and v2. + # Used by delete_edge/4 (specific label) and update_labelled_edge (label change). + defp prune_edge_index(%__MODULE__{multigraph: false} = g, _v1, _v2, _label), do: g + + defp prune_edge_index( + %__MODULE__{ + multigraph: true, + edges: meta, + partition_by: partition_by, + edge_properties: ep + } = + g, + {v1_id, v1}, + {v2_id, v2}, + label + ) do + edge_key = {v1_id, v2_id} + + case meta |> Map.get(edge_key, %{}) |> Map.fetch(label) do + {:ok, weight} -> + props = get_edge_props(ep, edge_key, label) + edge = Edge.new(v1, v2, label: label, weight: weight, properties: props) + # The index stores endpoint pairs, not individual labels. Removing one + # label must retain memberships contributed by another parallel label. + retained_partitions = + meta + |> Map.fetch!(edge_key) + |> Map.delete(label) + |> Enum.flat_map(fn {other_label, other_weight} -> + other_props = get_edge_props(ep, edge_key, other_label) + + partition_by.( + Edge.new(v1, v2, + label: other_label, + weight: other_weight, + properties: other_props + ) + ) + end) + |> MapSet.new() + + removed_partitions = + edge + |> partition_by.() + |> Enum.reject(&MapSet.member?(retained_partitions, &1)) + + prune_edge_key_from_partitions(g, edge_key, v1_id, v2_id, removed_partitions) + + :error -> + g + end + end + + defp prune_edge_key_from_partitions(g, edge_key, v1_id, v2_id, partitions) do + Enum.reduce(partitions, g, fn edge_p, %__MODULE__{} = acc -> + partition = Map.get(acc.edge_index, edge_p, %{}) + partition = delete_endpoint_edge(partition, v1_id, edge_key) + + partition = + if v1_id === v2_id, + do: partition, + else: delete_endpoint_edge(partition, v2_id, edge_key) + + edge_index = + if map_size(partition) == 0, + do: Map.delete(acc.edge_index, edge_p), + else: Map.put(acc.edge_index, edge_p, partition) + + %__MODULE__{acc | edge_index: edge_index} + end) + end + + defp delete_endpoint_edge(partition, vertex_id, edge_key) do + case Map.fetch(partition, vertex_id) do + :error -> + partition + + {:ok, edges} -> + remaining = MapSet.delete(edges, edge_key) + + if MapSet.size(remaining) == 0, + do: Map.delete(partition, vertex_id), + else: Map.put(partition, vertex_id, remaining) + end + end + + defp prune_vertex_from_edge_index(%__MODULE__{multigraph: false} = g, _v_id, _v), do: g + + defp prune_vertex_from_edge_index( + %__MODULE__{multigraph: true, out_edges: oe, in_edges: ie, vertices: vs} = g, + v_id, + v + ) do + g = + oe + |> Map.get(v_id, MapSet.new()) + |> Enum.reduce(g, fn neighbor_id, acc -> + neighbor = Map.get(vs, neighbor_id) + prune_all_edge_indexes(acc, {v_id, v}, {neighbor_id, neighbor}) + end) + + ie + |> Map.get(v_id, MapSet.new()) + |> Enum.reduce(g, fn neighbor_id, acc -> + neighbor = Map.get(vs, neighbor_id) + prune_all_edge_indexes(acc, {neighbor_id, neighbor}, {v_id, v}) + end) + end + @doc """ Removes an edge connecting `v1` to `v2`. A label can be specified to disambiguate the specific edge you wish to delete, if not provided, the unlabelled edge, if one exists, @@ -1400,7 +1999,9 @@ defmodule Graph do {:ok, v2_in} <- Map.fetch(ie, v2_id), {:ok, edge_meta} <- Map.fetch(meta, edge_key), {:ok, _} <- Map.fetch(edge_meta, label) do + g = %__MODULE__{} = prune_edge_index(g, {v1_id, v1}, {v2_id, v2}, label) edge_meta = Map.delete(edge_meta, label) + g = %{g | edge_properties: delete_edge_properties(g.edge_properties, edge_key, label)} case map_size(edge_meta) do 0 -> @@ -1501,36 +2102,7 @@ defmodule Graph do do_delete_edges(g, v1, v2) end - defp do_delete_edges( - %__MODULE__{ - in_edges: ie, - out_edges: oe, - edges: meta, - vertex_identifier: vertex_identifier - } = g, - v1, - v2 - ) do - with v1_id <- vertex_identifier.(v1), - v2_id <- vertex_identifier.(v2), - edge_key <- {v1_id, v2_id}, - true <- Map.has_key?(meta, edge_key), - v1_out <- Map.get(oe, v1_id), - v2_in <- Map.get(ie, v2_id) do - meta = Map.delete(meta, edge_key) - v1_out = MapSet.delete(v1_out, v2_id) - v2_in = MapSet.delete(v2_in, v1_id) - - %__MODULE__{ - g - | out_edges: Map.put(oe, v1_id, v1_out), - in_edges: Map.put(ie, v2_id, v2_in), - edges: meta - } - else - _ -> g - end - end + defp do_delete_edges(g, v1, v2), do: do_delete_edge(g, v1, v2) @doc """ The transposition of a graph is another graph with the direction of all the edges reversed. @@ -1538,16 +2110,39 @@ defmodule Graph do ## Example iex> g = Graph.new |> Graph.add_vertices([:a, :b, :c]) |> Graph.add_edge(:a, :b) |> Graph.add_edge(:b, :c) - ...> g |> Graph.transpose |> Graph.edges + ...> g |> Graph.transpose |> Graph.edges |> Enum.sort_by(& {&1.v1, &1.v2, &1.label}) [%Graph.Edge{v1: :b, v2: :a}, %Graph.Edge{v1: :c, v2: :b}] """ @spec transpose(t) :: t - def transpose(%__MODULE__{in_edges: ie, out_edges: oe, edges: meta} = g) do + def transpose( + %__MODULE__{in_edges: ie, out_edges: oe, edges: meta, edge_index: ei, edge_properties: ep} = + g + ) do meta2 = meta |> Enum.reduce(%{}, fn {{v1, v2}, meta}, acc -> Map.put(acc, {v2, v1}, meta) end) - %__MODULE__{g | in_edges: oe, out_edges: ie, edges: meta2} + ei2 = + Map.new(ei, fn {partition, vertex_map} -> + new_vertex_map = + Map.new(vertex_map, fn {v_id, edge_keys} -> + {v_id, MapSet.new(edge_keys, fn {v1, v2} -> {v2, v1} end)} + end) + + {partition, new_vertex_map} + end) + + ep2 = + Map.new(ep, fn {{v1, v2}, props} -> {{v2, v1}, props} end) + + %__MODULE__{ + g + | in_edges: oe, + out_edges: ie, + edges: meta2, + edge_index: ei2, + edge_properties: ep2 + } end @doc """ @@ -2225,7 +2820,8 @@ defmodule Graph do vertices: vs, in_edges: ie, edges: meta, - vertex_identifier: vertex_identifier + vertex_identifier: vertex_identifier, + edge_properties: ep }, v ) do @@ -2233,9 +2829,11 @@ defmodule Graph do {:ok, v_in} <- Map.fetch(ie, v_id) do Enum.flat_map(v_in, fn v1_id -> v1 = Map.get(vs, v1_id) + edge_key = {v1_id, v_id} - Enum.map(Map.get(meta, {v1_id, v_id}), fn {label, weight} -> - Edge.new(v1, v, label: label, weight: weight) + Enum.map(Map.get(meta, edge_key), fn {label, weight} -> + props = get_edge_props(ep, edge_key, label) + Edge.new(v1, v, label: label, weight: weight, properties: props) end) end) else @@ -2243,6 +2841,23 @@ defmodule Graph do end end + @doc """ + Returns a list of `Graph.Edge` structs representing the in edges to vertex `v`, + filtered by the given partition. + + Only available when `multigraph: true`. + + ## Example + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([{:a, :b, label: :foo}, {:a, :b, label: :bar}]) + ...> Graph.in_edges(g, :b, by: :foo) + [%Graph.Edge{v1: :a, v2: :b, label: :foo}] + """ + @spec in_edges(t, vertex, [{:by, term}]) :: [Edge.t()] + def in_edges(%__MODULE__{multigraph: true} = g, v, by: partition) do + incident_edges_in_partitions(g, v, [partition], nil, :in) + end + @doc """ Returns a list of vertices which the given vertex `v` has edges going to. @@ -2292,7 +2907,8 @@ defmodule Graph do vertices: vs, out_edges: oe, edges: meta, - vertex_identifier: vertex_identifier + vertex_identifier: vertex_identifier, + edge_properties: ep }, v ) do @@ -2300,9 +2916,11 @@ defmodule Graph do {:ok, v_out} <- Map.fetch(oe, v_id) do Enum.flat_map(v_out, fn v2_id -> v2 = Map.get(vs, v2_id) + edge_key = {v_id, v2_id} - Enum.map(Map.get(meta, {v_id, v2_id}), fn {label, weight} -> - Edge.new(v, v2, label: label, weight: weight) + Enum.map(Map.get(meta, edge_key), fn {label, weight} -> + props = get_edge_props(ep, edge_key, label) + Edge.new(v, v2, label: label, weight: weight, properties: props) end) end) else @@ -2311,6 +2929,64 @@ defmodule Graph do end end + @doc """ + Returns a list of `Graph.Edge` structs representing the out edges from vertex `v`, + filtered by multigraph options. + + Only available when `multigraph: true`. + + ## Options + + - `:by` - a single partition key or list of partition keys to filter edges by + - `:where` - a predicate function that receives an edge and returns a boolean + + ## Example + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([{:a, :b, label: :foo}, {:a, :b, label: :bar}, {:a, :c}]) + ...> Graph.out_edges(g, :a, by: :foo) + [%Graph.Edge{v1: :a, v2: :b, label: :foo}] + + iex> g = Graph.new(multigraph: true) |> Graph.add_edges([{:a, :b, label: :foo, weight: 5}, {:a, :b, label: :bar}]) + ...> Graph.out_edges(g, :a, by: :foo, where: fn e -> e.weight > 1 end) + [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 5}] + """ + @spec out_edges(Graph.t(), any(), [{:by, any()}, ...]) :: list() + def out_edges(%__MODULE__{multigraph: true} = g, v, opts) + when is_list(opts) do + where_fun = opts[:where] + + if Keyword.has_key?(opts, :by) do + partitions = partition_for_opts(opts[:by]) + + out_edges_in_partitions(g, v, partitions, where_fun) + else + g + |> out_edges(v) + |> filter_edges(where_fun) + end + end + + defp partition_for_opts(partition) when is_list(partition) do + partition + end + + defp partition_for_opts(partition) do + [partition] + end + + defp out_edges_in_partitions(g, v, partitions, where_fun) do + incident_edges_in_partitions(g, v, partitions, where_fun, :out) + end + + defp include_edge_for_filtered_partitions?(_edge, edge_partitions, partitions, nil = _where_fun) do + Enum.any?(edge_partitions, fn ep -> ep in partitions end) + end + + defp include_edge_for_filtered_partitions?(edge, edge_partitions, partitions, where_fun) + when is_function(where_fun) do + Enum.any?(edge_partitions, fn ep -> ep in partitions and where_fun.(edge) end) + end + @doc """ Builds a maximal subgraph of `g` which includes all of the vertices in `vs` and the edges which connect them. @@ -2325,7 +3001,9 @@ defmodule Graph do vertices: vertices, out_edges: oe, edges: meta, - vertex_identifier: vertex_identifier + vertex_identifier: vertex_identifier, + multigraph: multigraph, + partition_by: partition_by } = graph, vs ) do @@ -2335,25 +3013,41 @@ defmodule Graph do |> Enum.filter(&Map.has_key?(vertices, &1)) |> MapSet.new() - Enum.reduce(allowed, Graph.new(type: type, vertex_identifier: vertex_identifier), fn v_id, - sg -> - v = Map.get(vertices, v_id) - - sg = - sg - |> Graph.add_vertex(v) - |> Graph.label_vertex(v, Graph.vertex_labels(graph, v)) - - oe - |> Map.get(v_id, MapSet.new()) - |> MapSet.intersection(allowed) - |> Enum.reduce(sg, fn v2_id, sg -> - v2 = Map.get(vertices, v2_id) - - Enum.reduce(Map.get(meta, {v_id, v2_id}), sg, fn {label, weight}, sg -> - Graph.add_edge(sg, v, v2, label: label, weight: weight) + Enum.reduce( + allowed, + Graph.new( + type: type, + multigraph: multigraph, + partition_by: partition_by, + vertex_identifier: vertex_identifier + ), + fn v_id, sg -> + v = Map.get(vertices, v_id) + + sg = + sg + |> Graph.add_vertex(v) + |> Graph.label_vertex(v, Graph.vertex_labels(graph, v)) + + oe + |> Map.get(v_id, MapSet.new()) + |> MapSet.intersection(allowed) + |> Enum.reduce(sg, fn v2_id, sg -> + v2 = Map.get(vertices, v2_id) + + edge_key = {v_id, v2_id} + + Enum.reduce(Map.get(meta, edge_key), sg, fn {label, weight}, sg -> + props = get_edge_props(graph.edge_properties, edge_key, label) + + Graph.add_edge(sg, v, v2, + label: label, + weight: weight, + properties: props + ) + end) end) - end) - end) + end + ) end end diff --git a/lib/graph/pathfinding.ex b/lib/graph/pathfinding.ex index e560df2..eccbb85 100644 --- a/lib/graph/pathfinding.ex +++ b/lib/graph/pathfinding.ex @@ -2,7 +2,7 @@ defmodule Graph.Pathfinding do @moduledoc """ This module contains implementation code for path finding algorithms used by `libgraph`. """ - import Graph.Utils, only: [edge_weight: 3] + import Graph.Utils, only: [edge_weight: 3, edge_weight: 4] @type heuristic_fun :: (Graph.vertex() -> integer) @@ -10,6 +10,9 @@ defmodule Graph.Pathfinding do %{Graph.vertex() => integer() | :infinity} | nil def bellman_ford(g, a), do: Graph.Pathfindings.BellmanFord.call(g, a) + def bellman_ford(g, a, opts) when is_list(opts), + do: Graph.Pathfindings.BellmanFord.call(g, a, opts) + @doc """ Finds the shortest path between `a` and `b` as a list of vertices. Returns `nil` if no path can be found. @@ -18,11 +21,16 @@ defmodule Graph.Pathfinding do which path to explore next. The cost function in Dijkstra's algorithm is `weight(E(A, B))+lower_bound(E(A, B))` where `lower_bound(E(A, B))` is always 0. """ - @spec dijkstra(Graph.t(), Graph.vertex(), Graph.vertex()) :: [Graph.vertex()] | nil + @spec dijkstra(Graph.t(), Graph.vertex(), Graph.vertex()) :: + [Graph.vertex()] | nil def dijkstra(%Graph{} = g, a, b) do a_star(g, a, b, fn _v -> 0 end) end + def dijkstra(%Graph{} = g, a, b, opts) when is_list(opts) do + a_star(g, a, b, fn _v -> 0 end, opts) + end + @doc """ Finds the shortest path between `a` and `b` as a list of vertices. Returns `nil` if no path can be found. @@ -36,29 +44,77 @@ defmodule Graph.Pathfinding do always returns 0, and thus the next vertex is chosen based on the weight of the edge between it and the current vertex. """ - @spec a_star(Graph.t(), Graph.vertex(), Graph.vertex(), heuristic_fun) :: [Graph.vertex()] | nil - def a_star( - %Graph{type: :directed, vertices: vs, out_edges: oe, vertex_identifier: vertex_identifier} = - g, - a, - b, - hfun - ) - when is_function(hfun, 1) do + @spec a_star(Graph.t(), Graph.vertex(), Graph.vertex(), heuristic_fun) :: + [Graph.vertex()] | nil + def a_star(%Graph{} = g, a, b, hfun) when is_function(hfun, 1) do + do_a_star(g, a, b, hfun, nil) + end + + def a_star(%Graph{} = g, a, b, hfun, opts) when is_function(hfun, 1) and is_list(opts) do + partitions = partitions_from_opts(opts) + do_a_star(g, a, b, hfun, partitions) + end + + @doc """ + Finds all paths between `a` and `b`, each path as a list of vertices. + Returns `[]` if no path can be found. Results are eager and their number can + grow exponentially; see `Graph.get_paths/3` for usage and complexity guidance. + """ + @spec all(Graph.t(), Graph.vertex(), Graph.vertex()) :: [[Graph.vertex()]] + def all(%Graph{vertices: vs, out_edges: oe, vertex_identifier: vertex_identifier} = g, a, b) do with a_id <- vertex_identifier.(a), b_id <- vertex_identifier.(b), {:ok, a_out} <- Map.fetch(oe, a_id) do + case dfs(g, a_out, b_id, [a_id], MapSet.new([a_id]), []) do + [] -> + [] + + paths -> + paths + |> Enum.map(fn path -> Enum.map(path, &Map.get(vs, &1)) end) + end + else + _ -> [] + end + end + + ## Private + + defp partitions_from_opts(opts) do + case Keyword.fetch(opts, :by) do + {:ok, by} when is_list(by) -> by + {:ok, by} -> [by] + :error -> nil + end + end + + defp do_a_star( + %Graph{type: :directed, vertices: vs, vertex_identifier: vertex_identifier} = g, + a, + b, + hfun, + partitions + ) do + a_id = vertex_identifier.(a) + b_id = vertex_identifier.(b) + a_out = get_out_neighbors(g, a_id, partitions) + + if a_out do tree = %{a_id => a_id} q = PriorityQueue.new() q = a_out - |> Stream.map(fn id -> {id, cost(g, a_id, id, hfun)} end) + |> Stream.map(fn id -> {id, cost(g, a_id, id, hfun, partitions)} end) |> Enum.reduce(q, fn {id, cost}, q -> - PriorityQueue.push(q, {a_id, id, edge_weight(g, a_id, id)}, cost) + PriorityQueue.push( + q, + {a_id, id, do_edge_weight(g, a_id, id, partitions)}, + cost + ) end) - case do_bfs(q, g, b_id, tree, hfun) do + case do_bfs(q, g, b_id, tree, hfun, partitions) do nil -> nil @@ -66,32 +122,35 @@ defmodule Graph.Pathfinding do for id <- path, do: Map.get(vs, id) end else - _ -> - nil + nil end end - def a_star( - %Graph{type: :undirected, vertices: vs, vertex_identifier: vertex_identifier} = g, - a, - b, - hfun - ) - when is_function(hfun, 1) do + defp do_a_star( + %Graph{type: :undirected, vertices: vs, vertex_identifier: vertex_identifier} = g, + a, + b, + hfun, + partitions + ) do a_id = vertex_identifier.(a) b_id = vertex_identifier.(b) - a_all_edges = all_edges(g, a_id) + a_neighbors = get_all_neighbors(g, a_id, partitions) tree = %{a_id => a_id} q = PriorityQueue.new() q = - a_all_edges - |> Stream.map(fn id -> {id, cost(g, a_id, id, hfun)} end) + a_neighbors + |> Stream.map(fn id -> {id, cost(g, a_id, id, hfun, partitions)} end) |> Enum.reduce(q, fn {id, cost}, q -> - PriorityQueue.push(q, {a_id, id, edge_weight(g, a_id, id)}, cost) + PriorityQueue.push( + q, + {a_id, id, do_edge_weight(g, a_id, id, partitions)}, + cost + ) end) - case do_bfs(q, g, b_id, tree, hfun) do + case do_bfs(q, g, b_id, tree, hfun, partitions) do nil -> nil @@ -100,30 +159,35 @@ defmodule Graph.Pathfinding do end end - @doc """ - Finds all paths between `a` and `b`, each path as a list of vertices. - Returns `[]` if no path can be found. Results are eager and their number can - grow exponentially; see `Graph.get_paths/3` for usage and complexity guidance. - """ - @spec all(Graph.t(), Graph.vertex(), Graph.vertex()) :: [[Graph.vertex()]] - def all(%Graph{vertices: vs, out_edges: oe, vertex_identifier: vertex_identifier} = g, a, b) do - with a_id <- vertex_identifier.(a), - b_id <- vertex_identifier.(b), - {:ok, a_out} <- Map.fetch(oe, a_id) do - case dfs(g, a_out, b_id, [a_id], MapSet.new([a_id]), []) do - [] -> - [] - - paths -> - paths - |> Enum.map(fn path -> Enum.map(path, &Map.get(vs, &1)) end) - end - else - _ -> [] + defp get_out_neighbors(%Graph{out_edges: oe}, v_id, nil) do + case Map.fetch(oe, v_id) do + {:ok, out} -> out + :error -> nil end end - ## Private + defp get_out_neighbors(%Graph{out_edges: oe, edge_index: edge_index}, v_id, partitions) do + case Map.get(oe, v_id) do + nil -> + nil + + out_set -> + filtered = + partitions + |> Enum.reduce(MapSet.new(), fn partition, acc -> + edge_index + |> Map.get(partition, %{}) + |> Map.get(v_id, MapSet.new()) + |> Enum.reduce(acc, fn + {^v_id, v2_id}, acc -> MapSet.put(acc, v2_id) + _, acc -> acc + end) + end) + |> MapSet.intersection(out_set) + + if MapSet.size(filtered) == 0, do: nil, else: filtered + end + end defp all_edges(%Graph{type: :undirected, out_edges: oe, in_edges: ie}, v_id) do v_in = Map.get(ie, v_id, MapSet.new()) @@ -131,16 +195,53 @@ defmodule Graph.Pathfinding do MapSet.union(v_in, v_out) end + defp get_all_neighbors(%Graph{type: :undirected} = g, v_id, nil) do + all_edges(g, v_id) + end + + defp get_all_neighbors( + %Graph{type: :undirected, in_edges: ie, out_edges: oe, edge_index: edge_index}, + v_id, + partitions + ) do + all_set = MapSet.union(Map.get(ie, v_id, MapSet.new()), Map.get(oe, v_id, MapSet.new())) + + partitions + |> Enum.reduce(MapSet.new(), fn partition, acc -> + edge_index + |> Map.get(partition, %{}) + |> Map.get(v_id, MapSet.new()) + |> Enum.reduce(acc, fn + {^v_id, v2_id}, acc -> MapSet.put(acc, v2_id) + {v1_id, ^v_id}, acc -> MapSet.put(acc, v1_id) + _, acc -> acc + end) + end) + |> MapSet.intersection(all_set) + end + + defp cost(g, v1_id, v2_id, hfun, nil) do + cost(g, v1_id, v2_id, hfun) + end + + defp cost(%Graph{vertices: vs} = g, v1_id, v2_id, hfun, partitions) do + edge_weight(g, v1_id, v2_id, partitions) + hfun.(Map.get(vs, v2_id)) + end + defp cost(%Graph{vertices: vs} = g, v1_id, v2_id, hfun) do edge_weight(g, v1_id, v2_id) + hfun.(Map.get(vs, v2_id)) end + defp do_edge_weight(g, a, b, nil), do: edge_weight(g, a, b) + defp do_edge_weight(g, a, b, partitions), do: edge_weight(g, a, b, partitions) + defp do_bfs( q, - %Graph{type: :directed, out_edges: oe} = g, + %Graph{type: :directed} = g, target_id, tree, - hfun + hfun, + partitions ) do case PriorityQueue.pop(q) do {{:value, {v_id, ^target_id, _}}, _q1} -> @@ -148,27 +249,29 @@ defmodule Graph.Pathfinding do {{:value, {v1_id, v2_id, v2_acc_weight}}, q1} -> if Map.has_key?(tree, v2_id) do - do_bfs(q1, g, target_id, tree, hfun) + do_bfs(q1, g, target_id, tree, hfun, partitions) else - case Map.get(oe, v2_id) do + case get_out_neighbors(g, v2_id, partitions) do nil -> - do_bfs(q1, g, target_id, tree, hfun) + do_bfs(q1, g, target_id, tree, hfun, partitions) v2_out -> tree = Map.put(tree, v2_id, v1_id) q2 = v2_out - |> Enum.map(fn id -> {id, v2_acc_weight + cost(g, v2_id, id, hfun)} end) + |> Enum.map(fn id -> + {id, v2_acc_weight + cost(g, v2_id, id, hfun, partitions)} + end) |> Enum.reduce(q1, fn {id, cost}, q -> PriorityQueue.push( q, - {v2_id, id, v2_acc_weight + edge_weight(g, v2_id, id)}, + {v2_id, id, v2_acc_weight + do_edge_weight(g, v2_id, id, partitions)}, cost ) end) - do_bfs(q2, g, target_id, tree, hfun) + do_bfs(q2, g, target_id, tree, hfun, partitions) end end @@ -182,7 +285,8 @@ defmodule Graph.Pathfinding do %Graph{type: :undirected} = g, target_id, tree, - hfun + hfun, + partitions ) do case PriorityQueue.pop(q) do {{:value, {v_id, ^target_id, _}}, _q1} -> @@ -190,27 +294,29 @@ defmodule Graph.Pathfinding do {{:value, {v1_id, v2_id, v2_acc_weight}}, q1} -> if Map.has_key?(tree, v2_id) do - do_bfs(q1, g, target_id, tree, hfun) + do_bfs(q1, g, target_id, tree, hfun, partitions) else - all_edges = all_edges(g, v2_id) + neighbors = get_all_neighbors(g, v2_id, partitions) - if MapSet.equal?(all_edges, MapSet.new()) do - do_bfs(q1, g, target_id, tree, hfun) + if MapSet.equal?(neighbors, MapSet.new()) do + do_bfs(q1, g, target_id, tree, hfun, partitions) else tree = Map.put(tree, v2_id, v1_id) q2 = - all_edges - |> Enum.map(fn id -> {id, v2_acc_weight + cost(g, v2_id, id, hfun)} end) + neighbors + |> Enum.map(fn id -> + {id, v2_acc_weight + cost(g, v2_id, id, hfun, partitions)} + end) |> Enum.reduce(q1, fn {id, cost}, q -> PriorityQueue.push( q, - {v2_id, id, v2_acc_weight + edge_weight(g, v2_id, id)}, + {v2_id, id, v2_acc_weight + do_edge_weight(g, v2_id, id, partitions)}, cost ) end) - do_bfs(q2, g, target_id, tree, hfun) + do_bfs(q2, g, target_id, tree, hfun, partitions) end end diff --git a/lib/graph/pathfindings/bellman_ford.ex b/lib/graph/pathfindings/bellman_ford.ex index 4a4cd81..37dd324 100644 --- a/lib/graph/pathfindings/bellman_ford.ex +++ b/lib/graph/pathfindings/bellman_ford.ex @@ -11,11 +11,27 @@ defmodule Graph.Pathfindings.BellmanFord do @doc """ Returns nil when graph has negative cycle. """ - @spec call(Graph.t(), Graph.vertex()) :: %{Graph.vertex() => number() | :infinity} | nil - def call(%Graph{vertices: vs, edges: meta} = g, a) do + @spec call(Graph.t(), Graph.vertex()) :: + %{Graph.vertex() => number() | :infinity} | nil + def call(%Graph{} = g, a), do: do_call(g, a, nil) + + def call(%Graph{} = g, a, opts) when is_list(opts) do + partitions = partitions_from_opts(opts) + do_call(g, a, partitions) + end + + defp partitions_from_opts(opts) do + case Keyword.fetch(opts, :by) do + {:ok, by} when is_list(by) -> by + {:ok, by} -> [by] + :error -> nil + end + end + + defp do_call(%Graph{vertices: vs, edges: meta} = g, a, partitions) do distances = a |> g.vertex_identifier.() |> init_distances(vs) - weights = Enum.map(meta, &edge_weight/1) + weights = edges_with_weights(meta, g, partitions) distances = for _ <- 1..map_size(vs), @@ -31,6 +47,33 @@ defmodule Graph.Pathfindings.BellmanFord do end end + defp edges_with_weights(meta, _g, nil) do + Enum.map(meta, &edge_weight/1) + end + + defp edges_with_weights( + meta, + %Graph{partition_by: partition_by, edge_properties: ep}, + partitions + ) do + Enum.flat_map(meta, fn {edge_key, edge_value} -> + edge_value + |> Enum.filter(fn {label, weight} -> + props = + case ep do + %{^edge_key => %{^label => p}} -> p + _ -> %{} + end + + eps = partition_by.(%{label: label, weight: weight, properties: props}) + Enum.any?(eps, fn ep -> ep in partitions end) + end) + |> Enum.map(fn {_label, weight} -> + {edge_key, weight} + end) + end) + end + @spec init_distances(Graph.vertex(), Graph.vertices()) :: distance defp init_distances(vertex_id, vertices) do Map.new(vertices, fn diff --git a/lib/graph/reducer.ex b/lib/graph/reducer.ex index 0671952..2757301 100644 --- a/lib/graph/reducer.ex +++ b/lib/graph/reducer.ex @@ -16,7 +16,11 @@ defmodule Graph.Reducer do """ @callback map(g :: Graph.t(), mapper :: (Graph.vertex() -> term)) :: term - @callback reduce(g :: Graph.t(), acc :: term, reducer :: (Graph.vertex(), term -> term)) :: + @callback reduce( + g :: Graph.t(), + acc :: term, + reducer :: (Graph.vertex(), term -> term) + ) :: {:next, term} | {:halt, term} diff --git a/lib/graph/reducers/bfs.ex b/lib/graph/reducers/bfs.ex index 6879c4c..bab60f0 100644 --- a/lib/graph/reducers/bfs.ex +++ b/lib/graph/reducers/bfs.ex @@ -20,8 +20,12 @@ defmodule Graph.Reducers.Bfs do [1, 3, 4, 2] """ def map(g, fun) when is_function(fun, 1) do + map(g, fun, []) + end + + def map(g, fun, opts) when is_function(fun, 1) and is_list(opts) do g - |> reduce([], fn v, results -> {:next, [fun.(v) | results]} end) + |> reduce([], fn v, results -> {:next, [fun.(v) | results]} end, opts) |> Enum.reverse() end @@ -50,51 +54,94 @@ defmodule Graph.Reducers.Bfs do ...> #{__MODULE__}.reduce(g, [], fn 4, acc -> {:halt, acc}; v, acc -> {:next, [v|acc]} end) [3, 1] """ - def reduce(%Graph{vertices: vs} = g, acc, fun) when is_function(fun, 2) do + def reduce(%Graph{} = g, acc, fun) when is_function(fun, 2) do + reduce(g, acc, fun, []) + end + + def reduce(%Graph{vertices: vs} = g, acc, fun, opts) + when is_function(fun, 2) and is_list(opts) do + partitions = partitions_from_opts(opts) + vs # Start with a cost of zero |> Stream.map(fn {id, _} -> {id, 0} end) # Only populate the initial queue with those vertices which have no inbound edges - |> Stream.reject(fn {id, _cost} -> inbound_edges?(g, id) end) + |> Stream.reject(fn {id, _cost} -> inbound_edges?(g, id, partitions) end) |> Enum.reduce(PriorityQueue.new(), fn {id, cost}, q -> PriorityQueue.push(q, id, cost) end) - |> traverse(g, MapSet.new(), fun, acc) + |> traverse(g, MapSet.new(), fun, acc, partitions) end - defp inbound_edges?(%Graph{in_edges: ie}, v_id) do + defp partitions_from_opts(opts) do + case Keyword.fetch(opts, :by) do + {:ok, by} when is_list(by) -> by + {:ok, by} -> [by] + :error -> nil + end + end + + defp inbound_edges?(%Graph{in_edges: ie}, v_id, _partitions) do case Map.get(ie, v_id) do nil -> false edges -> MapSet.size(edges) > 0 end end - defp traverse(q, %Graph{out_edges: oe, vertices: vertices} = g, visited, fun, acc) do + defp out_neighbors(%Graph{out_edges: oe}, v_id, nil) do + Map.get(oe, v_id, MapSet.new()) + end + + defp out_neighbors(%Graph{out_edges: oe, edge_index: edge_index}, v_id, partitions) do + out_set = Map.get(oe, v_id, MapSet.new()) + + partitions + |> Enum.reduce(MapSet.new(), fn partition, acc -> + edge_index + |> Map.get(partition, %{}) + |> Map.get(v_id, MapSet.new()) + |> Enum.reduce(acc, fn + {^v_id, v2_id}, acc -> MapSet.put(acc, v2_id) + _, acc -> acc + end) + end) + |> MapSet.intersection(out_set) + end + + defp edge_weight_for(g, v_id, id, nil) do + Graph.Utils.edge_weight(g, v_id, id) + end + + defp edge_weight_for(g, v_id, id, partitions) do + Graph.Utils.edge_weight(g, v_id, id, partitions) + end + + defp traverse(q, %Graph{vertices: vertices} = g, visited, fun, acc, partitions) do case PriorityQueue.pop(q) do {{:value, v_id}, q1} -> if MapSet.member?(visited, v_id) do - traverse(q1, g, visited, fun, acc) + traverse(q1, g, visited, fun, acc, partitions) else v = Map.get(vertices, v_id) case fun.(v, acc) do {:next, acc2} -> visited = MapSet.put(visited, v_id) - v_out = Map.get(oe, v_id, MapSet.new()) + v_out = out_neighbors(g, v_id, partitions) q2 = v_out |> MapSet.to_list() |> Enum.reduce(q1, fn id, q -> - weight = Graph.Utils.edge_weight(g, v_id, id) + weight = edge_weight_for(g, v_id, id, partitions) PriorityQueue.push(q, id, weight) end) - traverse(q2, g, visited, fun, acc2) + traverse(q2, g, visited, fun, acc2, partitions) {:skip, acc2} -> visited = MapSet.put(visited, v_id) - traverse(q1, g, visited, fun, acc2) + traverse(q1, g, visited, fun, acc2, partitions) {:halt, acc2} -> acc2 diff --git a/lib/graph/reducers/dfs.ex b/lib/graph/reducers/dfs.ex index 9444a0c..102e0f1 100644 --- a/lib/graph/reducers/dfs.ex +++ b/lib/graph/reducers/dfs.ex @@ -20,7 +20,11 @@ defmodule Graph.Reducers.Dfs do [1, 3, 2, 4] """ def map(g, fun) when is_function(fun, 1) do - reduce(g, [], fn v, results -> {:next, [fun.(v) | results]} end) + map(g, fun, []) + end + + def map(g, fun, opts) when is_function(fun, 1) and is_list(opts) do + reduce(g, [], fn v, results -> {:next, [fun.(v) | results]} end, opts) |> Enum.reverse() end @@ -49,15 +53,75 @@ defmodule Graph.Reducers.Dfs do ...> #{__MODULE__}.reduce(g, [], fn 4, acc -> {:halt, acc}; v, acc -> {:next, [v|acc]} end) [2, 3, 1] """ - def reduce(%Graph{vertices: vs} = g, acc, fun) when is_function(fun, 2) do - traverse(Map.keys(vs), g, MapSet.new(), fun, acc) + def reduce(%Graph{} = g, acc, fun) when is_function(fun, 2) do + reduce(g, acc, fun, []) + end + + def reduce(%Graph{vertices: vs} = g, acc, fun, opts) + when is_function(fun, 2) and is_list(opts) do + partitions = partitions_from_opts(opts) + + start_ids = + if partitions do + Enum.reject(Map.keys(vs), fn id -> inbound_edges?(g, id) end) + else + Map.keys(vs) + end + + traverse(start_ids, g, MapSet.new(), fun, acc, partitions) + end + + defp partitions_from_opts(opts) do + case Keyword.fetch(opts, :by) do + {:ok, by} when is_list(by) -> by + {:ok, by} -> [by] + :error -> nil + end + end + + defp inbound_edges?(%Graph{in_edges: ie}, v_id) do + case Map.get(ie, v_id) do + nil -> false + edges -> MapSet.size(edges) > 0 + end + end + + defp out_neighbors(%Graph{out_edges: oe}, v_id, nil) do + oe + |> Map.get(v_id, MapSet.new()) + |> MapSet.to_list() + end + + defp out_neighbors(%Graph{out_edges: oe, edge_index: edge_index}, v_id, partitions) do + out_set = Map.get(oe, v_id, MapSet.new()) + + partitions + |> Enum.reduce(MapSet.new(), fn partition, acc -> + edge_index + |> Map.get(partition, %{}) + |> Map.get(v_id, MapSet.new()) + |> Enum.reduce(acc, fn + {^v_id, v2_id}, acc -> MapSet.put(acc, v2_id) + _, acc -> acc + end) + end) + |> MapSet.intersection(out_set) + |> MapSet.to_list() + end + + defp edge_weight_for(g, v_id, id, nil) do + Graph.Utils.edge_weight(g, v_id, id) + end + + defp edge_weight_for(g, v_id, id, partitions) do + Graph.Utils.edge_weight(g, v_id, id, partitions) end ## Private - defp traverse([v_id | rest], %Graph{out_edges: oe, vertices: vs} = g, visited, fun, acc) do + defp traverse([v_id | rest], %Graph{vertices: vs} = g, visited, fun, acc, partitions) do if MapSet.member?(visited, v_id) do - traverse(rest, g, visited, fun, acc) + traverse(rest, g, visited, fun, acc, partitions) else v = Map.get(vs, v_id) @@ -66,17 +130,15 @@ defmodule Graph.Reducers.Dfs do visited = MapSet.put(visited, v_id) out = - oe - |> Map.get(v_id, MapSet.new()) - |> MapSet.to_list() - |> Enum.sort_by(fn id -> Graph.Utils.edge_weight(g, v_id, id) end) + out_neighbors(g, v_id, partitions) + |> Enum.sort_by(fn id -> edge_weight_for(g, v_id, id, partitions) end) - traverse(out ++ rest, g, visited, fun, acc2) + traverse(out ++ rest, g, visited, fun, acc2, partitions) {:skip, acc2} -> # Skip this vertex and it's out-neighbors visited = MapSet.put(visited, v_id) - traverse(rest, g, visited, fun, acc2) + traverse(rest, g, visited, fun, acc2, partitions) {:halt, acc2} -> acc2 @@ -84,7 +146,7 @@ defmodule Graph.Reducers.Dfs do end end - defp traverse([], _g, _visited, _fun, acc) do + defp traverse([], _g, _visited, _fun, acc, _partitions) do acc end end diff --git a/lib/graph/utils.ex b/lib/graph/utils.ex index f53b987..dadb411 100644 --- a/lib/graph/utils.ex +++ b/lib/graph/utils.ex @@ -1,6 +1,6 @@ defmodule Graph.Utils do @moduledoc false - @compile {:inline, [vertex_id: 1, edge_weight: 3]} + @compile {:inline, [{:vertex_id, 1}, {:edge_weight, 3}, {:edge_weight, 4}]} @binary_heap_limit 64 @@ -107,7 +107,68 @@ defmodule Graph.Utils do end end + def edge_weight( + %Graph{ + type: :directed, + edges: meta, + partition_by: partition_by, + edge_properties: ep + }, + a, + b, + partitions + ) do + edge_key = {a, b} + + meta + |> Map.fetch!(edge_key) + |> filter_by_partitions(partition_by, partitions, ep, edge_key) + |> Enum.map(fn {_label, weight} -> weight end) + |> Enum.min() + end + + def edge_weight( + %Graph{ + type: :undirected, + edges: meta, + partition_by: partition_by, + edge_properties: ep + }, + a, + b, + partitions + ) do + edge_key = if Map.has_key?(meta, {a, b}), do: {a, b}, else: {b, a} + edge_meta = Map.get(meta, edge_key) + + case edge_meta do + nil -> + [] + + edge_meta when is_map(edge_meta) -> + edge_meta + |> filter_by_partitions(partition_by, partitions, ep, edge_key) + |> Enum.map(fn {_label, weight} -> weight end) + |> Enum.min() + end + end + + defp filter_by_partitions(edge_meta, partition_by, partitions, ep, edge_key) do + Enum.filter(edge_meta, fn {label, weight} -> + props = + case ep do + %{^edge_key => %{^label => p}} -> p + _ -> %{} + end + + eps = partition_by.(%{label: label, weight: weight, properties: props}) + Enum.any?(eps, fn ep -> ep in partitions end) + end) + end + # 2^32 @max_phash 4_294_967_296 def vertex_id(v), do: :erlang.phash2(v, @max_phash) + + def by_edge_label(%{label: label}), do: [label] end diff --git a/mix.exs b/mix.exs index a119dad..8fb9af3 100644 --- a/mix.exs +++ b/mix.exs @@ -18,9 +18,13 @@ defmodule Graph.Mixfile do def cli do [ - preferred_cli_env: [ + preferred_envs: [ docs: :dev, - bench: :bench + bench: :bench, + "bench.multigraph": :bench, + "bench.multigraph_creation": :bench, + "bench.multigraph_memory": :bench, + "bench.partition_indexes": :bench ] ] end @@ -67,7 +71,11 @@ defmodule Graph.Mixfile do "bench.create": ["run bench/create.exs"], "bench.k_core": ["run bench/k_core.exs"], "bench.shortest_path": ["run bench/shortest_path.exs"], - "bench.topsort": ["run bench/topsort.exs"] + "bench.topsort": ["run bench/topsort.exs"], + "bench.multigraph": ["run bench/multigraph.exs"], + "bench.multigraph_creation": ["run bench/multigraph_creation.exs"], + "bench.multigraph_memory": ["run bench/multigraph_memory.exs"], + "bench.partition_indexes": ["run bench/partition_indexes.exs"] ] end diff --git a/test/graph_test.exs b/test/graph_test.exs index baedfb6..e07927f 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -20,6 +20,423 @@ defmodule GraphTest do assert Graph.has_vertex?(g_with_custom_vertex_identifier, :v1) end + describe "multigraphs" do + test "`multigraph: true` option enables edge indexing on edge labels" do + graph = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b}, + {:a, :b, label: :foo}, + {:a, :b, label: :bar}, + {:b, :c, weight: 3}, + {:b, :a, label: {:complex, :label}} + ]) + + assert Enum.count(Graph.out_edges(graph, :a)) == 3 + assert [%Edge{label: :foo}] = Graph.out_edges(graph, :a, by: [:foo]) + assert [%Edge{label: :foo}] = Graph.out_edges(graph, :a, by: :foo) + assert [%Edge{label: :foo}] = Graph.in_edges(graph, :b, by: :foo) + assert [%Edge{label: :bar}] = Graph.out_edges(graph, :a, by: :bar) + assert [%Edge{label: nil}] = Graph.out_edges(graph, :a, by: nil) + + assert [%Edge{label: nil}] = Graph.out_edges(graph, :a, by: nil) + + assert [%Edge{label: {:complex, :label}}] = + Graph.out_edges(graph, :b, + where: fn edge -> edge.label == {:complex, :label} or edge.label == :bar end + ) + + assert 1 == graph |> Graph.edges(by: :foo) |> Enum.count() + assert 1 == graph |> Graph.edges(where: fn edge -> edge.weight > 2 end) |> Enum.count() + + assert 1 == + graph + |> Graph.edges(:a, by: :foo) + |> Enum.count() + + assert 2 == + graph + |> Graph.edges(by: [:foo, :bar]) + |> Enum.count() + + assert 1 == + graph + |> Graph.edges(by: [:foo, :bar], where: fn edge -> edge.label == :bar end) + |> Enum.count() + + assert [] == Graph.out_edges(graph, :a, by: :foobar) + end + + test "custom edge partition_by function" do + graph = + Graph.new(multigraph: true, partition_by: fn edge -> [edge.weight] end) + |> Graph.add_edges([ + {:a, :b}, + {:a, :b, label: :foo}, + {:a, :b, label: :bar}, + {:b, :c, weight: 3}, + {:b, :a, weight: 6} + ]) + + assert Enum.count(Graph.out_edges(graph, :b)) == 2 + + assert [%Edge{weight: 6}] = + Graph.out_edges(graph, :b, where: fn edge -> edge.weight == 6 end) + + assert [%Edge{weight: 3}] = + Graph.out_edges(graph, :b, where: fn edge -> edge.weight == 3 end) + end + + test "custom partition_by supports indexing to more than one partition" do + graph = + Graph.new(multigraph: true, partition_by: fn edge -> [edge.weight, edge.label] end) + |> Graph.add_edges([ + {:a, :b}, + {:a, :d, label: :foo}, + {:a, :b, label: :bar}, + {:b, :c, weight: 3}, + {:b, :a, weight: 6, label: :foo} + ]) + + assert Enum.count(Graph.out_edges(graph, :b)) == 2 + + assert [%Edge{weight: 6, label: :foo}] = + Graph.out_edges(graph, :b, by: 6) + + assert Enum.count(Graph.edges(graph, by: [:foo])) == 2 + end + + test "removing edges prunes index" do + g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b}, + {:a, :b, label: :foo}, + {:a, :b, label: :bar}, + {:b, :c, weight: 3}, + {:b, :a, label: {:complex, :label}} + ]) + + g = Graph.delete_edges(g, [{:b, :c}, {:b, :a}]) + refute Map.has_key?(g.edge_index, {:complex, :label}) + assert Enum.empty?(Graph.edges(g, by: [{:complex, :label}])) + # nil partition still exists for a->b nil-label edge + assert Map.has_key?(g.edge_index, nil) + end + + test "delete_edge/3 removes only a multigraph's properties and index for the given partition key/label" do + g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b}, + {:a, :b, label: :foo}, + {:a, :b, label: :bar}, + {:b, :c, weight: 3}, + {:b, :a, label: {:complex, :label}} + ]) + + g = Graph.delete_edge(g, :a, :b, :foo) + + refute Map.has_key?(g.edge_index, :foo) + assert Enum.empty?(Graph.out_edges(g, :a, by: :foo)) + assert Enum.empty?(Graph.edges(g, by: :foo)) + end + + test "update_labelled_edge/3 updates an indexed adge with new label" do + g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b}, + {:a, :b, label: :foo}, + {:a, :b, label: :bar}, + {:b, :c, weight: 3}, + {:b, :a, label: {:complex, :label}} + ]) + + g = Graph.update_labelled_edge(g, :a, :b, :foo, label: :baz) + + refute Map.has_key?(g.edge_index, :foo) + assert Map.has_key?(g.edge_index[:baz], g.vertex_identifier.(:a)) + end + + test "update_labelled_edge preserves sibling edges in partition index" do + g = + Graph.new(multigraph: true) + |> Graph.add_edge(:fact, :join, label: :runnable) + |> Graph.add_edge(:fact, :step_a, label: :runnable) + |> Graph.add_edge(:fact, :step_b, label: :runnable) + + assert length(Graph.edges(g, by: [:runnable])) == 3 + + g = Graph.update_labelled_edge(g, :fact, :join, :runnable, label: :ran) + + ran_edges = Graph.edges(g, by: [:ran]) + assert length(ran_edges) == 1 + assert hd(ran_edges).v1 == :fact and hd(ran_edges).v2 == :join + + runnable_edges = Graph.edges(g, by: [:runnable]) + assert length(runnable_edges) == 2 + + runnable_targets = Enum.map(runnable_edges, & &1.v2) |> Enum.sort() + assert runnable_targets == [:step_a, :step_b] + end + + test "delete_vertex prunes edge_index" do + g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :foo}, + {:a, :c, label: :bar}, + {:b, :c, label: :foo}, + {:c, :d, label: :baz} + ]) + + assert Map.has_key?(g.edge_index, :foo) + assert Map.has_key?(g.edge_index, :bar) + + g = Graph.delete_vertex(g, :a) + + refute Graph.has_vertex?(g, :a) + # :bar partition only had a->c, should be gone + refute Map.has_key?(g.edge_index, :bar) + # :foo partition still has b->c + assert Map.has_key?(g.edge_index, :foo) + assert [%Edge{v1: :b, v2: :c, label: :foo}] = Graph.edges(g, by: [:foo]) + + # no stale references to deleted vertex + Enum.each(g.edge_index, fn {_partition, vertex_map} -> + refute Map.has_key?(vertex_map, g.vertex_identifier.(:a)) + + Enum.each(vertex_map, fn {_v_id, edge_keys} -> + Enum.each(edge_keys, fn {v1_id, v2_id} -> + refute v1_id == g.vertex_identifier.(:a) + refute v2_id == g.vertex_identifier.(:a) + end) + end) + end) + end + + test "delete_vertices prunes edge_index" do + g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :foo}, + {:b, :c, label: :bar}, + {:c, :d, label: :baz} + ]) + + g = Graph.delete_vertices(g, [:a, :c]) + + refute Map.has_key?(g.edge_index, :foo) + refute Map.has_key?(g.edge_index, :bar) + refute Map.has_key?(g.edge_index, :baz) + assert g.edge_index == %{} + end + + test "transpose preserves edge_index with flipped edge keys" do + g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :foo}, + {:b, :c, label: :bar} + ]) + + gt = Graph.transpose(g) + + assert [%Edge{v1: :b, v2: :a, label: :foo}] = Graph.out_edges(gt, :b, by: :foo) + assert [%Edge{v1: :c, v2: :b, label: :bar}] = Graph.out_edges(gt, :c, by: :bar) + assert Enum.empty?(Graph.out_edges(gt, :a, by: :foo)) + end + + test "split_edge prunes old edge and indexes new edges" do + g = + Graph.new(multigraph: true) + |> Graph.add_edge(:a, :c, label: :foo) + |> Graph.add_edge(:a, :c, label: :bar) + + g = Graph.split_edge(g, :a, :c, :b) + + # old a->c edges should be gone from index + a_id = g.vertex_identifier.(:a) + c_id = g.vertex_identifier.(:c) + + Enum.each(g.edge_index, fn {_partition, vertex_map} -> + Enum.each(vertex_map, fn {_v_id, edge_keys} -> + refute MapSet.member?(edge_keys, {a_id, c_id}) + end) + end) + + # new edges a->b and b->c should be indexed + assert [%Edge{v1: :a, v2: :b, label: :foo}] = Graph.out_edges(g, :a, by: :foo) + assert [%Edge{v1: :a, v2: :b, label: :bar}] = Graph.out_edges(g, :a, by: :bar) + assert [%Edge{v1: :b, v2: :c, label: :foo}] = Graph.out_edges(g, :b, by: :foo) + assert [%Edge{v1: :b, v2: :c, label: :bar}] = Graph.out_edges(g, :b, by: :bar) + end + + test "subgraph preserves multigraph settings and rebuilds index" do + g = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :foo}, + {:b, :c, label: :bar}, + {:c, :d, label: :baz} + ]) + + sg = Graph.subgraph(g, [:a, :b, :c]) + + assert sg.multigraph == true + assert [%Edge{v1: :a, v2: :b, label: :foo}] = Graph.edges(sg, by: [:foo]) + assert [%Edge{v1: :b, v2: :c, label: :bar}] = Graph.edges(sg, by: [:bar]) + # :baz edge is not in subgraph since :d is excluded + assert Enum.empty?(Graph.edges(sg, by: [:baz])) + end + + test "BFS traversal using multigraph partitions" do + graph = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :foo}, + {:a, :b, label: :bar}, + {:a, :c, label: :foo}, + {:b, :d, label: :bar}, + {:c, :d, label: :foo} + ]) + + # BFS following only :foo edges: a -> b, a -> c, c -> d + foo_result = Graph.Reducers.Bfs.map(graph, fn v -> v end, by: :foo) + assert :a == hd(foo_result) + assert MapSet.new(foo_result) == MapSet.new([:a, :b, :c, :d]) + + # BFS following only :bar edges: a -> b, b -> d + bar_result = Graph.Reducers.Bfs.map(graph, fn v -> v end, by: :bar) + assert :a == hd(bar_result) + assert MapSet.new(bar_result) == MapSet.new([:a, :b, :d]) + end + + test "DFS traversal using multigraph partitions" do + graph = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :foo}, + {:a, :b, label: :bar}, + {:a, :c, label: :foo}, + {:b, :d, label: :bar}, + {:c, :d, label: :foo} + ]) + + # DFS following only :foo edges: a -> b, a -> c -> d + foo_result = Graph.Reducers.Dfs.map(graph, fn v -> v end, by: :foo) + assert :a == hd(foo_result) + assert MapSet.new(foo_result) == MapSet.new([:a, :b, :c, :d]) + + # DFS following only :bar edges: a -> b -> d + bar_result = Graph.Reducers.Dfs.map(graph, fn v -> v end, by: :bar) + assert :a == hd(bar_result) + assert MapSet.new(bar_result) == MapSet.new([:a, :b, :d]) + end + + test "Dijkstra with multigraph partition filtering" do + graph = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :fast, weight: 1}, + {:a, :c, label: :slow, weight: 10}, + {:b, :d, label: :fast, weight: 1}, + {:c, :d, label: :slow, weight: 1} + ]) + + # Via :fast edges only: a->b->d, cost 2 + assert [:a, :b, :d] = Graph.dijkstra(graph, :a, :d, by: :fast) + + # Via :slow edges only: a->c->d, cost 11 + assert [:a, :c, :d] = Graph.dijkstra(graph, :a, :d, by: :slow) + + # No :fast path from :a to :c + assert nil == Graph.dijkstra(graph, :a, :c, by: :fast) + end + + test "A* with multigraph partition filtering" do + graph = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :fast, weight: 1}, + {:a, :c, label: :slow, weight: 10}, + {:b, :d, label: :fast, weight: 1}, + {:c, :d, label: :slow, weight: 1} + ]) + + assert [:a, :b, :d] = Graph.a_star(graph, :a, :d, fn _ -> 0 end, by: :fast) + assert [:a, :c, :d] = Graph.a_star(graph, :a, :d, fn _ -> 0 end, by: :slow) + end + + test "Bellman-Ford with multigraph partition filtering" do + graph = + Graph.new(multigraph: true) + |> Graph.add_edges([ + {:a, :b, label: :fast, weight: 1}, + {:a, :c, label: :slow, weight: 10}, + {:b, :d, label: :fast, weight: 2}, + {:c, :d, label: :slow, weight: 1} + ]) + + result = Graph.bellman_ford(graph, :a, by: :fast) + assert result[:a] == 0 + assert result[:b] == 1 + assert result[:d] == 3 + # :c is not reachable via :fast edges + assert result[:c] == :infinity + end + end + + describe "edge properties" do + test "setting edge properties" do + g = + Graph.new() + |> Graph.add_edges([ + {:a, :b, properties: %{foo: :bar}}, + {:a, :b, label: :foo, properties: %{bar: :foo}} + ]) + + edges = Graph.out_edges(g, :a) |> Enum.sort_by(fn e -> {e.label != nil, e.label} end) + + assert [ + %Edge{v1: :a, v2: :b, properties: %{foo: :bar}}, + %Edge{v1: :a, v2: :b, label: :foo, properties: %{bar: :foo}} + ] = edges + end + + test "updating edge properties" do + g = + Graph.new() + |> Graph.add_edges([ + {:a, :b, properties: %{foo: :bar}}, + {:a, :b, label: :foo, properties: %{bar: :foo}} + ]) + |> Graph.update_edge(:a, :b, properties: %{ham: :potato}) + |> Graph.update_labelled_edge(:a, :b, :foo, properties: %{potato: :ham}) + + edges = Graph.out_edges(g, :a) |> Enum.sort_by(fn e -> {e.label != nil, e.label} end) + + assert [ + %Edge{v1: :a, v2: :b, properties: %{ham: :potato}}, + %Edge{v1: :a, v2: :b, label: :foo, properties: %{potato: :ham}} + ] = edges + end + + test "adding edge struct with properties" do + g = + Graph.new() + + edge = Edge.new(:a, :b, properties: %{foo: :bar}) + + g = Graph.add_edge(g, edge) + + assert [ + %Edge{v1: :a, v2: :b, properties: %{foo: :bar}} + ] = Graph.out_edges(g, :a) + end + end + test "delete vertex" do g = Graph.new() g = Graph.add_vertex(g, :v1, :labelA) @@ -73,16 +490,22 @@ defmodule GraphTest do doc = Inspect.Algebra.format(Inspect.Algebra.to_doc(g, %Inspect.Opts{structs: false}), 99999) assert ^structs_false = :erlang.iolist_to_binary(doc) - # pretty printed + # pretty printed - edge order within a vertex pair is non-deterministic (map iteration) str = "#{inspect(g)}" - assert "#Graph :b, :a -> :b, :b -[{:complex, :label}]-> :a, :b -> :c]>" = - str + assert str =~ ~r/^#Graph :b" + assert str =~ ":a -[foo]-> :b" + assert str =~ ":b -[{:complex, :label}]-> :a" + assert str =~ ":b -> :c" ustr = "#{inspect(ug)}" - assert "#Graph :b, :a <-> :b, :a <-[{:complex, :label}]-> :b, :b <-> :c]>" = - ustr + assert ustr =~ ~r/^#Graph :b" + assert ustr =~ ":a <-[foo]-> :b" + assert ustr =~ ":a <-[{:complex, :label}]-> :b" + assert ustr =~ ":b <-> :c" # large graph g = Enum.reduce(1..150, Graph.new(), fn i, g -> Graph.add_edge(g, i, i + 1) end) @@ -155,13 +578,11 @@ defmodule GraphTest do ]) |> Graph.edges(:a) - expected_result = [ - %Graph.Edge{label: "label3", v1: :b, v2: :a, weight: 1}, - %Graph.Edge{label: "label1", v1: :a, v2: :b, weight: 1}, - %Graph.Edge{label: "label2", v1: :a, v2: :b, weight: 1} - ] - - assert generated_result == expected_result + for edge <- generated_result do + assert edge.label in ["label1", "label2", "label3"] and + ((edge.v1 == :a and edge.v2 == :b) or + (edge.v1 == :b and edge.v2 == :a)) + end end test "is_subgraph?" do @@ -182,7 +603,8 @@ defmodule GraphTest do test "find all paths" do g = build_basic_cyclic_graph() - assert [[:a, :c, :d, :e], [:a, :b, :d, :e], [:a, :b, :c, :d, :e]] = Graph.get_paths(g, :a, :e) + assert [[:a, :c, :d, :e], [:a, :b, :d, :e], [:a, :b, :c, :d, :e]] = + Graph.get_paths(g, :a, :e) end test "find all paths on loopy graph" do @@ -355,7 +777,15 @@ defmodule GraphTest do g = Graph.new(type: :undirected) |> Graph.add_vertices([:a, :b, :c, :d, :e, :f]) - |> Graph.add_edges([{:a, :b}, {:b, :c}, {:c, :d}, {:d, :e}, {:e, :a}, {:e, :b}, {:d, :f}]) + |> Graph.add_edges([ + {:a, :b}, + {:b, :c}, + {:c, :d}, + {:d, :e}, + {:e, :a}, + {:e, :b}, + {:d, :f} + ]) cliques = Graph.cliques(g) |> Enum.map(&Enum.sort/1) |> Enum.sort() assert [[:a, :b, :e], [:b, :c], [:c, :d], [:d, :e], [:d, :f]] = cliques @@ -365,7 +795,15 @@ defmodule GraphTest do g = Graph.new(type: :undirected) |> Graph.add_vertices([:a, :b, :c, :d, :e, :f]) - |> Graph.add_edges([{:a, :b}, {:b, :c}, {:c, :d}, {:d, :e}, {:e, :a}, {:e, :b}, {:d, :f}]) + |> Graph.add_edges([ + {:a, :b}, + {:b, :c}, + {:c, :d}, + {:d, :e}, + {:e, :a}, + {:e, :b}, + {:d, :f} + ]) assert [[:a, :b, :e]] = Graph.k_cliques(g, 3) end @@ -484,7 +922,7 @@ defmodule GraphTest do assert [:i] = components[0] assert [:e, :f, :g, :h] = components[1] assert is_nil(components[2]) - assert [:a, :b, :c, :d] = components[3] + assert Enum.sort(components[3]) == [:a, :b, :c, :d] end test "coreness/2" do diff --git a/test/multigraph_index_contract_test.exs b/test/multigraph_index_contract_test.exs new file mode 100644 index 0000000..ab55576 --- /dev/null +++ b/test/multigraph_index_contract_test.exs @@ -0,0 +1,345 @@ +defmodule Graph.IndexContractTest do + use ExUnit.Case, async: true + + alias Graph.Edge + + defp partitions(edge) do + [edge.label, :all, {:weight, edge.weight}, {:kind, Map.get(edge.properties, :kind)}] + end + + defp graph(type) do + Graph.new(multigraph: true, type: type, partition_by: &partitions/1) + end + + # Independent oracle: read the canonical edge records, not an adjacency index. + # Every assertion checks complete structs, including orientation and properties. + defp assert_index_contract(g) do + edges = Graph.edges(g) + partitions = Enum.flat_map(edges, &g.partition_by.(&1)) |> Enum.uniq() + vertices = Graph.vertices(g) + + expected_index = + Enum.reduce(edges, %{}, fn edge, index -> + from = g.vertex_identifier.(edge.v1) + to = g.vertex_identifier.(edge.v2) + + Enum.reduce(g.partition_by.(edge), index, fn partition, index -> + Enum.reduce(Enum.uniq([from, to]), index, fn endpoint, index -> + Map.update(index, partition, %{endpoint => MapSet.new([{from, to}])}, fn owners -> + Map.update(owners, endpoint, MapSet.new([{from, to}]), &MapSet.put(&1, {from, to})) + end) + end) + end) + end) + + assert g.edge_index == expected_index + + for partition <- [:missing | partitions] do + selected = Enum.filter(edges, &(partition in g.partition_by.(&1))) + assert MapSet.new(Graph.edges(g, by: partition)) == MapSet.new(selected) + + for vertex <- vertices do + incident = Enum.filter(selected, &(&1.v1 === vertex or &1.v2 === vertex)) + + incoming = + if g.type == :undirected, do: incident, else: Enum.filter(selected, &(&1.v2 === vertex)) + + outgoing = + if g.type == :undirected, do: incident, else: Enum.filter(selected, &(&1.v1 === vertex)) + + assert MapSet.new(Graph.edges(g, vertex, by: partition)) == MapSet.new(incident) + assert MapSet.new(Graph.in_edges(g, vertex, by: partition)) == MapSet.new(incoming) + assert MapSet.new(Graph.out_edges(g, vertex, by: partition)) == MapSet.new(outgoing) + end + end + + g + end + + for type <- [:directed, :undirected] do + @graph_type type + + test "#{type}: overlapping partitions, opposite orientations, self loops, and predicates" do + g = + graph(@graph_type) + |> Graph.add_edge(:a, :b, label: :flow, weight: 3, properties: %{kind: :definition}) + |> Graph.add_edge(:a, :b, label: :produced, properties: %{kind: :runtime}) + |> Graph.add_edge(:c, :b, label: :flow) + |> Graph.add_edge(:b, :a, label: :reverse) + |> Graph.add_edge(:b, :b, label: :flow, weight: 2) + |> assert_index_contract() + + result = Graph.out_edges(g, :b, by: [:all, :flow, :all], where: &(&1.weight >= 2)) + assert length(result) == length(Enum.uniq(result)) + expected = Graph.out_edges(g, :b, by: :all) |> Enum.filter(&(&1.weight >= 2)) + assert MapSet.new(result) == MapSet.new(expected) + assert Graph.out_edges(g, :b, by: []) == [] + end + + test "#{type}: relabel preserves another label's shared partition and properties" do + g = + graph(@graph_type) + |> Graph.add_edge(:a, :b, label: :runnable, properties: %{kind: :active}) + |> Graph.add_edge(:a, :b, label: :flow, properties: %{kind: :definition}) + |> Graph.add_edge(:x, :y, label: :flow) + |> Graph.update_labelled_edge(:a, :b, :runnable, + label: :ran, + properties: %{kind: :history} + ) + |> assert_index_contract() + + assert [%Edge{label: :flow, properties: %{kind: :definition}}] = + Graph.out_edges(g, :a, by: {:kind, :definition}) + + assert Graph.out_edges(g, :a, by: {:kind, :active}) == [] + assert length(Graph.out_edges(g, :a, by: :all)) == 2 + end + + test "#{type}: relabel replacing an existing label removes its old custom partitions" do + graph(@graph_type) + |> Graph.add_edge(:a, :b, label: :first, properties: %{kind: :first}) + |> Graph.add_edge(:a, :b, label: :second, properties: %{kind: :second}) + |> Graph.update_labelled_edge(:a, :b, :first, + label: :second, + properties: %{kind: :replacement} + ) + |> assert_index_contract() + end + + test "#{type}: same-label updates and add overwrite refresh custom partitions" do + graph(@graph_type) + |> Graph.add_edge(:a, :b, label: :flow, weight: 1, properties: %{kind: :old}) + |> Graph.update_labelled_edge(:a, :b, :flow, weight: 4, properties: %{kind: :new}) + |> assert_index_contract() + |> Graph.update_labelled_edge(:a, :b, :flow, label: :flow, weight: 8) + |> assert_index_contract() + |> Graph.add_edge(:a, :b, label: :flow, weight: 2, properties: %{kind: :newer}) + |> assert_index_contract() + |> Graph.add_edge(:a, :b, label: :flow, weight: 3) + |> assert_index_contract() + end + + test "#{type}: deleting one parallel label retains the others and deletes empty indexes" do + g = + graph(@graph_type) + |> Graph.add_edge(:a, :b, label: :one, properties: %{kind: :common}) + |> Graph.add_edge(:a, :b, label: :two, properties: %{kind: :common}) + |> Graph.delete_edge(:a, :b, :one) + |> assert_index_contract() + + assert [%Edge{label: :two}] = Graph.in_edges(g, :b, by: {:kind, :common}) + assert Graph.delete_edge(g, :a, :b, :absent) == g + g = g |> Graph.delete_edge(:a, :b, :two) |> assert_index_contract() + assert g.edge_index == %{} + assert g.edge_properties == %{} + end + + test "#{type}: deleting all labels, reinsertion, and deleting vertices clean properties" do + g = + graph(@graph_type) + |> Graph.add_edge(:a, :b, label: :one, properties: %{kind: :old}) + |> Graph.add_edge(:a, :b, label: :two, properties: %{kind: :old}) + |> Graph.delete_edge(:a, :b) + |> assert_index_contract() + + assert g.edge_properties == %{} + g = g |> Graph.add_edge(:a, :b, label: :one) |> assert_index_contract() + assert [%Edge{properties: %{}}] = Graph.edges(g) + + g = + g + |> Graph.add_edge(:a, :a, label: :self, properties: %{kind: :old}) + |> Graph.add_edge(:c, :a, label: :back, properties: %{kind: :old}) + |> Graph.delete_vertex(:a) + |> assert_index_contract() + + assert g.edge_index == %{} + assert g.edge_properties == %{} + end + + test "#{type}: removing and relabeling self loops updates the endpoint only once" do + graph(@graph_type) + |> Graph.add_edge(:a, :a, label: :one) + |> Graph.add_edge(:a, :a, label: :two) + |> Graph.add_edge(:a, :b, label: :one) + |> Graph.update_labelled_edge(:a, :a, :one, label: :three) + |> assert_index_contract() + |> Graph.delete_edge(:a, :a, :two) + |> assert_index_contract() + |> Graph.delete_edge(:a, :a) + |> assert_index_contract() + end + + test "#{type}: fixed-seed mutation traces preserve index equivalence after every operation" do + :rand.seed(:exsss, {101, 203, 307}) + + Enum.reduce(1..180, graph(@graph_type), fn _, g -> + from = Enum.at([:a, :b, :c, :d], :rand.uniform(4) - 1) + to = Enum.at([:a, :b, :c, :d], :rand.uniform(4) - 1) + label = Enum.at([:flow, :runnable, :ran], :rand.uniform(3) - 1) + + opts = [ + label: label, + weight: :rand.uniform(4), + properties: %{kind: rem(:rand.uniform(10), 3)} + ] + + g = + case :rand.uniform(6) do + 1 -> + Graph.delete_edge(g, from, to, label) + + 2 -> + Graph.delete_edge(g, from, to) + + 3 -> + Graph.delete_vertex(g, from) + + 4 -> + case Graph.update_labelled_edge(g, from, to, :runnable, opts) do + {:error, :no_such_edge} -> g + updated -> updated + end + + _ -> + Graph.add_edge(g, from, to, opts) + end + + assert_index_contract(g) + end) + end + end + + test "partition query preserves structured vertices and custom vertex identifiers" do + Graph.new(multigraph: true, vertex_identifier: & &1.id, partition_by: &partitions/1) + |> Graph.add_edge(%{id: {:node, 1}}, %{id: {:node, 2}}, label: :flow) + |> Graph.add_edge(%{id: {:node, 3}}, %{id: {:node, 2}}, label: :ran) + |> assert_index_contract() + end + + test "partition queries do not call partition function for unrelated historical neighbors" do + partition = fn edge -> + if edge.label == :history, do: send(self(), :visited_history) + [edge.label] + end + + g = + Enum.reduce(1..500, Graph.new(multigraph: true, partition_by: partition), fn n, g -> + Graph.add_edge(g, {:fact, n}, :reducer, label: :history) + end) + |> Graph.add_edge(:mapper, :reducer, label: :fan_in) + + drain_history_messages() + assert [%Edge{v1: :mapper}] = Graph.in_edges(g, :reducer, by: :fan_in) + refute_received :visited_history + end + + test "same-identity replacement preserves indexed queries with current vertex values" do + g = + Graph.new(multigraph: true, vertex_identifier: & &1.id) + |> Graph.add_edge(%{id: 1, value: :old}, %{id: 2, value: :target}, label: :flow) + |> Graph.replace_vertex(%{id: 1}, %{id: 1, value: :new}) + |> assert_index_contract() + + assert [%Edge{v1: %{value: :new}}] = Graph.in_edges(g, %{id: 2}, by: :flow) + end + + for type <- [:directed, :undirected] do + @replacement_type type + + test "#{type}: changed-identity replacement preserves properties and partition queries" do + g = + graph(@replacement_type) + |> Graph.add_edge(:a, :b, label: :flow, properties: %{kind: :definition}) + |> Graph.add_edge(:a, :a, label: :loop, properties: %{kind: :self}) + |> Graph.replace_vertex(:a, :replacement) + |> assert_index_contract() + + assert Graph.edge(g, :replacement, :b, :flow).properties == %{kind: :definition} + assert Graph.out_edges(g, :a, by: :flow) == [] + assert length(Graph.edges(g, :b, by: :flow)) == 1 + + refute Enum.any?(Map.keys(g.edge_properties), fn {a, b} -> + a === g.vertex_identifier.(:a) or b === g.vertex_identifier.(:a) + end) + end + + test "#{type}: coalescing vertices keeps winning properties with their edge records" do + g = + graph(@replacement_type) + |> Graph.add_edge(:a, :c, label: :first, properties: %{kind: :first}) + |> Graph.add_edge(:b, :c, label: :second, properties: %{kind: :second}) + |> Graph.add_edge(:a, :d, label: :flow, properties: %{kind: :retained}) + |> Graph.replace_vertex(:a, :b) + |> assert_index_contract() + + for edge <- Graph.edges(g) do + expected = %{first: :first, second: :second, flow: :retained} + assert edge.properties == %{kind: Map.fetch!(expected, edge.label)} + end + + g |> Graph.delete_vertex(:b) |> assert_index_contract() + end + + test "#{type}: split and bulk deletion remove original properties and partitions" do + g = + graph(@replacement_type) + |> Graph.add_edge(:a, :b, label: :flow, properties: %{kind: :definition}) + |> Graph.add_edge(:a, :b, label: :other, properties: %{kind: :runtime}) + |> Graph.split_edge(:a, :b, :middle) + |> assert_index_contract() + + refute Map.has_key?(g.edge_properties, {g.vertex_identifier.(:a), g.vertex_identifier.(:b)}) + assert Graph.edge(g, :a, :middle, :flow).properties == %{kind: :definition} + assert Graph.edge(g, :middle, :b, :other).properties == %{kind: :runtime} + assert MapSet.new(Graph.edges(g, :a, :middle)) == MapSet.new(Graph.edges(g, :a)) + + g = + g + |> Graph.delete_edges(:a, :middle) + |> Graph.delete_edges(:middle, :b) + |> assert_index_contract() + + assert g.edge_properties == %{} + end + + test "#{type}: exact numeric identity survives indexed mutations and subgraphs" do + g = + Graph.new( + type: @replacement_type, + multigraph: true, + vertex_identifier: fn v -> v end, + partition_by: &partitions/1 + ) + |> Graph.add_edge(1, 1.0, label: :flow, properties: %{kind: :numeric}) + |> Graph.add_edge(1.0, :other, label: :keep) + + sg = Graph.subgraph(g, [1, 1.0]) |> assert_index_contract() + assert Graph.num_vertices(sg) == 2 + assert Graph.edge(sg, 1, 1.0, :flow).properties == %{kind: :numeric} + g |> Graph.delete_edge(1, 1.0, :flow) |> assert_index_contract() + g |> Graph.delete_vertex(1) |> assert_index_contract() + end + end + + test "coalescing an undirected vertex reorients indexed edges and their properties" do + g = + graph(:undirected) + |> Graph.add_edge(:a, :b, label: :flow, properties: %{kind: :definition}) + |> Graph.add_vertex(:z) + |> Graph.replace_vertex(:a, :z) + |> assert_index_contract() + + assert %Edge{v1: :b, v2: :z, properties: %{kind: :definition}} = Graph.edge(g, :z, :b, :flow) + assert Graph.edges(g, :b, :z) == Graph.edges(g, :z, :b) + g |> Graph.delete_edge(:z, :b, :flow) |> assert_index_contract() + end + + defp drain_history_messages do + receive do + :visited_history -> drain_history_messages() + after + 0 -> :ok + end + end +end diff --git a/test/multigraph_model_test.exs b/test/multigraph_model_test.exs new file mode 100644 index 0000000..2a74701 --- /dev/null +++ b/test/multigraph_model_test.exs @@ -0,0 +1,343 @@ +defmodule Graph.MultigraphModelTest do + use ExUnit.Case, async: true + use ExUnitProperties + + @moduletag timeout: :infinity + + @labels [:foo, :bar, :baz, :qux, nil] + + test "edge_index is complete: every edge is indexed under its partitions" do + check all(g <- multigraph(), max_runs: 500) do + for edge <- Graph.edges(g) do + partitions = g.partition_by.(edge) + v1_id = g.vertex_identifier.(edge.v1) + v2_id = g.vertex_identifier.(edge.v2) + edge_key = {v1_id, v2_id} + + for partition <- partitions do + partition_map = Map.get(g.edge_index, partition, %{}) + v1_set = Map.get(partition_map, v1_id, MapSet.new()) + v2_set = Map.get(partition_map, v2_id, MapSet.new()) + + assert MapSet.member?(v1_set, edge_key), + "edge #{inspect(edge)} missing from edge_index partition #{inspect(partition)} for v1" + + assert MapSet.member?(v2_set, edge_key), + "edge #{inspect(edge)} missing from edge_index partition #{inspect(partition)} for v2" + end + end + end + end + + test "edge_index is sound: every indexed edge_key exists in edges" do + check all(g <- multigraph(), max_runs: 500) do + for {_partition, vertex_map} <- g.edge_index, + {_v_id, edge_keys} <- vertex_map, + edge_key <- edge_keys do + assert Map.has_key?(g.edges, edge_key), + "stale edge_key #{inspect(edge_key)} in edge_index" + end + end + end + + test "partition filter returns exactly matching edges" do + check all(g <- multigraph(), max_runs: 500) do + for label <- @labels do + indexed = Graph.edges(g, by: [label]) + + scanned = + g + |> Graph.edges() + |> Enum.filter(fn edge -> + label in g.partition_by.(edge) + end) + + assert MapSet.new(indexed) == MapSet.new(scanned), + "by: #{inspect(label)} mismatch: indexed=#{length(indexed)}, scanned=#{length(scanned)}" + end + end + end + + test "index invariant holds after delete_edge" do + check all( + g <- multigraph(min_edges: 2), + max_runs: 500 + ) do + edge = Enum.random(Graph.edges(g)) + g2 = Graph.delete_edge(g, edge.v1, edge.v2, edge.label) + + assert_index_complete(g2) + assert_index_sound(g2) + end + end + + test "index invariant holds after delete_vertex" do + check all( + g <- multigraph(min_vertices: 2), + max_runs: 500 + ) do + vertex = Enum.random(Graph.vertices(g)) + g2 = Graph.delete_vertex(g, vertex) + + assert_index_complete(g2) + assert_index_sound(g2) + end + end + + test "index invariant holds after update_labelled_edge with new label" do + check all( + g <- multigraph(min_edges: 1), + max_runs: 500 + ) do + edge = Enum.random(Graph.edges(g)) + new_label = :updated_label + + case Graph.update_labelled_edge(g, edge.v1, edge.v2, edge.label, label: new_label) do + {:error, _} -> + :ok + + g2 -> + assert_index_complete(g2) + assert_index_sound(g2) + end + end + end + + test "index invariant holds after transpose" do + check all(g <- multigraph(), max_runs: 500) do + gt = Graph.transpose(g) + + assert_index_complete(gt) + assert_index_sound(gt) + end + end + + test "subgraph preserves multigraph and index correctness" do + check all( + g <- multigraph(min_vertices: 2), + max_runs: 500 + ) do + vs = Graph.vertices(g) + subset = Enum.take_random(vs, max(1, div(length(vs), 2))) + sg = Graph.subgraph(g, subset) + + assert sg.multigraph == true + assert_index_complete(sg) + assert_index_sound(sg) + end + end + + test "adding the same edge twice does not create duplicate index entries" do + check all(g <- multigraph(), max_runs: 500) do + for {_partition, vertex_map} <- g.edge_index, + {_v_id, edge_keys} <- vertex_map do + assert MapSet.size(edge_keys) == length(MapSet.to_list(edge_keys)), + "duplicate entries found in edge_index MapSet" + end + end + end + + test "non-multigraph queries are equivalent to multigraph queries" do + check all(g <- multigraph(), max_runs: 500) do + plain = + g + |> Graph.edges() + |> Enum.reduce(Graph.new(), fn edge, acc -> + Graph.add_edge(acc, edge.v1, edge.v2, + label: edge.label, + weight: edge.weight, + properties: edge.properties + ) + end) + + assert MapSet.new(Graph.edges(g)) == MapSet.new(Graph.edges(plain)) + assert MapSet.new(Graph.vertices(g)) == MapSet.new(Graph.vertices(plain)) + end + end + + test "index invariant holds after a sequence of mutations" do + check all( + g <- multigraph(min_vertices: 3, min_edges: 3), + mutations <- list_of(mutation_gen(), min_length: 1, max_length: 10), + max_runs: 300 + ) do + g_final = + Enum.reduce(mutations, g, fn mutation, g -> + apply_mutation(g, mutation) + end) + + assert_index_complete(g_final) + assert_index_sound(g_final) + end + end + + test "index invariant holds after split_edge" do + check all( + g <- multigraph(min_edges: 1), + max_runs: 500 + ) do + edge = Enum.random(Graph.edges(g)) + mid = {:split_mid, :rand.uniform(10_000)} + g2 = Graph.split_edge(g, edge.v1, edge.v2, mid) + + assert_index_complete(g2) + assert_index_sound(g2) + + # Original edge key should not remain in any index entry + v1_id = g2.vertex_identifier.(edge.v1) + v2_id = g2.vertex_identifier.(edge.v2) + + Enum.each(g2.edge_index, fn {_partition, vertex_map} -> + Enum.each(vertex_map, fn {_v_id, edge_keys} -> + refute MapSet.member?(edge_keys, {v1_id, v2_id}) + end) + end) + end + end + + test "BFS with partition filter visits subset of unfiltered BFS" do + check all(g <- multigraph(min_edges: 2), max_runs: 500) do + all_visited = MapSet.new(Graph.Reducers.Bfs.map(g, fn v -> v end)) + + for label <- @labels do + filtered = MapSet.new(Graph.Reducers.Bfs.map(g, fn v -> v end, by: label)) + assert MapSet.subset?(filtered, all_visited) + end + end + end + + test "DFS with partition filter visits subset of unfiltered vertices" do + check all(g <- multigraph(min_edges: 2), max_runs: 500) do + all_vertices = MapSet.new(Graph.vertices(g)) + + for label <- @labels do + filtered = MapSet.new(Graph.Reducers.Dfs.map(g, fn v -> v end, by: label)) + assert MapSet.subset?(filtered, all_vertices) + end + end + end + + test "Dijkstra with partition filter returns path using only filtered edges" do + check all(g <- multigraph(min_edges: 2), max_runs: 300) do + vertices = Graph.vertices(g) + + if length(vertices) >= 2 do + [a, b] = Enum.take_random(vertices, 2) + + for label <- @labels do + case Graph.dijkstra(g, a, b, by: label) do + nil -> + :ok + + path -> + assert hd(path) == a + assert List.last(path) == b + + # Every consecutive pair in the path must have an edge in the partition + path + |> Enum.chunk_every(2, 1, :discard) + |> Enum.each(fn [v1, v2] -> + matching = + Graph.edges(g) + |> Enum.any?(fn edge -> + edge.v1 == v1 and edge.v2 == v2 and label in g.partition_by.(edge) + end) + + assert matching, + "path segment #{inspect(v1)}->#{inspect(v2)} has no #{inspect(label)} edge" + end) + end + end + end + end + end + + ## Helpers + + defp assert_index_complete(g) do + for edge <- Graph.edges(g) do + partitions = g.partition_by.(edge) + v1_id = g.vertex_identifier.(edge.v1) + v2_id = g.vertex_identifier.(edge.v2) + edge_key = {v1_id, v2_id} + + for partition <- partitions do + partition_map = Map.get(g.edge_index, partition, %{}) + v1_set = Map.get(partition_map, v1_id, MapSet.new()) + + assert MapSet.member?(v1_set, edge_key), + "completeness: edge #{inspect(edge)} missing from partition #{inspect(partition)}" + end + end + end + + defp assert_index_sound(g) do + for {_partition, vertex_map} <- g.edge_index, + {_v_id, edge_keys} <- vertex_map, + edge_key <- edge_keys do + assert Map.has_key?(g.edges, edge_key), + "soundness: stale edge_key #{inspect(edge_key)} in edge_index" + end + end + + ## Generators + + defp multigraph(opts \\ []) do + min_vertices = Keyword.get(opts, :min_vertices, 1) + min_edges = Keyword.get(opts, :min_edges, 0) + + gen all( + num_vertices <- integer(max(min_vertices, 2)..10), + num_edges <- integer(max(min_edges, 1)..20), + edges <- list_of(edge_gen(num_vertices), length: num_edges) + ) do + Enum.reduce(edges, Graph.new(multigraph: true), fn {v1, v2, opts}, g -> + Graph.add_edge(g, v1, v2, opts) + end) + end + end + + defp edge_gen(num_vertices) do + gen all( + v1 <- integer(1..num_vertices), + v2 <- integer(1..num_vertices), + label <- member_of(@labels), + weight <- integer(1..10) + ) do + {v1, v2, [label: label, weight: weight]} + end + end + + defp mutation_gen do + one_of([ + tuple( + {constant(:add_edge), integer(1..10), integer(1..10), member_of(@labels), integer(1..10)} + ), + tuple({constant(:delete_edge), integer(1..10), integer(1..10), member_of(@labels)}), + tuple({constant(:delete_vertex), integer(1..10)}), + tuple( + {constant(:update_labelled_edge), integer(1..10), integer(1..10), member_of(@labels), + member_of(@labels)} + ) + ]) + end + + defp apply_mutation(g, {:add_edge, v1, v2, label, weight}) do + Graph.add_edge(g, v1, v2, label: label, weight: weight) + end + + defp apply_mutation(g, {:delete_edge, v1, v2, label}) do + Graph.delete_edge(g, v1, v2, label) + end + + defp apply_mutation(g, {:delete_vertex, v}) do + if Graph.has_vertex?(g, v), do: Graph.delete_vertex(g, v), else: g + end + + defp apply_mutation(g, {:update_labelled_edge, v1, v2, old_label, new_label}) do + case Graph.update_labelled_edge(g, v1, v2, old_label, label: new_label) do + {:error, _} -> g + g2 -> g2 + end + end +end diff --git a/test/multigraph_pathfinding_test.exs b/test/multigraph_pathfinding_test.exs new file mode 100644 index 0000000..3efa520 --- /dev/null +++ b/test/multigraph_pathfinding_test.exs @@ -0,0 +1,50 @@ +defmodule Graph.MultigraphPathfindingTest do + use ExUnit.Case, async: true + + for type <- [:directed, :undirected] do + test "#{type}: filtered shortest paths retain custom vertex identifiers" do + [a, b, c] = Enum.map(1..3, &%{id: {:vertex, &1}}) + + g = + Graph.new(type: unquote(type), multigraph: true, vertex_identifier: & &1.id) + |> Graph.add_edge(a, b, label: :flow, weight: 2) + |> Graph.add_edge(b, c, label: :flow, weight: 3) + |> Graph.add_edge(a, c, label: :history, weight: 1) + + assert Graph.dijkstra(g, a, c, by: :flow) == [a, b, c] + assert Graph.a_star(g, a, c, fn _ -> 0 end, by: :flow) == [a, b, c] + assert Graph.dijkstra(g, a, c) == [a, c] + end + end + + test "filtered Bellman-Ford uses custom identifiers and the cheapest selected parallel edge" do + [a, b, c] = Enum.map(1..3, &%{id: {:vertex, &1}}) + + g = + Graph.new( + multigraph: true, + vertex_identifier: & &1.id, + partition_by: fn edge -> [edge.properties.kind] end + ) + |> Graph.add_edge(a, b, label: :expensive, weight: 8, properties: %{kind: :flow}) + |> Graph.add_edge(a, b, label: :cheap, weight: 2, properties: %{kind: :flow}) + |> Graph.add_edge(b, c, weight: 3, properties: %{kind: :flow}) + |> Graph.add_edge(a, c, weight: 1, properties: %{kind: :history}) + + assert Graph.bellman_ford(g, a, by: :flow) == %{a => 0, b => 2, c => 5} + assert Graph.bellman_ford(g, a) == %{a => 0, b => 2, c => 1} + end + + test "undirected reverse traversal resolves properties from the stored edge orientation" do + g = + Graph.new( + type: :undirected, + multigraph: true, + partition_by: fn edge -> [Map.get(edge.properties, :kind)] end + ) + |> Graph.add_edge(:a, :b, properties: %{kind: :flow}) + |> Graph.add_edge(:b, :c, properties: %{kind: :flow}) + + assert Graph.dijkstra(g, :c, :a, by: :flow) == [:c, :b, :a] + end +end