From a96b5852de672f59bc4fc293ff14f2f7a7e7faca Mon Sep 17 00:00:00 2001 From: zblanco Date: Sun, 18 Aug 2024 17:28:10 -0600 Subject: [PATCH 01/22] approach multigraph indexing with edge properties --- .gitignore | 1 + lib/edge.ex | 9 ++++++--- lib/graph.ex | 45 +++++++++++++++++++++++++++++++++++++++++---- lib/graph/utils.ex | 2 ++ test/graph_test.exs | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 7 deletions(-) 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/lib/edge.ex b/lib/edge.ex index 4563ccc..d6cec16 100644 --- a/lib/edge.ex +++ b/lib/edge.ex @@ -8,13 +8,15 @@ 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} @@ -40,7 +42,8 @@ defmodule Graph.Edge do v1: v1, v2: v2, weight: Keyword.get(opts, :weight, 1), - label: Keyword.get(opts, :label) + label: Keyword.get(opts, :label), + properties: Keyword.get(opts, :properties, %{}) } end diff --git a/lib/graph.ex b/lib/graph.ex index b66670f..7e25fd3 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -28,10 +28,13 @@ defmodule Graph do defstruct in_edges: %{}, out_edges: %{}, edges: %{}, + edge_index: %{}, vertex_labels: %{}, vertices: %{}, type: :directed, - vertex_identifier: &Graph.Utils.vertex_id/1 + vertex_identifier: &Graph.Utils.vertex_id/1, + edge_indexer: &Graph.Utils.edge_label/1, + multigraph: false alias Graph.{Edge, EdgeSpecificationError} @@ -43,17 +46,26 @@ defmodule Graph do @type label :: term @type edge_weight :: integer | float @type edge_key :: {vertex_id, vertex_id} - @type edge_value :: %{label => edge_weight} + # @type edge_value :: %{label => edge_weight} + @type edge_index_key :: label | term + @type edge_properties :: %{ + label: label, + weight: edge_weight + } + @type edge_value :: %{label => edge_properties()} @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 => MapSet.t()}, vertex_labels: %{vertex_id => term}, vertices: %{vertex_id => vertex}, type: graph_type, - vertex_identifier: (vertex() -> term()) + vertex_identifier: (vertex() -> term()), + edge_indexer: (Edge.t() -> edge_index_key), + multigraph: boolean() } @type graph_info :: %{ :num_edges => non_neg_integer(), @@ -70,6 +82,23 @@ 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 `Graph.Utils.vertex_id/1`, a hash of the whole vertex utilizing `:erlang.phash2/2`. + - `multigraph: true | false | fn edge -> key end`, enables edge indexing by a key. + - When `true`, the key is the edge label itself. + - When `false` no additional memory is used for sets of . + - `edge_indexer`: a function which accepts an `%Edge{}` and returns a unique identifier of said edge. + Defaults to `Graph.Utils.edge_label/1`, the edge label itself. + + ### Multigraph Edge Indexing + + Indexing edges trades space for time to access only edges of a kind. + + When `multigraph: true` is enabled the `edge_indexer` of the graph is used to build a a set of edge keys (`{vertex_id, vertex_id}`) under a separate key. + + This can be a useful trade-off when traversing a graph where many different kinds of edges exist between the same vertices and + you want to avoid iterating over the set of all edges. I.e. a [multigraph](https://en.wikipedia.org/wiki/Multigraph). + The index provides allows map access time to to a set of edges when managing the graph. + + By default edges are indexed by the label but only when multigraph is toggled true. ## Example @@ -91,7 +120,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} + edge_indexer = Keyword.get(opts, :edge_indexer) || (&Graph.Utils.edge_label/1) + multigraph = Keyword.get(opts, :multigraph, false) + + %__MODULE__{ + type: type, + vertex_identifier: vertex_identifier, + edge_indexer: edge_indexer, + multigraph: multigraph + } end @doc """ diff --git a/lib/graph/utils.ex b/lib/graph/utils.ex index f53b987..b5a8f5d 100644 --- a/lib/graph/utils.ex +++ b/lib/graph/utils.ex @@ -110,4 +110,6 @@ defmodule Graph.Utils do # 2^32 @max_phash 4_294_967_296 def vertex_id(v), do: :erlang.phash2(v, @max_phash) + + def edge_label(%{label: label}), do: label end diff --git a/test/graph_test.exs b/test/graph_test.exs index 2b73f02..63cb97c 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -20,6 +20,43 @@ defmodule GraphTest do assert Graph.has_vertex?(g_with_custom_vertex_identifier, :v1) end + describe "multigraphs" do + test "`multigraph: true` option enables vertex 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, :foo) + assert [%Edge{label: :foo}] = Graph.in_edges(graph, :b, :foo) + assert [%Edge{label: :bar}] = Graph.out_edges(graph, :a, :bar) + assert [%Edge{label: :nil}] = Graph.out_edges(graph, :a, nil) + assert [] == Graph.out_edges(graph, :a, :foobar) + end + + test "custom vertex indexing function on edge labels" do + + end + + test "traversal using indexed labels" do + + end + + test "updating edge properties" do + + end + + test "removing edges" do + + end + end + test "delete vertex" do g = Graph.new() g = Graph.add_vertex(g, :v1, :labelA) From 3b5d4c465a814c212742530ddd481331d1975e8c Mon Sep 17 00:00:00 2001 From: Zack White Date: Mon, 26 Aug 2024 19:55:53 -0600 Subject: [PATCH 02/22] wip --- .formatter.exs | 0 .github/workflows/elixir.yml | 0 .gitignore | 0 .travis.yml | 0 LICENSE | 0 Makefile | 0 README.md | 0 bench/cliques.exs | 0 bench/create.exs | 0 bench/k_core.exs | 0 bench/shortest_path.exs | 0 bench/topsort.exs | 0 coveralls.json | 0 lib/edge.ex | 17 ++-- lib/graph.ex | 84 ++++++++++++++++--- lib/graph/directed.ex | 0 lib/graph/edge_specification_error.ex | 0 lib/graph/inspect.ex | 0 lib/graph/pathfinding.ex | 3 +- lib/graph/pathfindings/bellman_ford.ex | 0 lib/graph/reducer.ex | 0 lib/graph/reducers/bfs.ex | 0 lib/graph/reducers/dfs.ex | 0 lib/graph/serializer.ex | 0 lib/graph/serializers/dot.ex | 4 +- lib/graph/serializers/edgelist.ex | 0 lib/graph/serializers/flowchart.ex | 0 lib/graph/utils.ex | 6 +- lib/priority_queue.ex | 0 mix.exs | 0 mix.lock | 0 test/fixtures/email-Enron.txt | 0 .../README.petster-friendships-hamster | 0 test/fixtures/petster/edges.txt | 0 test/fixtures/petster/metadata.txt | 0 test/fixtures/petster/vertices.txt | 0 test/graph_test.exs | 26 ++++-- test/model_test.exs | 0 test/priority_queue_test.exs | 0 test/reducer_test.exs | 24 +++--- test/serializer_test.exs | 0 test/support/generators.ex | 0 test/support/parser.ex | 0 test/test_helper.exs | 0 test/utils_test.exs | 0 45 files changed, 122 insertions(+), 42 deletions(-) mode change 100644 => 100755 .formatter.exs mode change 100644 => 100755 .github/workflows/elixir.yml mode change 100644 => 100755 .gitignore mode change 100644 => 100755 .travis.yml mode change 100644 => 100755 LICENSE mode change 100644 => 100755 Makefile mode change 100644 => 100755 README.md mode change 100644 => 100755 bench/cliques.exs mode change 100644 => 100755 bench/create.exs mode change 100644 => 100755 bench/k_core.exs mode change 100644 => 100755 bench/shortest_path.exs mode change 100644 => 100755 bench/topsort.exs mode change 100644 => 100755 coveralls.json mode change 100644 => 100755 lib/edge.ex mode change 100644 => 100755 lib/graph.ex mode change 100644 => 100755 lib/graph/directed.ex mode change 100644 => 100755 lib/graph/edge_specification_error.ex mode change 100644 => 100755 lib/graph/inspect.ex mode change 100644 => 100755 lib/graph/pathfinding.ex mode change 100644 => 100755 lib/graph/pathfindings/bellman_ford.ex mode change 100644 => 100755 lib/graph/reducer.ex mode change 100644 => 100755 lib/graph/reducers/bfs.ex mode change 100644 => 100755 lib/graph/reducers/dfs.ex mode change 100644 => 100755 lib/graph/serializer.ex mode change 100644 => 100755 lib/graph/serializers/dot.ex mode change 100644 => 100755 lib/graph/serializers/edgelist.ex mode change 100644 => 100755 lib/graph/serializers/flowchart.ex mode change 100644 => 100755 lib/graph/utils.ex mode change 100644 => 100755 lib/priority_queue.ex mode change 100644 => 100755 mix.exs mode change 100644 => 100755 mix.lock mode change 100644 => 100755 test/fixtures/email-Enron.txt mode change 100644 => 100755 test/fixtures/petster/README.petster-friendships-hamster mode change 100644 => 100755 test/fixtures/petster/edges.txt mode change 100644 => 100755 test/fixtures/petster/metadata.txt mode change 100644 => 100755 test/fixtures/petster/vertices.txt mode change 100644 => 100755 test/graph_test.exs mode change 100644 => 100755 test/model_test.exs mode change 100644 => 100755 test/priority_queue_test.exs mode change 100644 => 100755 test/reducer_test.exs mode change 100644 => 100755 test/serializer_test.exs mode change 100644 => 100755 test/support/generators.ex mode change 100644 => 100755 test/support/parser.ex mode change 100644 => 100755 test/test_helper.exs mode change 100644 => 100755 test/utils_test.exs diff --git a/.formatter.exs b/.formatter.exs old mode 100644 new mode 100755 diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/.travis.yml b/.travis.yml old mode 100644 new mode 100755 diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/Makefile b/Makefile old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/bench/cliques.exs b/bench/cliques.exs old mode 100644 new mode 100755 diff --git a/bench/create.exs b/bench/create.exs old mode 100644 new mode 100755 diff --git a/bench/k_core.exs b/bench/k_core.exs old mode 100644 new mode 100755 diff --git a/bench/shortest_path.exs b/bench/shortest_path.exs old mode 100644 new mode 100755 diff --git a/bench/topsort.exs b/bench/topsort.exs old mode 100644 new mode 100755 diff --git a/coveralls.json b/coveralls.json old mode 100644 new mode 100755 diff --git a/lib/edge.ex b/lib/edge.ex old mode 100644 new mode 100755 index d6cec16..c1908ac --- a/lib/edge.ex +++ b/lib/edge.ex @@ -21,6 +21,7 @@ defmodule Graph.Edge do @type edge_opt :: {:weight, integer | float} | {:label, term} + | {:properties, map} @type edge_opts :: [edge_opt] @doc """ @@ -38,11 +39,14 @@ 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 @@ -51,12 +55,13 @@ defmodule Graph.Edge do def options_to_meta(opts) when is_list(opts) do label = Keyword.get(opts, :label) weight = Keyword.get(opts, :weight, 1) + properties = Keyword.get(opts, :properties, %{}) - case {label, weight} do - {_, w} = meta when is_number(w) -> - meta + case {label, %{weight: weight, properties: properties}} do + {label, %{weight: w} = meta} when is_number(w) -> + {label, meta} - {_, _} -> + _ -> raise ArgumentError, message: "invalid value for :weight, must be an integer" end end diff --git a/lib/graph.ex b/lib/graph.ex old mode 100644 new mode 100755 index 7e25fd3..8349484 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -50,7 +50,8 @@ defmodule Graph do @type edge_index_key :: label | term @type edge_properties :: %{ label: label, - weight: edge_weight + weight: edge_weight, + properties: map } @type edge_value :: %{label => edge_properties()} @type graph_type :: :directed | :undirected @@ -509,8 +510,12 @@ defmodule Graph do edge_meta when is_map(edge_meta) -> v2 = Map.get(vs, v2_id) - for {label, weight} <- edge_meta do - Edge.new(v2, v, label: label, weight: weight) + for {label, meta_value} <- edge_meta do + Edge.new(v2, v, + label: label, + weight: meta_value.weight, + properties: meta_value.properties + ) end end end) @@ -524,8 +529,12 @@ defmodule Graph do edge_meta when is_map(edge_meta) -> v2 = Map.get(vs, v2_id) - for {label, weight} <- edge_meta do - Edge.new(v, v2, label: label, weight: weight) + for {label, meta_value} <- edge_meta do + Edge.new(v2, v, + label: label, + weight: meta_value.weight, + properties: meta_value.properties + ) end end end) @@ -627,8 +636,8 @@ defmodule Graph do 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) + {:ok, %{weight: weight, properties: properties}} <- Map.fetch(edge_meta, label) do + Edge.new(v1, v2, label: label, weight: weight, properties: properties) else _ -> nil @@ -1034,8 +1043,24 @@ defmodule Graph do end edge_meta = Map.get(meta, {v1_id, v2_id}, %{}) - {label, weight} = Edge.options_to_meta(opts) - edge_meta = Map.put(edge_meta, label, weight) + {label, options_meta} = Edge.options_to_meta(opts) + edge_meta = Map.put(edge_meta, label, options_meta) + + g = + if g.multigraph do + edge = Edge.new(v1, v2, label: label, weight: options_meta.weight, properties: opts) + + partition = g.edge_indexer.(edge) + key = {v1_id, partition} + set = Map.get(g.edge_index, key, MapSet.new()) + + %__MODULE__{ + g + | edge_index: Map.put(g.edge_index, key, MapSet.put(set, {v1_id, v2_id})) + } + else + g + end %__MODULE__{ g @@ -1050,7 +1075,7 @@ defmodule Graph do 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. @@ -2242,8 +2267,12 @@ defmodule Graph do Enum.flat_map(v_out, fn v2_id -> v2 = Map.get(vs, 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, {v_id, v2_id}), fn {label, edge_meta} -> + Edge.new(v, v2, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) end) end) else @@ -2252,6 +2281,37 @@ defmodule Graph do end end + def out_edges( + %__MODULE__{ + vertices: vs, + edges: edges, + multigraph: true, + edge_index: edge_index, + vertex_identifier: vertex_identifier, + edge_indexer: edge_indexer + }, + v, + partition + ) do + v1_id = vertex_identifier.(v) + key = {v1_id, partition} + # only return out_edges for which the index key returns a subset + edge_index + |> Map.get(key, MapSet.new()) + |> Enum.flat_map(fn {_v1_id, v2_id} = edge_key -> + v2 = Map.get(vs, v2_id) + + edges + |> Map.get(edge_key, []) + |> Enum.map(fn {label, edge_meta} -> + Edge.new(v, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) + end) + |> Enum.filter(fn edge -> + edge_indexer.(edge) == partition + end) + end) + end + @doc """ Builds a maximal subgraph of `g` which includes all of the vertices in `vs` and the edges which connect them. diff --git a/lib/graph/directed.ex b/lib/graph/directed.ex old mode 100644 new mode 100755 diff --git a/lib/graph/edge_specification_error.ex b/lib/graph/edge_specification_error.ex old mode 100644 new mode 100755 diff --git a/lib/graph/inspect.ex b/lib/graph/inspect.ex old mode 100644 new mode 100755 diff --git a/lib/graph/pathfinding.ex b/lib/graph/pathfinding.ex old mode 100644 new mode 100755 index c7f7fa6..b21724a --- a/lib/graph/pathfinding.ex +++ b/lib/graph/pathfinding.ex @@ -6,7 +6,8 @@ defmodule Graph.Pathfinding do @type heuristic_fun :: (Graph.vertex() -> integer) - @spec bellman_ford(Graph.t, Graph.vertex) :: %{Graph.vertex() => integer() | :infinity} | nil + @spec bellman_ford(Graph.t(), Graph.vertex()) :: + %{Graph.vertex() => integer() | :infinity} | nil def bellman_ford(g, a), do: Graph.Pathfindings.BellmanFord.call(g, a) @doc """ diff --git a/lib/graph/pathfindings/bellman_ford.ex b/lib/graph/pathfindings/bellman_ford.ex old mode 100644 new mode 100755 diff --git a/lib/graph/reducer.ex b/lib/graph/reducer.ex old mode 100644 new mode 100755 diff --git a/lib/graph/reducers/bfs.ex b/lib/graph/reducers/bfs.ex old mode 100644 new mode 100755 diff --git a/lib/graph/reducers/dfs.ex b/lib/graph/reducers/dfs.ex old mode 100644 new mode 100755 diff --git a/lib/graph/serializer.ex b/lib/graph/serializer.ex old mode 100644 new mode 100755 diff --git a/lib/graph/serializers/dot.ex b/lib/graph/serializers/dot.ex old mode 100644 new mode 100755 index 0df88c7..2266795 --- a/lib/graph/serializers/dot.ex +++ b/lib/graph/serializers/dot.ex @@ -14,7 +14,9 @@ defmodule Graph.Serializers.DOT do defp serialize_nodes(%Graph{vertices: vertices} = g) do Enum.reduce(vertices, "", fn {id, v}, acc -> - acc <> Serializer.indent(1) <> "#{id}" <> "[label=" <> Serializer.get_vertex_label(g, id, v) <> "]\n" + acc <> + Serializer.indent(1) <> + "#{id}" <> "[label=" <> Serializer.get_vertex_label(g, id, v) <> "]\n" end) end diff --git a/lib/graph/serializers/edgelist.ex b/lib/graph/serializers/edgelist.ex old mode 100644 new mode 100755 diff --git a/lib/graph/serializers/flowchart.ex b/lib/graph/serializers/flowchart.ex old mode 100644 new mode 100755 diff --git a/lib/graph/utils.ex b/lib/graph/utils.ex old mode 100644 new mode 100755 index b5a8f5d..2880961 --- a/lib/graph/utils.ex +++ b/lib/graph/utils.ex @@ -83,7 +83,7 @@ defmodule Graph.Utils do def edge_weight(%Graph{type: :directed, edges: meta}, a, b) do Map.fetch!(meta, {a, b}) - |> Enum.map(fn {_label, weight} -> weight end) + |> Enum.map(fn {_label, %{weight: weight}} -> weight end) |> Enum.min() end @@ -96,13 +96,13 @@ defmodule Graph.Utils do edge_meta when is_map(edge_meta) -> edge_meta - |> Enum.map(fn {_label, weight} -> weight end) + |> Enum.map(fn {_label, %{weight: weight}} -> weight end) |> Enum.min() end edge_meta when is_map(edge_meta) -> edge_meta - |> Enum.map(fn {_label, weight} -> weight end) + |> Enum.map(fn {_label, %{weight: weight}} -> weight end) |> Enum.min() end end diff --git a/lib/priority_queue.ex b/lib/priority_queue.ex old mode 100644 new mode 100755 diff --git a/mix.exs b/mix.exs old mode 100644 new mode 100755 diff --git a/mix.lock b/mix.lock old mode 100644 new mode 100755 diff --git a/test/fixtures/email-Enron.txt b/test/fixtures/email-Enron.txt old mode 100644 new mode 100755 diff --git a/test/fixtures/petster/README.petster-friendships-hamster b/test/fixtures/petster/README.petster-friendships-hamster old mode 100644 new mode 100755 diff --git a/test/fixtures/petster/edges.txt b/test/fixtures/petster/edges.txt old mode 100644 new mode 100755 diff --git a/test/fixtures/petster/metadata.txt b/test/fixtures/petster/metadata.txt old mode 100644 new mode 100755 diff --git a/test/fixtures/petster/vertices.txt b/test/fixtures/petster/vertices.txt old mode 100644 new mode 100755 diff --git a/test/graph_test.exs b/test/graph_test.exs old mode 100644 new mode 100755 index 63cb97c..e1a9b00 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -31,29 +31,39 @@ defmodule GraphTest do {:b, :c, weight: 3}, {:b, :a, label: {:complex, :label}} ]) + |> IO.inspect(structs: false) assert Enum.count(Graph.out_edges(graph, :a)) == 3 assert [%Edge{label: :foo}] = Graph.out_edges(graph, :a, :foo) assert [%Edge{label: :foo}] = Graph.in_edges(graph, :b, :foo) assert [%Edge{label: :bar}] = Graph.out_edges(graph, :a, :bar) - assert [%Edge{label: :nil}] = Graph.out_edges(graph, :a, nil) + assert [%Edge{label: nil}] = Graph.out_edges(graph, :a, nil) assert [] == Graph.out_edges(graph, :a, :foobar) end - test "custom vertex indexing function on edge labels" do + test "custom edge indexing function" do + graph = + Graph.new(multigraph: true, edge_indexer: 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, 6) + assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, 3) end - test "traversal using indexed labels" do - + test "traversal using indexed keys" do end - test "updating edge properties" do - + test "edge properties" do end test "removing edges" do - end end @@ -673,7 +683,7 @@ defmodule GraphTest do end defp build_complex_signed_graph do - Graph.new + Graph.new() |> Graph.add_edge(:a, :b, weight: -1) |> Graph.add_edge(:b, :e, weight: 2) |> Graph.add_edge(:e, :d, weight: -3) diff --git a/test/model_test.exs b/test/model_test.exs old mode 100644 new mode 100755 diff --git a/test/priority_queue_test.exs b/test/priority_queue_test.exs old mode 100644 new mode 100755 diff --git a/test/reducer_test.exs b/test/reducer_test.exs old mode 100644 new mode 100755 index 713e32f..428024f --- a/test/reducer_test.exs +++ b/test/reducer_test.exs @@ -35,17 +35,19 @@ defmodule Graph.Reducer.Test do end test "can walk a graph breadth-first, when the starting points had their in-edges deleted" do - g = Graph.new - |> Graph.add_vertices([:a, :b, :c, :d, :e, :f, :g]) - |> Graph.add_edge(:a, :b) - |> Graph.add_edge(:a, :d) - |> Graph.add_edge(:b, :c) - |> Graph.add_edge(:b, :d) - |> Graph.add_edge(:c, :e) - |> Graph.add_edge(:d, :f) - |> Graph.add_edge(:f, :g) - |> Graph.add_edge(:b, :a) # Add this edge and then remove it - |> Graph.delete_edge(:b, :a) + g = + Graph.new() + |> Graph.add_vertices([:a, :b, :c, :d, :e, :f, :g]) + |> Graph.add_edge(:a, :b) + |> Graph.add_edge(:a, :d) + |> Graph.add_edge(:b, :c) + |> Graph.add_edge(:b, :d) + |> Graph.add_edge(:c, :e) + |> Graph.add_edge(:d, :f) + |> Graph.add_edge(:f, :g) + # Add this edge and then remove it + |> Graph.add_edge(:b, :a) + |> Graph.delete_edge(:b, :a) expected = [:a, :b, :d, :c, :f, :e, :g] assert ^expected = Graph.Reducers.Bfs.map(g, fn v -> v end) diff --git a/test/serializer_test.exs b/test/serializer_test.exs old mode 100644 new mode 100755 diff --git a/test/support/generators.ex b/test/support/generators.ex old mode 100644 new mode 100755 diff --git a/test/support/parser.ex b/test/support/parser.ex old mode 100644 new mode 100755 diff --git a/test/test_helper.exs b/test/test_helper.exs old mode 100644 new mode 100755 diff --git a/test/utils_test.exs b/test/utils_test.exs old mode 100644 new mode 100755 From 8a23cd3fa8e886f2a9d7bf4027d6aaf1d1ca6595 Mon Sep 17 00:00:00 2001 From: Zack White Date: Mon, 26 Aug 2024 20:07:46 -0600 Subject: [PATCH 03/22] wip --- .gitignore | 0 lib/edge.ex | 17 +++----- lib/graph.ex | 84 ++++++------------------------------ lib/graph/pathfinding.ex | 3 +- lib/graph/serializers/dot.ex | 4 +- lib/graph/utils.ex | 6 +-- test/graph_test.exs | 26 ++++------- test/reducer_test.exs | 24 +++++------ 8 files changed, 42 insertions(+), 122 deletions(-) mode change 100755 => 100644 .gitignore mode change 100755 => 100644 lib/edge.ex mode change 100755 => 100644 lib/graph.ex mode change 100755 => 100644 lib/graph/pathfinding.ex mode change 100755 => 100644 lib/graph/serializers/dot.ex mode change 100755 => 100644 lib/graph/utils.ex mode change 100755 => 100644 test/graph_test.exs mode change 100755 => 100644 test/reducer_test.exs diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 diff --git a/lib/edge.ex b/lib/edge.ex old mode 100755 new mode 100644 index c1908ac..d6cec16 --- a/lib/edge.ex +++ b/lib/edge.ex @@ -21,7 +21,6 @@ defmodule Graph.Edge do @type edge_opt :: {:weight, integer | float} | {:label, term} - | {:properties, map} @type edge_opts :: [edge_opt] @doc """ @@ -39,14 +38,11 @@ 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: weight, - label: label, + weight: Keyword.get(opts, :weight, 1), + label: Keyword.get(opts, :label), properties: Keyword.get(opts, :properties, %{}) } end @@ -55,13 +51,12 @@ defmodule Graph.Edge do def options_to_meta(opts) when is_list(opts) do label = Keyword.get(opts, :label) weight = Keyword.get(opts, :weight, 1) - properties = Keyword.get(opts, :properties, %{}) - case {label, %{weight: weight, properties: properties}} do - {label, %{weight: w} = meta} when is_number(w) -> - {label, meta} + case {label, weight} do + {_, w} = meta when is_number(w) -> + meta - _ -> + {_, _} -> raise ArgumentError, message: "invalid value for :weight, must be an integer" end end diff --git a/lib/graph.ex b/lib/graph.ex old mode 100755 new mode 100644 index 8349484..7e25fd3 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -50,8 +50,7 @@ defmodule Graph do @type edge_index_key :: label | term @type edge_properties :: %{ label: label, - weight: edge_weight, - properties: map + weight: edge_weight } @type edge_value :: %{label => edge_properties()} @type graph_type :: :directed | :undirected @@ -510,12 +509,8 @@ defmodule Graph do edge_meta when is_map(edge_meta) -> v2 = Map.get(vs, v2_id) - for {label, meta_value} <- edge_meta do - Edge.new(v2, v, - label: label, - weight: meta_value.weight, - properties: meta_value.properties - ) + for {label, weight} <- edge_meta do + Edge.new(v2, v, label: label, weight: weight) end end end) @@ -529,12 +524,8 @@ defmodule Graph do edge_meta when is_map(edge_meta) -> v2 = Map.get(vs, v2_id) - for {label, meta_value} <- edge_meta do - Edge.new(v2, v, - label: label, - weight: meta_value.weight, - properties: meta_value.properties - ) + for {label, weight} <- edge_meta do + Edge.new(v, v2, label: label, weight: weight) end end end) @@ -636,8 +627,8 @@ defmodule Graph do v2_id <- vertex_identifier.(v2), edge_key <- {v1_id, v2_id}, {:ok, edge_meta} <- Map.fetch(meta, edge_key), - {:ok, %{weight: weight, properties: properties}} <- Map.fetch(edge_meta, label) do - Edge.new(v1, v2, label: label, weight: weight, properties: properties) + {:ok, weight} <- Map.fetch(edge_meta, label) do + Edge.new(v1, v2, label: label, weight: weight) else _ -> nil @@ -1043,24 +1034,8 @@ defmodule Graph do end edge_meta = Map.get(meta, {v1_id, v2_id}, %{}) - {label, options_meta} = Edge.options_to_meta(opts) - edge_meta = Map.put(edge_meta, label, options_meta) - - g = - if g.multigraph do - edge = Edge.new(v1, v2, label: label, weight: options_meta.weight, properties: opts) - - partition = g.edge_indexer.(edge) - key = {v1_id, partition} - set = Map.get(g.edge_index, key, MapSet.new()) - - %__MODULE__{ - g - | edge_index: Map.put(g.edge_index, key, MapSet.put(set, {v1_id, v2_id})) - } - else - g - end + {label, weight} = Edge.options_to_meta(opts) + edge_meta = Map.put(edge_meta, label, weight) %__MODULE__{ g @@ -1075,7 +1050,7 @@ defmodule Graph do 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, properties: map]}`. + `{vertex, vertex, edge_opts :: [label: term, weight: integer]}`. 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. @@ -2267,12 +2242,8 @@ defmodule Graph do Enum.flat_map(v_out, fn v2_id -> v2 = Map.get(vs, v2_id) - Enum.map(Map.get(meta, {v_id, v2_id}), fn {label, edge_meta} -> - Edge.new(v, v2, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) + Enum.map(Map.get(meta, {v_id, v2_id}), fn {label, weight} -> + Edge.new(v, v2, label: label, weight: weight) end) end) else @@ -2281,37 +2252,6 @@ defmodule Graph do end end - def out_edges( - %__MODULE__{ - vertices: vs, - edges: edges, - multigraph: true, - edge_index: edge_index, - vertex_identifier: vertex_identifier, - edge_indexer: edge_indexer - }, - v, - partition - ) do - v1_id = vertex_identifier.(v) - key = {v1_id, partition} - # only return out_edges for which the index key returns a subset - edge_index - |> Map.get(key, MapSet.new()) - |> Enum.flat_map(fn {_v1_id, v2_id} = edge_key -> - v2 = Map.get(vs, v2_id) - - edges - |> Map.get(edge_key, []) - |> Enum.map(fn {label, edge_meta} -> - Edge.new(v, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) - end) - |> Enum.filter(fn edge -> - edge_indexer.(edge) == partition - end) - end) - end - @doc """ Builds a maximal subgraph of `g` which includes all of the vertices in `vs` and the edges which connect them. diff --git a/lib/graph/pathfinding.ex b/lib/graph/pathfinding.ex old mode 100755 new mode 100644 index b21724a..c7f7fa6 --- a/lib/graph/pathfinding.ex +++ b/lib/graph/pathfinding.ex @@ -6,8 +6,7 @@ defmodule Graph.Pathfinding do @type heuristic_fun :: (Graph.vertex() -> integer) - @spec bellman_ford(Graph.t(), Graph.vertex()) :: - %{Graph.vertex() => integer() | :infinity} | nil + @spec bellman_ford(Graph.t, Graph.vertex) :: %{Graph.vertex() => integer() | :infinity} | nil def bellman_ford(g, a), do: Graph.Pathfindings.BellmanFord.call(g, a) @doc """ diff --git a/lib/graph/serializers/dot.ex b/lib/graph/serializers/dot.ex old mode 100755 new mode 100644 index 2266795..0df88c7 --- a/lib/graph/serializers/dot.ex +++ b/lib/graph/serializers/dot.ex @@ -14,9 +14,7 @@ defmodule Graph.Serializers.DOT do defp serialize_nodes(%Graph{vertices: vertices} = g) do Enum.reduce(vertices, "", fn {id, v}, acc -> - acc <> - Serializer.indent(1) <> - "#{id}" <> "[label=" <> Serializer.get_vertex_label(g, id, v) <> "]\n" + acc <> Serializer.indent(1) <> "#{id}" <> "[label=" <> Serializer.get_vertex_label(g, id, v) <> "]\n" end) end diff --git a/lib/graph/utils.ex b/lib/graph/utils.ex old mode 100755 new mode 100644 index 2880961..b5a8f5d --- a/lib/graph/utils.ex +++ b/lib/graph/utils.ex @@ -83,7 +83,7 @@ defmodule Graph.Utils do def edge_weight(%Graph{type: :directed, edges: meta}, a, b) do Map.fetch!(meta, {a, b}) - |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> Enum.map(fn {_label, weight} -> weight end) |> Enum.min() end @@ -96,13 +96,13 @@ defmodule Graph.Utils do edge_meta when is_map(edge_meta) -> edge_meta - |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> Enum.map(fn {_label, weight} -> weight end) |> Enum.min() end edge_meta when is_map(edge_meta) -> edge_meta - |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> Enum.map(fn {_label, weight} -> weight end) |> Enum.min() end end diff --git a/test/graph_test.exs b/test/graph_test.exs old mode 100755 new mode 100644 index e1a9b00..63cb97c --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -31,39 +31,29 @@ defmodule GraphTest do {:b, :c, weight: 3}, {:b, :a, label: {:complex, :label}} ]) - |> IO.inspect(structs: false) assert Enum.count(Graph.out_edges(graph, :a)) == 3 assert [%Edge{label: :foo}] = Graph.out_edges(graph, :a, :foo) assert [%Edge{label: :foo}] = Graph.in_edges(graph, :b, :foo) assert [%Edge{label: :bar}] = Graph.out_edges(graph, :a, :bar) - assert [%Edge{label: nil}] = Graph.out_edges(graph, :a, nil) + assert [%Edge{label: :nil}] = Graph.out_edges(graph, :a, nil) assert [] == Graph.out_edges(graph, :a, :foobar) end - test "custom edge indexing function" do - graph = - Graph.new(multigraph: true, edge_indexer: 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} - ]) + test "custom vertex indexing function on edge labels" do - assert Enum.count(Graph.out_edges(graph, :b)) == 2 - assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, 6) - assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, 3) end - test "traversal using indexed keys" do + test "traversal using indexed labels" do + end - test "edge properties" do + test "updating edge properties" do + end test "removing edges" do + end end @@ -683,7 +673,7 @@ defmodule GraphTest do end defp build_complex_signed_graph do - Graph.new() + Graph.new |> Graph.add_edge(:a, :b, weight: -1) |> Graph.add_edge(:b, :e, weight: 2) |> Graph.add_edge(:e, :d, weight: -3) diff --git a/test/reducer_test.exs b/test/reducer_test.exs old mode 100755 new mode 100644 index 428024f..713e32f --- a/test/reducer_test.exs +++ b/test/reducer_test.exs @@ -35,19 +35,17 @@ defmodule Graph.Reducer.Test do end test "can walk a graph breadth-first, when the starting points had their in-edges deleted" do - g = - Graph.new() - |> Graph.add_vertices([:a, :b, :c, :d, :e, :f, :g]) - |> Graph.add_edge(:a, :b) - |> Graph.add_edge(:a, :d) - |> Graph.add_edge(:b, :c) - |> Graph.add_edge(:b, :d) - |> Graph.add_edge(:c, :e) - |> Graph.add_edge(:d, :f) - |> Graph.add_edge(:f, :g) - # Add this edge and then remove it - |> Graph.add_edge(:b, :a) - |> Graph.delete_edge(:b, :a) + g = Graph.new + |> Graph.add_vertices([:a, :b, :c, :d, :e, :f, :g]) + |> Graph.add_edge(:a, :b) + |> Graph.add_edge(:a, :d) + |> Graph.add_edge(:b, :c) + |> Graph.add_edge(:b, :d) + |> Graph.add_edge(:c, :e) + |> Graph.add_edge(:d, :f) + |> Graph.add_edge(:f, :g) + |> Graph.add_edge(:b, :a) # Add this edge and then remove it + |> Graph.delete_edge(:b, :a) expected = [:a, :b, :d, :c, :f, :e, :g] assert ^expected = Graph.Reducers.Bfs.map(g, fn v -> v end) From 937494722efdddefffe79a99a49e9f20cd912ad1 Mon Sep 17 00:00:00 2001 From: Zack White Date: Mon, 26 Aug 2024 20:09:08 -0600 Subject: [PATCH 04/22] Revert "wip" This reverts commit 8a23cd3fa8e886f2a9d7bf4027d6aaf1d1ca6595. --- .gitignore | 0 lib/edge.ex | 17 +++++--- lib/graph.ex | 84 ++++++++++++++++++++++++++++++------ lib/graph/pathfinding.ex | 3 +- lib/graph/serializers/dot.ex | 4 +- lib/graph/utils.ex | 6 +-- test/graph_test.exs | 26 +++++++---- test/reducer_test.exs | 24 ++++++----- 8 files changed, 122 insertions(+), 42 deletions(-) mode change 100644 => 100755 .gitignore mode change 100644 => 100755 lib/edge.ex mode change 100644 => 100755 lib/graph.ex mode change 100644 => 100755 lib/graph/pathfinding.ex mode change 100644 => 100755 lib/graph/serializers/dot.ex mode change 100644 => 100755 lib/graph/utils.ex mode change 100644 => 100755 test/graph_test.exs mode change 100644 => 100755 test/reducer_test.exs diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/lib/edge.ex b/lib/edge.ex old mode 100644 new mode 100755 index d6cec16..c1908ac --- a/lib/edge.ex +++ b/lib/edge.ex @@ -21,6 +21,7 @@ defmodule Graph.Edge do @type edge_opt :: {:weight, integer | float} | {:label, term} + | {:properties, map} @type edge_opts :: [edge_opt] @doc """ @@ -38,11 +39,14 @@ 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 @@ -51,12 +55,13 @@ defmodule Graph.Edge do def options_to_meta(opts) when is_list(opts) do label = Keyword.get(opts, :label) weight = Keyword.get(opts, :weight, 1) + properties = Keyword.get(opts, :properties, %{}) - case {label, weight} do - {_, w} = meta when is_number(w) -> - meta + case {label, %{weight: weight, properties: properties}} do + {label, %{weight: w} = meta} when is_number(w) -> + {label, meta} - {_, _} -> + _ -> raise ArgumentError, message: "invalid value for :weight, must be an integer" end end diff --git a/lib/graph.ex b/lib/graph.ex old mode 100644 new mode 100755 index 7e25fd3..8349484 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -50,7 +50,8 @@ defmodule Graph do @type edge_index_key :: label | term @type edge_properties :: %{ label: label, - weight: edge_weight + weight: edge_weight, + properties: map } @type edge_value :: %{label => edge_properties()} @type graph_type :: :directed | :undirected @@ -509,8 +510,12 @@ defmodule Graph do edge_meta when is_map(edge_meta) -> v2 = Map.get(vs, v2_id) - for {label, weight} <- edge_meta do - Edge.new(v2, v, label: label, weight: weight) + for {label, meta_value} <- edge_meta do + Edge.new(v2, v, + label: label, + weight: meta_value.weight, + properties: meta_value.properties + ) end end end) @@ -524,8 +529,12 @@ defmodule Graph do edge_meta when is_map(edge_meta) -> v2 = Map.get(vs, v2_id) - for {label, weight} <- edge_meta do - Edge.new(v, v2, label: label, weight: weight) + for {label, meta_value} <- edge_meta do + Edge.new(v2, v, + label: label, + weight: meta_value.weight, + properties: meta_value.properties + ) end end end) @@ -627,8 +636,8 @@ defmodule Graph do 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) + {:ok, %{weight: weight, properties: properties}} <- Map.fetch(edge_meta, label) do + Edge.new(v1, v2, label: label, weight: weight, properties: properties) else _ -> nil @@ -1034,8 +1043,24 @@ defmodule Graph do end edge_meta = Map.get(meta, {v1_id, v2_id}, %{}) - {label, weight} = Edge.options_to_meta(opts) - edge_meta = Map.put(edge_meta, label, weight) + {label, options_meta} = Edge.options_to_meta(opts) + edge_meta = Map.put(edge_meta, label, options_meta) + + g = + if g.multigraph do + edge = Edge.new(v1, v2, label: label, weight: options_meta.weight, properties: opts) + + partition = g.edge_indexer.(edge) + key = {v1_id, partition} + set = Map.get(g.edge_index, key, MapSet.new()) + + %__MODULE__{ + g + | edge_index: Map.put(g.edge_index, key, MapSet.put(set, {v1_id, v2_id})) + } + else + g + end %__MODULE__{ g @@ -1050,7 +1075,7 @@ defmodule Graph do 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. @@ -2242,8 +2267,12 @@ defmodule Graph do Enum.flat_map(v_out, fn v2_id -> v2 = Map.get(vs, 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, {v_id, v2_id}), fn {label, edge_meta} -> + Edge.new(v, v2, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) end) end) else @@ -2252,6 +2281,37 @@ defmodule Graph do end end + def out_edges( + %__MODULE__{ + vertices: vs, + edges: edges, + multigraph: true, + edge_index: edge_index, + vertex_identifier: vertex_identifier, + edge_indexer: edge_indexer + }, + v, + partition + ) do + v1_id = vertex_identifier.(v) + key = {v1_id, partition} + # only return out_edges for which the index key returns a subset + edge_index + |> Map.get(key, MapSet.new()) + |> Enum.flat_map(fn {_v1_id, v2_id} = edge_key -> + v2 = Map.get(vs, v2_id) + + edges + |> Map.get(edge_key, []) + |> Enum.map(fn {label, edge_meta} -> + Edge.new(v, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) + end) + |> Enum.filter(fn edge -> + edge_indexer.(edge) == partition + end) + end) + end + @doc """ Builds a maximal subgraph of `g` which includes all of the vertices in `vs` and the edges which connect them. diff --git a/lib/graph/pathfinding.ex b/lib/graph/pathfinding.ex old mode 100644 new mode 100755 index c7f7fa6..b21724a --- a/lib/graph/pathfinding.ex +++ b/lib/graph/pathfinding.ex @@ -6,7 +6,8 @@ defmodule Graph.Pathfinding do @type heuristic_fun :: (Graph.vertex() -> integer) - @spec bellman_ford(Graph.t, Graph.vertex) :: %{Graph.vertex() => integer() | :infinity} | nil + @spec bellman_ford(Graph.t(), Graph.vertex()) :: + %{Graph.vertex() => integer() | :infinity} | nil def bellman_ford(g, a), do: Graph.Pathfindings.BellmanFord.call(g, a) @doc """ diff --git a/lib/graph/serializers/dot.ex b/lib/graph/serializers/dot.ex old mode 100644 new mode 100755 index 0df88c7..2266795 --- a/lib/graph/serializers/dot.ex +++ b/lib/graph/serializers/dot.ex @@ -14,7 +14,9 @@ defmodule Graph.Serializers.DOT do defp serialize_nodes(%Graph{vertices: vertices} = g) do Enum.reduce(vertices, "", fn {id, v}, acc -> - acc <> Serializer.indent(1) <> "#{id}" <> "[label=" <> Serializer.get_vertex_label(g, id, v) <> "]\n" + acc <> + Serializer.indent(1) <> + "#{id}" <> "[label=" <> Serializer.get_vertex_label(g, id, v) <> "]\n" end) end diff --git a/lib/graph/utils.ex b/lib/graph/utils.ex old mode 100644 new mode 100755 index b5a8f5d..2880961 --- a/lib/graph/utils.ex +++ b/lib/graph/utils.ex @@ -83,7 +83,7 @@ defmodule Graph.Utils do def edge_weight(%Graph{type: :directed, edges: meta}, a, b) do Map.fetch!(meta, {a, b}) - |> Enum.map(fn {_label, weight} -> weight end) + |> Enum.map(fn {_label, %{weight: weight}} -> weight end) |> Enum.min() end @@ -96,13 +96,13 @@ defmodule Graph.Utils do edge_meta when is_map(edge_meta) -> edge_meta - |> Enum.map(fn {_label, weight} -> weight end) + |> Enum.map(fn {_label, %{weight: weight}} -> weight end) |> Enum.min() end edge_meta when is_map(edge_meta) -> edge_meta - |> Enum.map(fn {_label, weight} -> weight end) + |> Enum.map(fn {_label, %{weight: weight}} -> weight end) |> Enum.min() end end diff --git a/test/graph_test.exs b/test/graph_test.exs old mode 100644 new mode 100755 index 63cb97c..e1a9b00 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -31,29 +31,39 @@ defmodule GraphTest do {:b, :c, weight: 3}, {:b, :a, label: {:complex, :label}} ]) + |> IO.inspect(structs: false) assert Enum.count(Graph.out_edges(graph, :a)) == 3 assert [%Edge{label: :foo}] = Graph.out_edges(graph, :a, :foo) assert [%Edge{label: :foo}] = Graph.in_edges(graph, :b, :foo) assert [%Edge{label: :bar}] = Graph.out_edges(graph, :a, :bar) - assert [%Edge{label: :nil}] = Graph.out_edges(graph, :a, nil) + assert [%Edge{label: nil}] = Graph.out_edges(graph, :a, nil) assert [] == Graph.out_edges(graph, :a, :foobar) end - test "custom vertex indexing function on edge labels" do + test "custom edge indexing function" do + graph = + Graph.new(multigraph: true, edge_indexer: 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, 6) + assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, 3) end - test "traversal using indexed labels" do - + test "traversal using indexed keys" do end - test "updating edge properties" do - + test "edge properties" do end test "removing edges" do - end end @@ -673,7 +683,7 @@ defmodule GraphTest do end defp build_complex_signed_graph do - Graph.new + Graph.new() |> Graph.add_edge(:a, :b, weight: -1) |> Graph.add_edge(:b, :e, weight: 2) |> Graph.add_edge(:e, :d, weight: -3) diff --git a/test/reducer_test.exs b/test/reducer_test.exs old mode 100644 new mode 100755 index 713e32f..428024f --- a/test/reducer_test.exs +++ b/test/reducer_test.exs @@ -35,17 +35,19 @@ defmodule Graph.Reducer.Test do end test "can walk a graph breadth-first, when the starting points had their in-edges deleted" do - g = Graph.new - |> Graph.add_vertices([:a, :b, :c, :d, :e, :f, :g]) - |> Graph.add_edge(:a, :b) - |> Graph.add_edge(:a, :d) - |> Graph.add_edge(:b, :c) - |> Graph.add_edge(:b, :d) - |> Graph.add_edge(:c, :e) - |> Graph.add_edge(:d, :f) - |> Graph.add_edge(:f, :g) - |> Graph.add_edge(:b, :a) # Add this edge and then remove it - |> Graph.delete_edge(:b, :a) + g = + Graph.new() + |> Graph.add_vertices([:a, :b, :c, :d, :e, :f, :g]) + |> Graph.add_edge(:a, :b) + |> Graph.add_edge(:a, :d) + |> Graph.add_edge(:b, :c) + |> Graph.add_edge(:b, :d) + |> Graph.add_edge(:c, :e) + |> Graph.add_edge(:d, :f) + |> Graph.add_edge(:f, :g) + # Add this edge and then remove it + |> Graph.add_edge(:b, :a) + |> Graph.delete_edge(:b, :a) expected = [:a, :b, :d, :c, :f, :e, :g] assert ^expected = Graph.Reducers.Bfs.map(g, fn v -> v end) From 53a74f502a9fac27433fd50c3e1fbcda1a8621ca Mon Sep 17 00:00:00 2001 From: Zack White Date: Mon, 26 Aug 2024 20:55:38 -0600 Subject: [PATCH 05/22] undo file permission --- .formatter.exs | 0 .github/workflows/elixir.yml | 0 .gitignore | 0 .travis.yml | 0 LICENSE | 0 Makefile | 0 README.md | 0 bench/cliques.exs | 0 bench/create.exs | 0 bench/k_core.exs | 0 bench/shortest_path.exs | 0 bench/topsort.exs | 0 coveralls.json | 0 lib/edge.ex | 0 lib/graph.ex | 0 lib/graph/directed.ex | 0 lib/graph/edge_specification_error.ex | 0 lib/graph/inspect.ex | 0 lib/graph/pathfinding.ex | 0 lib/graph/pathfindings/bellman_ford.ex | 0 lib/graph/reducer.ex | 0 lib/graph/reducers/bfs.ex | 0 lib/graph/reducers/dfs.ex | 0 lib/graph/serializer.ex | 0 lib/graph/serializers/dot.ex | 0 lib/graph/serializers/edgelist.ex | 0 lib/graph/serializers/flowchart.ex | 0 lib/graph/utils.ex | 0 lib/priority_queue.ex | 0 mix.exs | 0 mix.lock | 0 test/fixtures/email-Enron.txt | 0 test/fixtures/petster/README.petster-friendships-hamster | 0 test/fixtures/petster/edges.txt | 0 test/fixtures/petster/metadata.txt | 0 test/fixtures/petster/vertices.txt | 0 test/graph_test.exs | 0 test/model_test.exs | 0 test/priority_queue_test.exs | 0 test/reducer_test.exs | 0 test/serializer_test.exs | 0 test/support/generators.ex | 0 test/support/parser.ex | 0 test/test_helper.exs | 0 test/utils_test.exs | 0 45 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 .formatter.exs mode change 100755 => 100644 .github/workflows/elixir.yml mode change 100755 => 100644 .gitignore mode change 100755 => 100644 .travis.yml mode change 100755 => 100644 LICENSE mode change 100755 => 100644 Makefile mode change 100755 => 100644 README.md mode change 100755 => 100644 bench/cliques.exs mode change 100755 => 100644 bench/create.exs mode change 100755 => 100644 bench/k_core.exs mode change 100755 => 100644 bench/shortest_path.exs mode change 100755 => 100644 bench/topsort.exs mode change 100755 => 100644 coveralls.json mode change 100755 => 100644 lib/edge.ex mode change 100755 => 100644 lib/graph.ex mode change 100755 => 100644 lib/graph/directed.ex mode change 100755 => 100644 lib/graph/edge_specification_error.ex mode change 100755 => 100644 lib/graph/inspect.ex mode change 100755 => 100644 lib/graph/pathfinding.ex mode change 100755 => 100644 lib/graph/pathfindings/bellman_ford.ex mode change 100755 => 100644 lib/graph/reducer.ex mode change 100755 => 100644 lib/graph/reducers/bfs.ex mode change 100755 => 100644 lib/graph/reducers/dfs.ex mode change 100755 => 100644 lib/graph/serializer.ex mode change 100755 => 100644 lib/graph/serializers/dot.ex mode change 100755 => 100644 lib/graph/serializers/edgelist.ex mode change 100755 => 100644 lib/graph/serializers/flowchart.ex mode change 100755 => 100644 lib/graph/utils.ex mode change 100755 => 100644 lib/priority_queue.ex mode change 100755 => 100644 mix.exs mode change 100755 => 100644 mix.lock mode change 100755 => 100644 test/fixtures/email-Enron.txt mode change 100755 => 100644 test/fixtures/petster/README.petster-friendships-hamster mode change 100755 => 100644 test/fixtures/petster/edges.txt mode change 100755 => 100644 test/fixtures/petster/metadata.txt mode change 100755 => 100644 test/fixtures/petster/vertices.txt mode change 100755 => 100644 test/graph_test.exs mode change 100755 => 100644 test/model_test.exs mode change 100755 => 100644 test/priority_queue_test.exs mode change 100755 => 100644 test/reducer_test.exs mode change 100755 => 100644 test/serializer_test.exs mode change 100755 => 100644 test/support/generators.ex mode change 100755 => 100644 test/support/parser.ex mode change 100755 => 100644 test/test_helper.exs mode change 100755 => 100644 test/utils_test.exs diff --git a/.formatter.exs b/.formatter.exs old mode 100755 new mode 100644 diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml old mode 100755 new mode 100644 diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 diff --git a/.travis.yml b/.travis.yml old mode 100755 new mode 100644 diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 diff --git a/Makefile b/Makefile old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/bench/cliques.exs b/bench/cliques.exs old mode 100755 new mode 100644 diff --git a/bench/create.exs b/bench/create.exs old mode 100755 new mode 100644 diff --git a/bench/k_core.exs b/bench/k_core.exs old mode 100755 new mode 100644 diff --git a/bench/shortest_path.exs b/bench/shortest_path.exs old mode 100755 new mode 100644 diff --git a/bench/topsort.exs b/bench/topsort.exs old mode 100755 new mode 100644 diff --git a/coveralls.json b/coveralls.json old mode 100755 new mode 100644 diff --git a/lib/edge.ex b/lib/edge.ex old mode 100755 new mode 100644 diff --git a/lib/graph.ex b/lib/graph.ex old mode 100755 new mode 100644 diff --git a/lib/graph/directed.ex b/lib/graph/directed.ex old mode 100755 new mode 100644 diff --git a/lib/graph/edge_specification_error.ex b/lib/graph/edge_specification_error.ex old mode 100755 new mode 100644 diff --git a/lib/graph/inspect.ex b/lib/graph/inspect.ex old mode 100755 new mode 100644 diff --git a/lib/graph/pathfinding.ex b/lib/graph/pathfinding.ex old mode 100755 new mode 100644 diff --git a/lib/graph/pathfindings/bellman_ford.ex b/lib/graph/pathfindings/bellman_ford.ex old mode 100755 new mode 100644 diff --git a/lib/graph/reducer.ex b/lib/graph/reducer.ex old mode 100755 new mode 100644 diff --git a/lib/graph/reducers/bfs.ex b/lib/graph/reducers/bfs.ex old mode 100755 new mode 100644 diff --git a/lib/graph/reducers/dfs.ex b/lib/graph/reducers/dfs.ex old mode 100755 new mode 100644 diff --git a/lib/graph/serializer.ex b/lib/graph/serializer.ex old mode 100755 new mode 100644 diff --git a/lib/graph/serializers/dot.ex b/lib/graph/serializers/dot.ex old mode 100755 new mode 100644 diff --git a/lib/graph/serializers/edgelist.ex b/lib/graph/serializers/edgelist.ex old mode 100755 new mode 100644 diff --git a/lib/graph/serializers/flowchart.ex b/lib/graph/serializers/flowchart.ex old mode 100755 new mode 100644 diff --git a/lib/graph/utils.ex b/lib/graph/utils.ex old mode 100755 new mode 100644 diff --git a/lib/priority_queue.ex b/lib/priority_queue.ex old mode 100755 new mode 100644 diff --git a/mix.exs b/mix.exs old mode 100755 new mode 100644 diff --git a/mix.lock b/mix.lock old mode 100755 new mode 100644 diff --git a/test/fixtures/email-Enron.txt b/test/fixtures/email-Enron.txt old mode 100755 new mode 100644 diff --git a/test/fixtures/petster/README.petster-friendships-hamster b/test/fixtures/petster/README.petster-friendships-hamster old mode 100755 new mode 100644 diff --git a/test/fixtures/petster/edges.txt b/test/fixtures/petster/edges.txt old mode 100755 new mode 100644 diff --git a/test/fixtures/petster/metadata.txt b/test/fixtures/petster/metadata.txt old mode 100755 new mode 100644 diff --git a/test/fixtures/petster/vertices.txt b/test/fixtures/petster/vertices.txt old mode 100755 new mode 100644 diff --git a/test/graph_test.exs b/test/graph_test.exs old mode 100755 new mode 100644 diff --git a/test/model_test.exs b/test/model_test.exs old mode 100755 new mode 100644 diff --git a/test/priority_queue_test.exs b/test/priority_queue_test.exs old mode 100755 new mode 100644 diff --git a/test/reducer_test.exs b/test/reducer_test.exs old mode 100755 new mode 100644 diff --git a/test/serializer_test.exs b/test/serializer_test.exs old mode 100755 new mode 100644 diff --git a/test/support/generators.ex b/test/support/generators.ex old mode 100755 new mode 100644 diff --git a/test/support/parser.ex b/test/support/parser.ex old mode 100755 new mode 100644 diff --git a/test/test_helper.exs b/test/test_helper.exs old mode 100755 new mode 100644 diff --git a/test/utils_test.exs b/test/utils_test.exs old mode 100755 new mode 100644 From 7a14cd51496afd675cd74a85f7916b5f4c326a60 Mon Sep 17 00:00:00 2001 From: zack Date: Sat, 31 Aug 2024 09:45:05 -0600 Subject: [PATCH 06/22] match weights from adjusted meta --- lib/edge.ex | 3 +- lib/graph.ex | 48 +++++++++++++++----------- lib/graph/pathfindings/bellman_ford.ex | 3 +- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/lib/edge.ex b/lib/edge.ex index c1908ac..324c4e0 100644 --- a/lib/edge.ex +++ b/lib/edge.ex @@ -61,7 +61,8 @@ defmodule Graph.Edge do {label, %{weight: w} = meta} when is_number(w) -> {label, meta} - _ -> + other -> + IO.inspect(other) raise ArgumentError, message: "invalid value for :weight, must be an integer" end end diff --git a/lib/graph.ex b/lib/graph.ex index 8349484..210570d 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -465,8 +465,8 @@ defmodule Graph do target = Map.get(vs, out_neighbor) meta = Map.get(meta, {source_id, out_neighbor}) - Enum.map(meta, fn {label, weight} -> - Edge.new(source, target, label: label, weight: weight) + Enum.map(meta, fn {label, %{weight: weight, properties: properties}} -> + Edge.new(source, target, label: label, weight: weight, properties: properties) end) end) end) @@ -555,7 +555,7 @@ defmodule Graph do iex> g = Graph.new(type: :undirected) |> Graph.add_edge(:a, :b, label: :uses) ...> g = Graph.add_edge(g, :a, :b, label: :contains) ...> Graph.edges(g, :a, :b) - [%Graph.Edge{v1: :a, v2: :b, label: :contains}, %Graph.Edge{v1: :a, v2: :b, label: :uses}] + [%Graph.Edge{label: :contains, properties: %{}, v1: :a, v2: :b, weight: 1}, %Graph.Edge{v1: :a, v2: :b, weight: 1, label: :uses, properties: %{}}] """ @spec edges(t, vertex, vertex) :: [Edge.t()] def edges(%__MODULE__{type: type, edges: meta, vertex_identifier: vertex_identifier}, v1, v2) do @@ -577,18 +577,18 @@ defmodule Graph do end defp edge_list(v1, v2, edge_meta, :undirected) do - for {label, weight} <- edge_meta do + for {label, %{weight: weight, properties: properties}} <- edge_meta do if v1 > v2 do - Edge.new(v2, v1, label: label, weight: weight) + Edge.new(v2, v1, label: label, weight: weight, properties: properties) else - Edge.new(v1, v2, label: label, weight: weight) + Edge.new(v1, v2, label: label, weight: weight, properties: properties) end end end defp edge_list(v1, v2, edge_meta, _) do - for {label, weight} <- edge_meta do - Edge.new(v1, v2, label: label, weight: weight) + for {label, %{weight: weight, properties: properties}} <- edge_meta do + Edge.new(v1, v2, label: label, weight: weight, properties: properties) end end @@ -1169,10 +1169,10 @@ defmodule Graph do g = add_vertex(g, v3) - Enum.reduce(meta, g, fn {label, weight}, acc -> + Enum.reduce(meta, g, fn {label, %{weight: weight, properties: properties}}, acc -> 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: properties) + |> add_edge(v3, v2, label: label, weight: weight, properties: properties) end) else _ -> {:error, :no_such_edge} @@ -1241,18 +1241,18 @@ defmodule Graph do edge_key <- {v1_id, v2_id}, {:ok, meta} <- Map.fetch(em, edge_key), {:ok, _} <- Map.fetch(meta, old_label), - {new_label, new_weight} <- Edge.options_to_meta(opts) do + {new_label, new_attrs} <- Edge.options_to_meta(opts) do case new_label do ^old_label -> - new_meta = Map.put(meta, old_label, new_weight) + new_meta = Map.put(meta, old_label, new_attrs) %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} nil -> - new_meta = Map.put(meta, old_label, new_weight) + new_meta = Map.put(meta, old_label, new_attrs) %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} _ -> - new_meta = Map.put(Map.delete(meta, old_label), new_label, new_weight) + new_meta = Map.put(Map.delete(meta, old_label), new_label, new_attrs) %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} end else @@ -2200,8 +2200,12 @@ defmodule Graph do Enum.flat_map(v_in, fn v1_id -> v1 = Map.get(vs, v1_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, {v1_id, v_id}), fn {label, edge_meta} -> + Edge.new(v1, v, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) end) end) else @@ -2246,7 +2250,7 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:a, :b, label: :foo}, {:b, :c}]) ...> Graph.out_edges(g, :a) - [%Graph.Edge{v1: :a, v2: :b, label: :foo}, %Graph.Edge{v1: :a, v2: :b}] + [%Graph.Edge{v1: :a, v2: :b, label: :foo, properties: %{}}, %Graph.Edge{v1: :a, v2: :b}] """ @spec out_edges(t, vertex) :: Edge.t() def out_edges(%__MODULE__{type: :undirected} = g, v) do @@ -2348,8 +2352,12 @@ defmodule Graph do |> 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(Map.get(meta, {v_id, v2_id}), sg, fn {label, edge_meta}, sg -> + Graph.add_edge(sg, v, v2, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) end) end) end) diff --git a/lib/graph/pathfindings/bellman_ford.ex b/lib/graph/pathfindings/bellman_ford.ex index d5a421b..985018c 100644 --- a/lib/graph/pathfindings/bellman_ford.ex +++ b/lib/graph/pathfindings/bellman_ford.ex @@ -51,7 +51,8 @@ defmodule Graph.Pathfindings.BellmanFord do end @spec edge_weight(term) :: float - defp edge_weight({e, edge_value}), do: {e, edge_value |> Map.values() |> List.first()} + defp edge_weight({e, edge_value}), + do: {e, edge_value |> Map.values() |> List.first() |> Map.get(:weight)} defp has_negative_cycle?(distances, meta) do Enum.any?(meta, fn {{u, v}, weight} -> From 91bcbb2b00832d95eeb3ef8302c6b54f38032d40 Mon Sep 17 00:00:00 2001 From: zack Date: Sat, 31 Aug 2024 12:44:24 -0600 Subject: [PATCH 07/22] more passing tests && duplicate index for in_edges partition --- lib/edge.ex | 3 +- lib/graph.ex | 73 ++++++++++++++++++++++-------- lib/graph/serializers/dot.ex | 4 +- lib/graph/serializers/flowchart.ex | 4 +- test/graph_test.exs | 10 ++-- 5 files changed, 65 insertions(+), 29 deletions(-) diff --git a/lib/edge.ex b/lib/edge.ex index 324c4e0..7236374 100644 --- a/lib/edge.ex +++ b/lib/edge.ex @@ -61,8 +61,7 @@ defmodule Graph.Edge do {label, %{weight: w} = meta} when is_number(w) -> {label, meta} - other -> - IO.inspect(other) + _other -> raise ArgumentError, message: "invalid value for :weight, must be an integer" end end diff --git a/lib/graph.ex b/lib/graph.ex index 210570d..5d29f74 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -33,7 +33,7 @@ defmodule Graph do vertices: %{}, type: :directed, vertex_identifier: &Graph.Utils.vertex_id/1, - edge_indexer: &Graph.Utils.edge_label/1, + partition_by: &Graph.Utils.edge_label/1, multigraph: false alias Graph.{Edge, EdgeSpecificationError} @@ -65,7 +65,7 @@ defmodule Graph do vertices: %{vertex_id => vertex}, type: graph_type, vertex_identifier: (vertex() -> term()), - edge_indexer: (Edge.t() -> edge_index_key), + partition_by: (Edge.t() -> edge_index_key), multigraph: boolean() } @type graph_info :: %{ @@ -121,13 +121,13 @@ 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) - edge_indexer = Keyword.get(opts, :edge_indexer) || (&Graph.Utils.edge_label/1) + partition_by = Keyword.get(opts, :partition_by) || (&Graph.Utils.edge_label/1) multigraph = Keyword.get(opts, :multigraph, false) %__MODULE__{ type: type, vertex_identifier: vertex_identifier, - edge_indexer: edge_indexer, + partition_by: partition_by, multigraph: multigraph } end @@ -550,12 +550,12 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edge(:a, :b, label: :uses) ...> g = Graph.add_edge(g, :a, :b, label: :contains) ...> Graph.edges(g, :a, :b) - [%Graph.Edge{v1: :a, v2: :b, label: :contains}, %Graph.Edge{v1: :a, v2: :b, label: :uses}] + [%Graph.Edge{v1: :a, v2: :b, label: :uses}, %Graph.Edge{v1: :a, v2: :b, label: :contains}] iex> g = Graph.new(type: :undirected) |> Graph.add_edge(:a, :b, label: :uses) ...> g = Graph.add_edge(g, :a, :b, label: :contains) ...> Graph.edges(g, :a, :b) - [%Graph.Edge{label: :contains, properties: %{}, v1: :a, v2: :b, weight: 1}, %Graph.Edge{v1: :a, v2: :b, weight: 1, label: :uses, properties: %{}}] + [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: :uses, properties: %{}}, %Graph.Edge{label: :contains, properties: %{}, v1: :a, v2: :b, weight: 1}] """ @spec edges(t, vertex, vertex) :: [Edge.t()] def edges(%__MODULE__{type: type, edges: meta, vertex_identifier: vertex_identifier}, v1, v2) do @@ -1050,13 +1050,18 @@ defmodule Graph do if g.multigraph do edge = Edge.new(v1, v2, label: label, weight: options_meta.weight, properties: opts) - partition = g.edge_indexer.(edge) - key = {v1_id, partition} - set = Map.get(g.edge_index, key, MapSet.new()) + partition = g.partition_by.(edge) + v1_key = {v1_id, partition} + v2_key = {v2_id, partition} + v1_set = Map.get(g.edge_index, v1_key, MapSet.new()) + v2_set = Map.get(g.edge_index, v2_key, MapSet.new()) %__MODULE__{ g - | edge_index: Map.put(g.edge_index, key, MapSet.put(set, {v1_id, v2_id})) + | edge_index: + g.edge_index + |> Map.put(v1_key, MapSet.put(v1_set, {v1_id, v2_id})) + |> Map.put(v2_key, MapSet.put(v2_set, {v1_id, v2_id})) } else g @@ -1092,7 +1097,7 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:a, :b, label: :foo}, {:a, :b, label: :foo, weight: 2}]) ...> Graph.edges(g) - [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}, %Graph.Edge{v1: :a, v2: :b}] + [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 2}] iex> Graph.new |> Graph.add_vertices([:a, :b, :c]) |> Graph.add_edges([:a, :b]) ** (Graph.EdgeSpecificationError) Expected a valid edge specification, but got: :a @@ -1190,7 +1195,7 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edge(:a, :b) |> Graph.add_edge(:a, :b, label: :bar) ...> %Graph{} = g = Graph.update_edge(g, :a, :b, weight: 2, label: :foo) ...> Graph.edges(g) - [%Graph.Edge{v1: :a, v2: :b, label: :bar}, %Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}] + [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}, %Graph.Edge{v1: :a, v2: :b, label: :bar}] """ @spec update_edge(t, vertex, vertex, Edge.edge_opts()) :: t | {:error, :no_such_edge} def update_edge(%__MODULE__{} = g, v1, v2, opts) when is_list(opts) do @@ -1207,12 +1212,12 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edge(:a, :b) |> Graph.add_edge(:a, :b, label: :bar) ...> %Graph{} = g = Graph.update_labelled_edge(g, :a, :b, :bar, weight: 2, label: :foo) ...> Graph.edges(g) - [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}, %Graph.Edge{v1: :a, v2: :b}] + [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 2}] iex> g = Graph.new(type: :undirected) |> Graph.add_edge(:a, :b) |> Graph.add_edge(:a, :b, label: :bar) ...> %Graph{} = g = Graph.update_labelled_edge(g, :a, :b, :bar, weight: 2, label: :foo) ...> Graph.edges(g) - [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}, %Graph.Edge{v1: :a, v2: :b}] + [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 2}] """ @spec update_labelled_edge(t, vertex, vertex, label, Edge.edge_opts()) :: t | {:error, :no_such_edge} @@ -2179,7 +2184,7 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:a, :b, label: :foo}, {:b, :c}]) ...> Graph.in_edges(g, :b) - [%Graph.Edge{v1: :a, v2: :b, label: :foo}, %Graph.Edge{v1: :a, v2: :b}] + [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 1}] """ @spec in_edges(t, vertex) :: Edge.t() def in_edges(%__MODULE__{type: :undirected} = g, v) do @@ -2213,6 +2218,38 @@ defmodule Graph do end end + def in_edges( + %__MODULE__{ + vertices: vs, + edges: edges, + multigraph: true, + vertex_identifier: vertex_identifier, + edge_index: edge_index, + partition_by: partition_by + }, + v, + partition + ) do + v2_id = vertex_identifier.(v) + key = {v2_id, partition} + + edge_index + |> Map.get(key, MapSet.new()) + |> IO.inspect(label: "edge_index_keys") + |> Enum.flat_map(fn {v1_id, _v2_id} = edge_key -> + v1 = Map.get(vs, v1_id) + + edges + |> Map.get(edge_key, []) + |> Enum.map(fn {label, edge_meta} -> + Edge.new(v1, v, label: label, weight: edge_meta.weight, properties: edge_meta.properties) + end) + |> Enum.filter(fn edge -> + partition_by.(edge) == partition + end) + end) + end + @doc """ Returns a list of vertices which the given vertex `v` has edges going to. @@ -2250,7 +2287,7 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:a, :b, label: :foo}, {:b, :c}]) ...> Graph.out_edges(g, :a) - [%Graph.Edge{v1: :a, v2: :b, label: :foo, properties: %{}}, %Graph.Edge{v1: :a, v2: :b}] + [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 1}] """ @spec out_edges(t, vertex) :: Edge.t() def out_edges(%__MODULE__{type: :undirected} = g, v) do @@ -2292,7 +2329,7 @@ defmodule Graph do multigraph: true, edge_index: edge_index, vertex_identifier: vertex_identifier, - edge_indexer: edge_indexer + partition_by: partition_by }, v, partition @@ -2311,7 +2348,7 @@ defmodule Graph do Edge.new(v, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) end) |> Enum.filter(fn edge -> - edge_indexer.(edge) == partition + partition_by.(edge) == partition end) end) end diff --git a/lib/graph/serializers/dot.ex b/lib/graph/serializers/dot.ex index 2266795..baf5dd4 100644 --- a/lib/graph/serializers/dot.ex +++ b/lib/graph/serializers/dot.ex @@ -28,10 +28,10 @@ defmodule Graph.Serializers.DOT do |> Map.get(id, MapSet.new()) |> Enum.flat_map(fn id2 -> Enum.map(Map.fetch!(em, {id, id2}), fn - {nil, weight} -> + {nil, %{weight: weight}} -> {id, id2, weight} - {label, weight} -> + {label, %{weight: weight}} -> {id, id2, weight, Serializer.encode_label(label)} end) end) diff --git a/lib/graph/serializers/flowchart.ex b/lib/graph/serializers/flowchart.ex index 392a940..7a65cb8 100644 --- a/lib/graph/serializers/flowchart.ex +++ b/lib/graph/serializers/flowchart.ex @@ -38,8 +38,8 @@ defmodule Graph.Serializers.Flowchart do g.edges |> Map.fetch!({id, out_edge_id}) |> Enum.map(fn - {nil, weight} -> {id, out_edge_id, weight} - {label, weight} -> {id, out_edge_id, weight, encode_label(label)} + {nil, %{weight: weight}} -> {id, out_edge_id, weight} + {label, %{weight: weight}} -> {id, out_edge_id, weight, encode_label(label)} end) end) |> case do diff --git a/test/graph_test.exs b/test/graph_test.exs index e1a9b00..c5d395a 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -21,7 +21,7 @@ defmodule GraphTest do end describe "multigraphs" do - test "`multigraph: true` option enables vertex indexing on edge labels" do + test "`multigraph: true` option enables edge indexing on edge labels" do graph = Graph.new(multigraph: true) |> Graph.add_edges([ @@ -43,7 +43,7 @@ defmodule GraphTest do test "custom edge indexing function" do graph = - Graph.new(multigraph: true, edge_indexer: fn edge -> edge.weight end) + Graph.new(multigraph: true, partition_by: fn edge -> edge.weight end) |> Graph.add_edges([ {:a, :b}, {:a, :b, label: :foo}, @@ -54,7 +54,7 @@ defmodule GraphTest do assert Enum.count(Graph.out_edges(graph, :b)) == 2 assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, 6) - assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, 3) + assert [%Edge{weight: 3}] = Graph.out_edges(graph, :b, 3) end test "traversal using indexed keys" do @@ -123,12 +123,12 @@ defmodule GraphTest do # pretty printed str = "#{inspect(g)}" - assert "#Graph :b, :a -> :b, :b -[{:complex, :label}]-> :a, :b -> :c]>" = + assert "#Graph :b, :a -[foo]-> :b, :b -[{:complex, :label}]-> :a, :b -> :c]>" = str ustr = "#{inspect(ug)}" - assert "#Graph :b, :a <-> :b, :a <-[{:complex, :label}]-> :b, :b <-> :c]>" = + assert "#Graph :b, :a <-[foo]-> :b, :a <-[{:complex, :label}]-> :b, :b <-> :c]>" = ustr # large graph From 1cadec78dcfedc042a5475bd76e944e5f6bb1938 Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Sep 2024 08:18:04 -0600 Subject: [PATCH 08/22] prune on edge deletion --- lib/graph.ex | 57 ++++++++++++++++++++++++++++++++++++++++----- test/graph_test.exs | 49 ++++++++++++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/lib/graph.ex b/lib/graph.ex index 5d29f74..deec692 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -86,21 +86,19 @@ defmodule Graph do - `multigraph: true | false | fn edge -> key end`, enables edge indexing by a key. - When `true`, the key is the edge label itself. - When `false` no additional memory is used for sets of . - - `edge_indexer`: a function which accepts an `%Edge{}` and returns a unique identifier of said edge. - Defaults to `Graph.Utils.edge_label/1`, the edge label itself. + - `partition_by`: a function which accepts an `%Edge{}` and returns a unique identifier of said edge. + Defaults to `Graph.Utils.edge_label/1`, the edge label itself when multigraphs are enabled. ### Multigraph Edge Indexing Indexing edges trades space for time to access only edges of a kind. - When `multigraph: true` is enabled the `edge_indexer` of the graph is used to build a a set of edge keys (`{vertex_id, vertex_id}`) under a separate key. + When `multigraph: true` is enabled the `partition_by` of the graph is used to build a set of edge keys (`{vertex_id, vertex_id}`) under a separate key. This can be a useful trade-off when traversing a graph where many different kinds of edges exist between the same vertices and you want to avoid iterating over the set of all edges. I.e. a [multigraph](https://en.wikipedia.org/wiki/Multigraph). The index provides allows map access time to to a set of edges when managing the graph. - By default edges are indexed by the label but only when multigraph is toggled true. - ## Example iex> Graph.new() @@ -1308,6 +1306,8 @@ defmodule Graph do v1, v2 ) do + g = prune_edge_index(g, v1, v2, nil) + with v1_id <- vertex_identifier.(v1), v2_id <- vertex_identifier.(v2), edge_key <- {v1_id, v2_id}, @@ -1328,6 +1328,50 @@ defmodule Graph do end end + defp prune_edge_index( + %__MODULE__{ + multigraph: true, + edge_index: edge_index, + edges: meta, + partition_by: partition_by, + vertex_identifier: vertex_identifier + } = g, + v1, + v2, + label + ) do + v1_id = vertex_identifier.(v1) + v2_id = vertex_identifier.(v2) + + {_label, edge_meta} = + meta + |> Map.get({v1_id, v2_id}) + |> Enum.filter(fn {edge_label, _} -> edge_label == label end) + |> List.first() + + edge = + Edge.new(v1, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) + + edge_p = partition_by.(edge) + + v1_key = {v1_id, edge_p} + v2_key = {v2_id, edge_p} + + edge_index = + edge_index + |> Map.delete(v1_key) + |> Map.delete(v2_key) + + %__MODULE__{ + g + | edge_index: edge_index + } + end + + defp prune_edge_index(%__MODULE__{multigraph: false} = g, _v1, _v2, _label) do + g + 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, @@ -1379,6 +1423,8 @@ defmodule Graph do v2, label ) do + g = prune_edge_index(g, v1, v2, label) + with v1_id <- vertex_identifier.(v1), v2_id <- vertex_identifier.(v2), edge_key <- {v1_id, v2_id}, @@ -2235,7 +2281,6 @@ defmodule Graph do edge_index |> Map.get(key, MapSet.new()) - |> IO.inspect(label: "edge_index_keys") |> Enum.flat_map(fn {v1_id, _v2_id} = edge_key -> v1 = Map.get(vs, v1_id) diff --git a/test/graph_test.exs b/test/graph_test.exs index c5d395a..0abae58 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -31,7 +31,7 @@ defmodule GraphTest do {:b, :c, weight: 3}, {:b, :a, label: {:complex, :label}} ]) - |> IO.inspect(structs: false) + |> IO.inspect(structs: false, label: "multigraph") assert Enum.count(Graph.out_edges(graph, :a)) == 3 assert [%Edge{label: :foo}] = Graph.out_edges(graph, :a, :foo) @@ -41,7 +41,7 @@ defmodule GraphTest do assert [] == Graph.out_edges(graph, :a, :foobar) end - test "custom edge indexing function" do + test "custom edge partition_by function" do graph = Graph.new(multigraph: true, partition_by: fn edge -> edge.weight end) |> Graph.add_edges([ @@ -57,13 +57,54 @@ defmodule GraphTest do assert [%Edge{weight: 3}] = Graph.out_edges(graph, :b, 3) 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, {g.vertex_identifier.(:b), {:complex, :label}}) + end + test "traversal using indexed keys" do end + end - test "edge properties" do + 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}} + ]) + + assert [ + %Edge{v1: :a, v2: :b, properties: %{foo: :bar}}, + %Edge{v1: :a, v2: :b, label: :foo, properties: %{bar: :foo}} + ] = Graph.out_edges(g, :a) end - test "removing edges" do + 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}) + + assert [ + %Edge{v1: :a, v2: :b, properties: %{ham: :potato}}, + %Edge{v1: :a, v2: :b, label: :foo, properties: %{potato: :ham}} + ] = Graph.out_edges(g, :a) end end From 89dfe11ac7cf2762cf5ebd3481bd7463c9041137 Mon Sep 17 00:00:00 2001 From: Zack White Date: Sat, 11 Jan 2025 14:28:17 -0700 Subject: [PATCH 09/22] try latest in CI --- .github/workflows/elixir.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 95a4794..3168018 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -9,11 +9,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - otp: ['24.2', '25.0'] - elixir: ['1.12.3', '1.13.3', '1.14.0'] - exclude: - - otp: '25.0' - elixir: '1.12.3' + include: + - otp: '24.2' + elixir: '1.12.3' + + - otp: '25.0' + elixir: '1.13.3' + + - otp: '27.2' + elixir: '1.18.1' steps: - uses: actions/checkout@v2 - uses: erlef/setup-beam@v1 From 2b9cc73f091ef9fb6984cd82efe2cd3883e8fb91 Mon Sep 17 00:00:00 2001 From: Zack White Date: Sat, 11 Jan 2025 14:32:43 -0700 Subject: [PATCH 10/22] try latest in CI --- .github/workflows/elixir.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 3168018..ef7b1df 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -10,14 +10,17 @@ jobs: strategy: matrix: include: - - otp: '24.2' - elixir: '1.12.3' + - elixir: 1.14.5 + otp: 24.3 - - otp: '25.0' - elixir: '1.13.3' + - elixir: 1.15.4 + otp: 25.3 - - otp: '27.2' - elixir: '1.18.1' + - elixir: 1.16.3 + otp: 26.2 + + - otp: 27.2 + elixir: 1.18.1 steps: - uses: actions/checkout@v2 - uses: erlef/setup-beam@v1 From 07b018d6307b4d29ce5de8c9dffabab6e0a411d9 Mon Sep 17 00:00:00 2001 From: Zack White Date: Fri, 17 Jan 2025 15:01:26 -0700 Subject: [PATCH 11/22] edge adjacency index pruning --- lib/graph.ex | 57 +++++++++++++++++++++++++++--------- test/graph_test.exs | 17 ++++++++++- test/priority_queue_test.exs | 2 +- test/utils_test.exs | 2 +- 4 files changed, 61 insertions(+), 17 deletions(-) diff --git a/lib/graph.ex b/lib/graph.ex index deec692..6a3566b 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -1306,13 +1306,12 @@ defmodule Graph do v1, v2 ) do - g = prune_edge_index(g, v1, v2, nil) - with v1_id <- vertex_identifier.(v1), v2_id <- vertex_identifier.(v2), edge_key <- {v1_id, v2_id}, {:ok, v1_out} <- Map.fetch(oe, v1_id), {:ok, v2_in} <- Map.fetch(ie, v2_id) do + g = prune_edge_index(g, {v1_id, v1}, {v2_id, v2}, nil) v1_out = MapSet.delete(v1_out, v2_id) v2_in = MapSet.delete(v2_in, v1_id) meta = Map.delete(meta, edge_key) @@ -1333,21 +1332,52 @@ defmodule Graph do multigraph: true, edge_index: edge_index, edges: meta, - partition_by: partition_by, - vertex_identifier: vertex_identifier + partition_by: partition_by } = g, - v1, - v2, - label + {v1_id, v1}, + {v2_id, v2}, + nil ) do - v1_id = vertex_identifier.(v1) - v2_id = vertex_identifier.(v2) + meta + |> Map.get({v1_id, v2_id}) + |> Enum.reduce(g, fn {label, edge_meta}, acc -> + edge = + Edge.new(v1, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) + + edge_p = partition_by.(edge) + + v1_key = {v1_id, edge_p} + v2_key = {v2_id, edge_p} + + edge_index = + edge_index + |> Map.delete(v1_key) + |> Map.delete(v2_key) + + %__MODULE__{ + acc + | edge_index: edge_index + } + end) + end - {_label, edge_meta} = + defp prune_edge_index( + %__MODULE__{ + multigraph: true, + edge_index: edge_index, + edges: meta, + partition_by: partition_by + } = g, + {v1_id, v1}, + {v2_id, v2}, + label + ) do + [{_label, edge_meta} | _] = meta |> Map.get({v1_id, v2_id}) - |> Enum.filter(fn {edge_label, _} -> edge_label == label end) - |> List.first() + |> Enum.filter(fn {edge_label, _v} -> + edge_label == label + end) edge = Edge.new(v1, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) @@ -1423,8 +1453,6 @@ defmodule Graph do v2, label ) do - g = prune_edge_index(g, v1, v2, label) - with v1_id <- vertex_identifier.(v1), v2_id <- vertex_identifier.(v2), edge_key <- {v1_id, v2_id}, @@ -1432,6 +1460,7 @@ 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 = prune_edge_index(g, {v1_id, v1}, {v2_id, v2}, label) edge_meta = Map.delete(edge_meta, label) case map_size(edge_meta) do diff --git a/test/graph_test.exs b/test/graph_test.exs index 0abae58..194f3fa 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -31,7 +31,6 @@ defmodule GraphTest do {:b, :c, weight: 3}, {:b, :a, label: {:complex, :label}} ]) - |> IO.inspect(structs: false, label: "multigraph") assert Enum.count(Graph.out_edges(graph, :a)) == 3 assert [%Edge{label: :foo}] = Graph.out_edges(graph, :a, :foo) @@ -72,6 +71,22 @@ defmodule GraphTest do refute Map.has_key?(g.edge_index, {g.vertex_identifier.(:b), {:complex, :label}}) end + test "delete_edge/3 removes only a multigraph's properties and index for the given partition key" 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) |> IO.inspect(structs: false) + refute Map.has_key?(g.edge_index, {g.vertex_identifier.(:a), :foo}) + refute Map.has_key?(g.edge_index, {g.vertex_identifier.(:b), :foo}) + end + test "traversal using indexed keys" do end end diff --git a/test/priority_queue_test.exs b/test/priority_queue_test.exs index ab6b632..20d5d40 100644 --- a/test/priority_queue_test.exs +++ b/test/priority_queue_test.exs @@ -10,7 +10,7 @@ defmodule PriorityQueue.Test do end) str = "#{inspect(pq)}" - assert "#PriorityQueue" = str + assert "#PriorityQueue" = str end test "can enqueue random elements and pull them out in priority order" do diff --git a/test/utils_test.exs b/test/utils_test.exs index 7a9a3f3..f72724c 100644 --- a/test/utils_test.exs +++ b/test/utils_test.exs @@ -7,7 +7,7 @@ defmodule Graph.UtilsTest do test "sizeof/1" do assert 64 = sizeof({1, :foo, "bar"}) - assert 440 = sizeof(String.duplicate("bar", 128)) + assert 456 = sizeof(String.duplicate("bar", 128)) assert 8 = sizeof([]) assert 24 = sizeof([1 | 2]) assert 56 = sizeof([1, 2, 3]) From ab3ee73157a4aad17edcbb10f8b3a271c16a8dbf Mon Sep 17 00:00:00 2001 From: Zack White Date: Sat, 1 Mar 2025 13:18:07 -0700 Subject: [PATCH 12/22] add multigraph edge partitioning & filtering --- lib/graph.ex | 301 ++++++++++++++++++++++++++++++++++++++------ test/graph_test.exs | 42 +++++-- 2 files changed, 294 insertions(+), 49 deletions(-) diff --git a/lib/graph.ex b/lib/graph.ex index 6a3566b..65c30f3 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -483,7 +483,21 @@ defmodule Graph do ...> Graph.edges(g, :d) [] """ - @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, @@ -555,7 +569,25 @@ defmodule Graph do ...> Graph.edges(g, :a, :b) [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: :uses, properties: %{}}, %Graph.Edge{label: :contains, properties: %{}, v1: :a, v2: :b, weight: 1}] """ - @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}, v1, v2) do with v1_id <- vertex_identifier.(v1), v2_id <- vertex_identifier.(v2), @@ -574,6 +606,100 @@ defmodule Graph do end end + 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, edge_meta}, acc -> + edge = + Edge.new(v1, v2, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) + + edge_partition = g.partition_by.(edge) + + if include_edge_for_filtered_partitions?(edge, edge_partition, partitions, where_fun) do + [edge | acc] + else + acc + end + end) + end) + end + + defp edges_in_partitions(g, v1, partitions, where_fun) do + v1_id = g.vertex_identifier.(v1) + + out_edges_set = + g.out_edges + |> Map.get(v1_id, MapSet.new()) + |> MapSet.new(fn v2_id -> + {v1_id, v2_id} + end) + + in_edges_set = + g.in_edges + |> Map.get(v1_id, MapSet.new()) + |> MapSet.new(fn v2_id -> + {v2_id, v1_id} + end) + + edges = MapSet.union(out_edges_set, in_edges_set) + + edge_adjacency_set = + partitions + |> Enum.reduce(MapSet.new(), fn partition, acc -> + g.edge_index + |> Map.get(partition, %{}) + |> Map.get(v1_id, MapSet.new()) + |> MapSet.union(acc) + end) + |> MapSet.intersection(edges) + + Enum.flat_map(edge_adjacency_set, fn {_v1_id, v2_id} = edge_key -> + v2 = Map.get(g.verticies, v2_id) + + edges + |> Map.get(edge_key, []) + |> Enum.reduce([], fn {label, edge_meta}, acc -> + edge = + Edge.new(v1, v2, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) + + edge_partition = g.partition_by.(edge) + + if include_edge_for_filtered_partitions?(edge, edge_partition, partitions, where_fun) do + [edge | acc] + else + acc + end + end) + end) + end + + defp filter_edges(edges, nil), do: edges + + defp filter_edges(edges, where_fun) do + Enum.filter(edges, where_fun) + end + defp edge_list(v1, v2, edge_meta, :undirected) do for {label, %{weight: weight, properties: properties}} <- edge_meta do if v1 > v2 do @@ -1049,17 +1175,28 @@ defmodule Graph do edge = Edge.new(v1, v2, label: label, weight: options_meta.weight, properties: opts) partition = g.partition_by.(edge) - v1_key = {v1_id, partition} - v2_key = {v2_id, partition} - v1_set = Map.get(g.edge_index, v1_key, MapSet.new()) - v2_set = Map.get(g.edge_index, v2_key, MapSet.new()) + + 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(v1_key, MapSet.put(v1_set, {v1_id, v2_id})) - |> Map.put(v2_key, MapSet.put(v2_set, {v1_id, v2_id})) + |> Map.put(partition, new_edge_partition) } else g @@ -2297,30 +2434,52 @@ defmodule Graph do %__MODULE__{ vertices: vs, edges: edges, + in_edges: ie, multigraph: true, vertex_identifier: vertex_identifier, edge_index: edge_index, partition_by: partition_by }, v, - partition + by: partition ) do v2_id = vertex_identifier.(v) - key = {v2_id, partition} - edge_index - |> Map.get(key, MapSet.new()) - |> Enum.flat_map(fn {v1_id, _v2_id} = edge_key -> + in_edges_set = + ie + |> Map.get(v2_id, MapSet.new()) + |> MapSet.new(fn v1_id -> + {v1_id, v2_id} + end) + + in_edge_adjacency_set = + edge_index + |> Map.get(partition, %{}) + |> Map.get(v2_id, MapSet.new()) + |> MapSet.intersection(in_edges_set) + + Enum.flat_map(in_edge_adjacency_set, fn {v1_id, _v2_id} = edge_key -> v1 = Map.get(vs, v1_id) edges |> Map.get(edge_key, []) |> Enum.map(fn {label, edge_meta} -> - Edge.new(v1, v, label: label, weight: edge_meta.weight, properties: edge_meta.properties) - end) - |> Enum.filter(fn edge -> - partition_by.(edge) == partition + edge = + Edge.new(v1, v, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) + + edge_partition = partition_by.(edge) + + if edge_partition == partition do + edge + else + nil + end end) + |> Enum.reject(&is_nil/1) end) end @@ -2396,37 +2555,101 @@ defmodule Graph do end end - def out_edges( - %__MODULE__{ - vertices: vs, - edges: edges, - multigraph: true, - edge_index: edge_index, - vertex_identifier: vertex_identifier, - partition_by: partition_by - }, - v, - partition - ) do + @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( + %__MODULE__{ + vertices: vs, + edges: edges, + out_edges: oe, + multigraph: true, + edge_index: edge_index, + vertex_identifier: vertex_identifier, + partition_by: partition_by + }, + v, + partitions, + where_fun + ) do v1_id = vertex_identifier.(v) - key = {v1_id, partition} - # only return out_edges for which the index key returns a subset - edge_index - |> Map.get(key, MapSet.new()) - |> Enum.flat_map(fn {_v1_id, v2_id} = edge_key -> + + out_edges_set = + oe + |> Map.get(v1_id, MapSet.new()) + |> MapSet.new(fn v2_id -> + {v1_id, v2_id} + end) + + out_edge_adjacency_set = + partitions + |> Enum.reduce(MapSet.new(), fn partition, acc -> + edge_index + |> Map.get(partition, %{}) + |> Map.get(v1_id, MapSet.new()) + |> MapSet.union(acc) + end) + |> MapSet.intersection(out_edges_set) + + Enum.flat_map(out_edge_adjacency_set, fn {_v1_id, v2_id} = edge_key -> v2 = Map.get(vs, v2_id) edges |> Map.get(edge_key, []) - |> Enum.map(fn {label, edge_meta} -> - Edge.new(v, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) - end) - |> Enum.filter(fn edge -> - partition_by.(edge) == partition + |> Enum.reduce([], fn {label, edge_meta}, acc -> + edge = + Edge.new(v, v2, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) + + edge_partition = partition_by.(edge) + + if include_edge_for_filtered_partitions?(edge, edge_partition, partitions, where_fun) do + [edge | acc] + else + acc + end end) end) end + defp include_edge_for_filtered_partitions?(_edge, edge_partition, partitions, nil = _where_fun) do + edge_partition in partitions + end + + defp include_edge_for_filtered_partitions?(edge, edge_partition, partitions, where_fun) + when is_function(where_fun) do + edge_partition in partitions and where_fun.(edge) + end + + defp include_edge_for_filtered_partitions?(edge, _edge_partition, _partitions, where_fun) + when is_function(where_fun) do + where_fun.(edge) + end + @doc """ Builds a maximal subgraph of `g` which includes all of the vertices in `vs` and the edges which connect them. diff --git a/test/graph_test.exs b/test/graph_test.exs index 194f3fa..dd13988 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -33,16 +33,38 @@ defmodule GraphTest do ]) assert Enum.count(Graph.out_edges(graph, :a)) == 3 - assert [%Edge{label: :foo}] = Graph.out_edges(graph, :a, :foo) - assert [%Edge{label: :foo}] = Graph.in_edges(graph, :b, :foo) - assert [%Edge{label: :bar}] = Graph.out_edges(graph, :a, :bar) - assert [%Edge{label: nil}] = Graph.out_edges(graph, :a, nil) - assert [] == Graph.out_edges(graph, :a, :foobar) + 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 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.new(multigraph: true, partition_where: fn edge -> edge.weight end) |> Graph.add_edges([ {:a, :b}, {:a, :b, label: :foo}, @@ -52,8 +74,8 @@ defmodule GraphTest do ]) assert Enum.count(Graph.out_edges(graph, :b)) == 2 - assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, 6) - assert [%Edge{weight: 3}] = Graph.out_edges(graph, :b, 3) + assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, where: 6) + assert [%Edge{weight: 3}] = Graph.out_edges(graph, :b, where: 3) end test "removing edges prunes index" do @@ -71,7 +93,7 @@ defmodule GraphTest do refute Map.has_key?(g.edge_index, {g.vertex_identifier.(:b), {:complex, :label}}) end - test "delete_edge/3 removes only a multigraph's properties and index for the given partition key" do + 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([ @@ -82,7 +104,7 @@ defmodule GraphTest do {:b, :a, label: {:complex, :label}} ]) - g = Graph.delete_edge(g, :a, :b, :foo) |> IO.inspect(structs: false) + g = Graph.delete_edge(g, :a, :b, :foo) refute Map.has_key?(g.edge_index, {g.vertex_identifier.(:a), :foo}) refute Map.has_key?(g.edge_index, {g.vertex_identifier.(:b), :foo}) end From 971c9a5c9cedd300c9552b95d251052b944baee0 Mon Sep 17 00:00:00 2001 From: Zack White Date: Sat, 1 Mar 2025 13:23:31 -0700 Subject: [PATCH 13/22] use opt --- test/graph_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/graph_test.exs b/test/graph_test.exs index dd13988..f8109cb 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -64,7 +64,7 @@ defmodule GraphTest do test "custom edge partition_by function" do graph = - Graph.new(multigraph: true, partition_where: fn edge -> edge.weight end) + Graph.new(multigraph: true, partition_by: fn edge -> edge.weight end) |> Graph.add_edges([ {:a, :b}, {:a, :b, label: :foo}, From 7d06a074f489daab385e495d43cd2afb76d684b4 Mon Sep 17 00:00:00 2001 From: Zack White Date: Sat, 1 Mar 2025 13:26:00 -0700 Subject: [PATCH 14/22] use where filter fn --- test/graph_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/graph_test.exs b/test/graph_test.exs index f8109cb..469ea8b 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -74,8 +74,8 @@ defmodule GraphTest do ]) assert Enum.count(Graph.out_edges(graph, :b)) == 2 - assert [%Edge{weight: 6}] = Graph.out_edges(graph, :b, where: 6) - assert [%Edge{weight: 3}] = Graph.out_edges(graph, :b, where: 3) + 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 "removing edges prunes index" do From 3d65693271654af796f65790a857337a4abdf3a9 Mon Sep 17 00:00:00 2001 From: Zack White Date: Mon, 3 Mar 2025 13:06:57 -0700 Subject: [PATCH 15/22] fix edges/3 --- lib/graph.ex | 48 +++++++++++++++++++++++++++++++++++---------- test/graph_test.exs | 13 ++++++++++-- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/lib/graph.ex b/lib/graph.ex index 65c30f3..a5cdfb1 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -91,13 +91,11 @@ defmodule Graph do ### Multigraph Edge Indexing - Indexing edges trades space for time to access only edges of a kind. + 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. - When `multigraph: true` is enabled the `partition_by` of the graph is used to build a set of edge keys (`{vertex_id, vertex_id}`) under a separate key. - - This can be a useful trade-off when traversing a graph where many different kinds of edges exist between the same vertices and - you want to avoid iterating over the set of all edges. I.e. a [multigraph](https://en.wikipedia.org/wiki/Multigraph). - The index provides allows map access time to to a set of edges when managing the graph. + 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. ## Example @@ -471,7 +469,12 @@ defmodule Graph do 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 @@ -482,6 +485,16 @@ defmodule Graph do 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 | keyword()) :: [Edge.t()] @@ -555,7 +568,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 @@ -568,6 +586,16 @@ defmodule Graph do ...> g = Graph.add_edge(g, :a, :b, label: :contains) ...> Graph.edges(g, :a, :b) [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: :uses, properties: %{}}, %Graph.Edge{label: :contains, properties: %{}, v1: :a, v2: :b, weight: 1}] + + 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 | keyword()) :: [Edge.t()] def edges( @@ -671,9 +699,9 @@ defmodule Graph do |> MapSet.intersection(edges) Enum.flat_map(edge_adjacency_set, fn {_v1_id, v2_id} = edge_key -> - v2 = Map.get(g.verticies, v2_id) + v2 = Map.get(g.vertices, v2_id) - edges + g.edges |> Map.get(edge_key, []) |> Enum.reduce([], fn {label, edge_meta}, acc -> edge = diff --git a/test/graph_test.exs b/test/graph_test.exs index 469ea8b..da91d18 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -49,6 +49,11 @@ defmodule GraphTest do 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]) @@ -74,8 +79,12 @@ defmodule GraphTest do ]) 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) + + 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 "removing edges prunes index" do From ab4305ede2c9bc11feb5203a0a98e9c9a2e48d82 Mon Sep 17 00:00:00 2001 From: Zack White Date: Fri, 21 Mar 2025 14:47:39 -0600 Subject: [PATCH 16/22] update multigraph index on update/delete --- lib/graph.ex | 88 +++++++++++++++++++++++++++++---------------- test/graph_test.exs | 23 ++++++++++-- 2 files changed, 79 insertions(+), 32 deletions(-) diff --git a/lib/graph.ex b/lib/graph.ex index a5cdfb1..49784f3 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -1202,30 +1202,7 @@ defmodule Graph do if g.multigraph do edge = Edge.new(v1, v2, label: label, weight: options_meta.weight, properties: opts) - partition = g.partition_by.(edge) - - 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) - } + index_multigraph_edge(g, {v1_id, v2_id}, edge) else g end @@ -1238,6 +1215,37 @@ defmodule Graph do } end + defp index_multigraph_edge( + %__MODULE__{multigraph: true, edge_index: edge_index} = g, + {v1_id, v2_id}, + %Edge{} = edge + ) do + partition = g.partition_by.(edge) + + edge_partition = Map.get(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: + edge_index + |> Map.put(partition, new_edge_partition) + } + 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. @@ -1421,7 +1429,20 @@ defmodule Graph do _ -> new_meta = Map.put(Map.delete(meta, old_label), new_label, new_attrs) - %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + + if g.multigraph do + g = + g + |> prune_edge_index({v1_id, v1}, {v2_id, v2}, old_label) + |> index_multigraph_edge( + {v1_id, v2_id}, + Edge.new(v1, v2, label: new_label, weight: new_attrs.weight, properties: opts) + ) + + %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + else + %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + end end else _ -> @@ -1549,13 +1570,20 @@ defmodule Graph do edge_p = partition_by.(edge) - v1_key = {v1_id, edge_p} - v2_key = {v2_id, edge_p} + partition = + edge_index + |> Map.get(edge_p, %{}) + |> Map.reject(fn {k, v} -> + (k == v1_id and MapSet.member?(v, {v1_id, v2_id})) or + (k == v2_id and MapSet.member?(v, {v1_id, v2_id})) + end) edge_index = - edge_index - |> Map.delete(v1_key) - |> Map.delete(v2_key) + if not Enum.empty?(partition) do + Map.put(edge_index, edge_p, partition) + else + Map.delete(edge_index, edge_p) + end %__MODULE__{ g diff --git a/test/graph_test.exs b/test/graph_test.exs index da91d18..c3a59c9 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -114,8 +114,27 @@ defmodule GraphTest do ]) g = Graph.delete_edge(g, :a, :b, :foo) - refute Map.has_key?(g.edge_index, {g.vertex_identifier.(:a), :foo}) - refute Map.has_key?(g.edge_index, {g.vertex_identifier.(: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 "traversal using indexed keys" do From 5aaabff91d1dc3378d17c633ab4ba1427284875b Mon Sep 17 00:00:00 2001 From: Zack White Date: Tue, 6 May 2025 13:09:27 -0600 Subject: [PATCH 17/22] partition_by many keys && fix edges reflection vertex order --- lib/graph.ex | 146 +++++++++++++++++++++++--------------------- lib/graph/utils.ex | 2 +- test/graph_test.exs | 33 +++++++--- 3 files changed, 104 insertions(+), 77 deletions(-) diff --git a/lib/graph.ex b/lib/graph.ex index 49784f3..0dd8a02 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -33,7 +33,7 @@ defmodule Graph do vertices: %{}, type: :directed, vertex_identifier: &Graph.Utils.vertex_id/1, - partition_by: &Graph.Utils.edge_label/1, + partition_by: &Graph.Utils.by_edge_label/1, multigraph: false alias Graph.{Edge, EdgeSpecificationError} @@ -65,7 +65,7 @@ defmodule Graph do vertices: %{vertex_id => vertex}, type: graph_type, vertex_identifier: (vertex() -> term()), - partition_by: (Edge.t() -> edge_index_key), + partition_by: (Edge.t() -> list(edge_index_key)), multigraph: boolean() } @type graph_info :: %{ @@ -86,8 +86,8 @@ defmodule Graph do - `multigraph: true | false | fn edge -> key end`, enables edge indexing by a key. - When `true`, the key is the edge label itself. - When `false` no additional memory is used for sets of . - - `partition_by`: a function which accepts an `%Edge{}` and returns a unique identifier of said edge. - Defaults to `Graph.Utils.edge_label/1`, the edge label itself when multigraphs are enabled. + - `partition_by`: a function which accepts an `%Edge{}` and returns a list of unique identifiers used as the partition keys. + Defaults to `Graph.Utils.by_edge_label/1`, which partitions edges by the label when multigraphs are enabled. ### Multigraph Edge Indexing @@ -113,11 +113,15 @@ defmodule Graph do iex> g = Graph.new(vertex_identifier: fn v -> :erlang.phash2(v) end) |> Graph.add_edges([{:a, :b}, {:b, :a}]) ...> 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}] """ def new(opts \\ []) do type = Keyword.get(opts, :type) || :directed vertex_identifier = Keyword.get(opts, :vertex_identifier) || (&Graph.Utils.vertex_id/1) - partition_by = Keyword.get(opts, :partition_by) || (&Graph.Utils.edge_label/1) + partition_by = Keyword.get(opts, :partition_by) || (&Graph.Utils.by_edge_label/1) multigraph = Keyword.get(opts, :multigraph, false) %__MODULE__{ @@ -555,7 +559,7 @@ defmodule Graph do v2 = Map.get(vs, v2_id) for {label, meta_value} <- edge_meta do - Edge.new(v2, v, + Edge.new(v, v2, label: label, weight: meta_value.weight, properties: meta_value.properties @@ -658,9 +662,9 @@ defmodule Graph do properties: edge_meta.properties ) - edge_partition = g.partition_by.(edge) + edge_partitions = g.partition_by.(edge) - if include_edge_for_filtered_partitions?(edge, edge_partition, partitions, where_fun) do + if include_edge_for_filtered_partitions?(edge, edge_partitions, partitions, where_fun) do [edge | acc] else acc @@ -711,9 +715,9 @@ defmodule Graph do properties: edge_meta.properties ) - edge_partition = g.partition_by.(edge) + edge_partitions = g.partition_by.(edge) - if include_edge_for_filtered_partitions?(edge, edge_partition, partitions, where_fun) do + if include_edge_for_filtered_partitions?(edge, edge_partitions, partitions, where_fun) do [edge | acc] else acc @@ -1216,34 +1220,36 @@ defmodule Graph do end defp index_multigraph_edge( - %__MODULE__{multigraph: true, edge_index: edge_index} = g, + %__MODULE__{multigraph: true} = graph, {v1_id, v2_id}, %Edge{} = edge ) do - partition = g.partition_by.(edge) + partitions = graph.partition_by.(edge) - edge_partition = Map.get(edge_index, partition, %{}) + Enum.reduce(partitions, graph, fn partition, 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()) + 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}) - ) + 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: - edge_index - |> Map.put(partition, new_edge_partition) - } + %__MODULE__{ + g + | edge_index: + g.edge_index + |> Map.put(partition, new_edge_partition) + } + end) end @doc """ @@ -1530,20 +1536,22 @@ defmodule Graph do edge = Edge.new(v1, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) - edge_p = partition_by.(edge) + edge_partitions = partition_by.(edge) - v1_key = {v1_id, edge_p} - v2_key = {v2_id, edge_p} + Enum.reduce(edge_partitions, acc, fn edge_p, acc -> + v1_key = {v1_id, edge_p} + v2_key = {v2_id, edge_p} - edge_index = - edge_index - |> Map.delete(v1_key) - |> Map.delete(v2_key) + edge_index = + edge_index + |> Map.delete(v1_key) + |> Map.delete(v2_key) - %__MODULE__{ - acc - | edge_index: edge_index - } + %__MODULE__{ + acc + | edge_index: edge_index + } + end) end) end @@ -1568,27 +1576,29 @@ defmodule Graph do edge = Edge.new(v1, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) - edge_p = partition_by.(edge) + edge_partitions = partition_by.(edge) - partition = - edge_index - |> Map.get(edge_p, %{}) - |> Map.reject(fn {k, v} -> - (k == v1_id and MapSet.member?(v, {v1_id, v2_id})) or - (k == v2_id and MapSet.member?(v, {v1_id, v2_id})) - end) + Enum.reduce(edge_partitions, g, fn edge_p, acc -> + partition = + edge_index + |> Map.get(edge_p, %{}) + |> Map.reject(fn {k, v} -> + (k == v1_id and MapSet.member?(v, {v1_id, v2_id})) or + (k == v2_id and MapSet.member?(v, {v1_id, v2_id})) + end) - edge_index = - if not Enum.empty?(partition) do - Map.put(edge_index, edge_p, partition) - else - Map.delete(edge_index, edge_p) - end + edge_index = + if not Enum.empty?(partition) do + Map.put(edge_index, edge_p, partition) + else + Map.delete(edge_index, edge_p) + end - %__MODULE__{ - g - | edge_index: edge_index - } + %__MODULE__{ + acc + | edge_index: edge_index + } + end) end defp prune_edge_index(%__MODULE__{multigraph: false} = g, _v1, _v2, _label) do @@ -2527,9 +2537,9 @@ defmodule Graph do properties: edge_meta.properties ) - edge_partition = partition_by.(edge) + edge_partitions = partition_by.(edge) - if edge_partition == partition do + if Enum.any?(edge_partitions, fn edge_partition -> edge_partition == partition end) do edge else nil @@ -2681,9 +2691,9 @@ defmodule Graph do properties: edge_meta.properties ) - edge_partition = partition_by.(edge) + edges_in_partitions = partition_by.(edge) - if include_edge_for_filtered_partitions?(edge, edge_partition, partitions, where_fun) do + if include_edge_for_filtered_partitions?(edge, edges_in_partitions, partitions, where_fun) do [edge | acc] else acc @@ -2692,13 +2702,13 @@ defmodule Graph do end) end - defp include_edge_for_filtered_partitions?(_edge, edge_partition, partitions, nil = _where_fun) do - edge_partition in partitions + 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_partition, partitions, where_fun) + defp include_edge_for_filtered_partitions?(edge, edge_partitions, partitions, where_fun) when is_function(where_fun) do - edge_partition in partitions and where_fun.(edge) + Enum.any?(edge_partitions, fn ep -> ep in partitions and where_fun.(edge) end) end defp include_edge_for_filtered_partitions?(edge, _edge_partition, _partitions, where_fun) diff --git a/lib/graph/utils.ex b/lib/graph/utils.ex index 2880961..2260fc2 100644 --- a/lib/graph/utils.ex +++ b/lib/graph/utils.ex @@ -111,5 +111,5 @@ defmodule Graph.Utils do @max_phash 4_294_967_296 def vertex_id(v), do: :erlang.phash2(v, @max_phash) - def edge_label(%{label: label}), do: label + def by_edge_label(%{label: label}), do: [label] end diff --git a/test/graph_test.exs b/test/graph_test.exs index c3a59c9..fd10db1 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -69,7 +69,7 @@ defmodule GraphTest do test "custom edge partition_by function" do graph = - Graph.new(multigraph: true, partition_by: fn edge -> edge.weight end) + Graph.new(multigraph: true, partition_by: fn edge -> [edge.weight] end) |> Graph.add_edges([ {:a, :b}, {:a, :b, label: :foo}, @@ -87,6 +87,25 @@ defmodule GraphTest do 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) @@ -300,13 +319,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 From 416f99e002cebe82f5640014bc2c79dcf318dcd1 Mon Sep 17 00:00:00 2001 From: Zack White Date: Mon, 22 Sep 2025 11:12:38 -0600 Subject: [PATCH 18/22] pass properties through add_edge of struct --- lib/graph.ex | 10 ++++++++-- test/graph_test.exs | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/graph.ex b/lib/graph.ex index 0dd8a02..285689f 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -1139,8 +1139,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 """ diff --git a/test/graph_test.exs b/test/graph_test.exs index fd10db1..679eb17 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -156,8 +156,30 @@ defmodule GraphTest do assert Map.has_key?(g.edge_index[:baz], g.vertex_identifier.(:a)) end - test "traversal using indexed keys" do - end + # test "BFS traversal using multigraph partitions" do + # graph = + # Graph.new(multigraph: true) + # |> Graph.add_edges([ + # {:a, :b, label: :foo}, + # {:a, :b, label: :bar}, + # {:b, :c, weight: 3}, + # {:b, :a, label: {:complex, :label}} + # ]) + + # assert Graph.bfs(graph, :a) == [:a, :b, :c] + # end + + # test "DFS traversal using multigraph partitions" do + # end + + # test "Dijkstra traversal using multigraph partitions" do + # end + + # test "A* traversal using multigraph partitions" do + # end + + # test "Bellman-Ford traversal using multigraph partitions" do + # end end describe "edge properties" do @@ -190,6 +212,19 @@ defmodule GraphTest do %Edge{v1: :a, v2: :b, label: :foo, properties: %{potato: :ham}} ] = Graph.out_edges(g, :a) 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 From d501b532da6cdc81f27440805b83348fe5443735 Mon Sep 17 00:00:00 2001 From: Zack White Date: Fri, 20 Feb 2026 17:39:41 -0700 Subject: [PATCH 19/22] fix edge index pruning --- lib/graph.ex | 46 ++++++++++++++++++++++++++++++++------------- test/graph_test.exs | 45 +++++++++++++++++++++----------------------- 2 files changed, 54 insertions(+), 37 deletions(-) diff --git a/lib/graph.ex b/lib/graph.ex index 285689f..c0056bf 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -1528,7 +1528,6 @@ defmodule Graph do defp prune_edge_index( %__MODULE__{ multigraph: true, - edge_index: edge_index, edges: meta, partition_by: partition_by } = g, @@ -1548,14 +1547,14 @@ defmodule Graph do v1_key = {v1_id, edge_p} v2_key = {v2_id, edge_p} - edge_index = - edge_index + updated_edge_index = + acc.edge_index |> Map.delete(v1_key) |> Map.delete(v2_key) %__MODULE__{ acc - | edge_index: edge_index + | edge_index: updated_edge_index } end) end) @@ -1564,7 +1563,6 @@ defmodule Graph do defp prune_edge_index( %__MODULE__{ multigraph: true, - edge_index: edge_index, edges: meta, partition_by: partition_by } = g, @@ -1584,25 +1582,47 @@ defmodule Graph do edge_partitions = partition_by.(edge) + edge_key = {v1_id, v2_id} + Enum.reduce(edge_partitions, g, fn edge_p, acc -> partition = - edge_index + acc.edge_index |> Map.get(edge_p, %{}) - |> Map.reject(fn {k, v} -> - (k == v1_id and MapSet.member?(v, {v1_id, v2_id})) or - (k == v2_id and MapSet.member?(v, {v1_id, v2_id})) + |> Enum.reduce(%{}, fn {k, v}, new_partition -> + cond do + k == v1_id -> + remaining = MapSet.delete(v, edge_key) + + if MapSet.size(remaining) > 0 do + Map.put(new_partition, k, remaining) + else + new_partition + end + + k == v2_id -> + remaining = MapSet.delete(v, edge_key) + + if MapSet.size(remaining) > 0 do + Map.put(new_partition, k, remaining) + else + new_partition + end + + true -> + Map.put(new_partition, k, v) + end end) - edge_index = + updated_edge_index = if not Enum.empty?(partition) do - Map.put(edge_index, edge_p, partition) + Map.put(acc.edge_index, edge_p, partition) else - Map.delete(edge_index, edge_p) + Map.delete(acc.edge_index, edge_p) end %__MODULE__{ acc - | edge_index: edge_index + | edge_index: updated_edge_index } end) end diff --git a/test/graph_test.exs b/test/graph_test.exs index 679eb17..948af25 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -156,30 +156,27 @@ defmodule GraphTest do assert Map.has_key?(g.edge_index[:baz], g.vertex_identifier.(:a)) end - # test "BFS traversal using multigraph partitions" do - # graph = - # Graph.new(multigraph: true) - # |> Graph.add_edges([ - # {:a, :b, label: :foo}, - # {:a, :b, label: :bar}, - # {:b, :c, weight: 3}, - # {:b, :a, label: {:complex, :label}} - # ]) - - # assert Graph.bfs(graph, :a) == [:a, :b, :c] - # end - - # test "DFS traversal using multigraph partitions" do - # end - - # test "Dijkstra traversal using multigraph partitions" do - # end - - # test "A* traversal using multigraph partitions" do - # end - - # test "Bellman-Ford traversal using multigraph partitions" do - # 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 end describe "edge properties" do From 8381eaf3e9961e4ec7116e9a1135b13ae5958974 Mon Sep 17 00:00:00 2001 From: Zack White Date: Fri, 20 Feb 2026 20:05:51 -0700 Subject: [PATCH 20/22] cover multigraph support, add benchmarks, & document --- .github/workflows/elixir.yml | 6 +- README.md | 87 ++++++ bench/multigraph.exs | 53 ++++ bench/multigraph_creation.exs | 35 +++ bench/multigraph_memory.exs | 49 ++++ lib/edge.ex | 16 +- lib/graph.ex | 386 +++++++++++++++++-------- lib/graph/pathfinding.ex | 222 ++++++++++---- lib/graph/pathfindings/bellman_ford.ex | 36 ++- lib/graph/reducers/bfs.ex | 69 ++++- lib/graph/reducers/dfs.ex | 86 +++++- lib/graph/utils.ex | 42 ++- mix.exs | 16 +- mix.lock | 17 +- test/graph_test.exs | 211 +++++++++++++- test/multigraph_model_test.exs | 343 ++++++++++++++++++++++ test/utils_test.exs | 2 +- 17 files changed, 1452 insertions(+), 224 deletions(-) create mode 100644 bench/multigraph.exs create mode 100644 bench/multigraph_creation.exs create mode 100644 bench/multigraph_memory.exs create mode 100644 test/multigraph_model_test.exs diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index ef7b1df..b1638c4 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -13,14 +13,14 @@ jobs: - elixir: 1.14.5 otp: 24.3 - - elixir: 1.15.4 + - elixir: 1.15.8 otp: 25.3 - elixir: 1.16.3 otp: 26.2 - - otp: 27.2 - elixir: 1.18.1 + - elixir: 1.18.2 + otp: 27.2 steps: - uses: actions/checkout@v2 - uses: erlef/setup-beam@v1 diff --git a/README.md b/README.md index 728785d..2e09cff 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,93 @@ def deps do end ``` +## 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 allows O(1) lookup of edges by partition key, avoiding full edge scans. + +### 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 now support an arbitrary `properties` map for storing additional metadata: + +```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/lib/edge.ex b/lib/edge.ex index 7236374..e76ece3 100644 --- a/lib/edge.ex +++ b/lib/edge.ex @@ -25,13 +25,21 @@ defmodule Graph.Edge do @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 diff --git a/lib/graph.ex b/lib/graph.ex index c0056bf..45264f9 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -16,14 +16,33 @@ defmodule Graph do - A map of vertex ids to their in neighbors (`in_edges`), effectively the transposition of `out_edges` - A map of vertex ids to vertex labels (`vertex_labels`), (labels are only stored if a non-nil label was provided) - A map of edge ids (where an edge id is simply a tuple of `{vertex_id, vertex_id}`) to a map of edge metadata (`edges`) - - Edge metadata is a map of `label => weight`, and each entry in that map represents a distinct edge. This allows - us to support multiple edges in the same direction between the same pair of vertices, but for many purposes simply - treat them as a single logical edge. + - Edge metadata is a map of `label => %{weight: weight, properties: properties}`, and each entry in that map + represents a distinct edge. This allows us to support multiple edges in the same direction between the same + pair of vertices, but for many purposes simply treat them as a single logical edge. This structure is designed to be as efficient as possible once a graph is built, but it turned out that it is also 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 `Graph.Utils.by_edge_label/1`, which partitions by edge label). + + The index structure is `%{partition_key => %{vertex_id => MapSet.t(edge_key)}}`, enabling O(1) map-access + retrieval of edges by partition, avoiding O(E) scans over all edges. + + 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: %{}, @@ -83,9 +102,9 @@ 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 `Graph.Utils.vertex_id/1`, a hash of the whole vertex utilizing `:erlang.phash2/2`. - - `multigraph: true | false | fn edge -> key end`, enables edge indexing by a key. - - When `true`, the key is the edge label itself. - - When `false` no additional memory is used for sets of . + - `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 `Graph.Utils.by_edge_label/1`, which partitions edges by the label when multigraphs are enabled. @@ -372,6 +391,27 @@ 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 @@ -390,6 +430,27 @@ defmodule Graph do @spec bellman_ford(t, vertex) :: [vertex] 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] + 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`. @@ -416,6 +477,28 @@ defmodule Graph do @spec a_star(t, vertex, vertex, (vertex, vertex -> integer)) :: [vertex] 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, vertex -> integer), keyword) :: [vertex] + 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`. @@ -450,7 +533,7 @@ 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}] """ @@ -483,7 +566,7 @@ defmodule Graph do ## 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}]) @@ -583,13 +666,13 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edge(:a, :b, label: :uses) ...> g = Graph.add_edge(g, :a, :b, label: :contains) - ...> Graph.edges(g, :a, :b) - [%Graph.Edge{v1: :a, v2: :b, label: :uses}, %Graph.Edge{v1: :a, v2: :b, label: :contains}] + ...> Graph.edges(g, :a, :b) |> Enum.sort_by(& &1.label) + [%Graph.Edge{v1: :a, v2: :b, label: :contains}, %Graph.Edge{v1: :a, v2: :b, label: :uses}] iex> g = Graph.new(type: :undirected) |> Graph.add_edge(:a, :b, label: :uses) ...> g = Graph.add_edge(g, :a, :b, label: :contains) - ...> Graph.edges(g, :a, :b) - [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: :uses, properties: %{}}, %Graph.Edge{label: :contains, properties: %{}, v1: :a, v2: :b, weight: 1}] + ...> Graph.edges(g, :a, :b) |> Enum.sort_by(& &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) @@ -994,10 +1077,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} @@ -1091,19 +1181,20 @@ 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 ls = g.vertex_labels with v_id <- vertex_identifier.(v), - true <- Map.has_key?(vs, v_id), - oe <- Map.delete(oe, v_id), - ie <- Map.delete(ie, v_id), - vs <- Map.delete(vs, v_id), - ls <- Map.delete(ls, v_id) do + true <- Map.has_key?(vs, v_id) do + g = prune_vertex_from_edge_index(g, v_id, v) + + oe = Map.delete(g.out_edges, v_id) + ie = Map.delete(g.in_edges, v_id) + vs = Map.delete(vs, v_id) + ls = Map.delete(ls, v_id) oe = for {id, ns} <- oe, do: {id, MapSet.delete(ns, v_id)}, into: %{} ie = for {id, ns} <- ie, do: {id, MapSet.delete(ns, v_id)}, into: %{} em = for {{id1, id2}, _} = e <- em, v_id != id1 && v_id != id2, do: e, into: %{} @@ -1275,12 +1366,12 @@ 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}]) - ...> Graph.edges(g) - [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 2}] + ...> Graph.edges(g) |> Enum.sort_by(& {&1.v1, &1.v2, &1.label}) + [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}, %Graph.Edge{v1: :a, v2: :b}] iex> Graph.new |> Graph.add_vertices([:a, :b, :c]) |> Graph.add_edges([:a, :b]) ** (Graph.EdgeSpecificationError) Expected a valid edge specification, but got: :a @@ -1349,10 +1440,13 @@ 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 = 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) + | 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}) } g = add_vertex(g, v3) @@ -1377,8 +1471,8 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edge(:a, :b) |> Graph.add_edge(:a, :b, label: :bar) ...> %Graph{} = g = Graph.update_edge(g, :a, :b, weight: 2, label: :foo) - ...> Graph.edges(g) - [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}, %Graph.Edge{v1: :a, v2: :b, label: :bar}] + ...> Graph.edges(g) |> Enum.sort_by(& &1.label) + [%Graph.Edge{v1: :a, v2: :b, label: :bar}, %Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}] """ @spec update_edge(t, vertex, vertex, Edge.edge_opts()) :: t | {:error, :no_such_edge} def update_edge(%__MODULE__{} = g, v1, v2, opts) when is_list(opts) do @@ -1394,13 +1488,13 @@ defmodule Graph do iex> g = Graph.new |> Graph.add_edge(:a, :b) |> Graph.add_edge(:a, :b, label: :bar) ...> %Graph{} = g = Graph.update_labelled_edge(g, :a, :b, :bar, weight: 2, label: :foo) - ...> Graph.edges(g) - [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 2}] + ...> Graph.edges(g) |> Enum.sort_by(& &1.label) + [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}, %Graph.Edge{v1: :a, v2: :b}] iex> g = Graph.new(type: :undirected) |> Graph.add_edge(:a, :b) |> Graph.add_edge(:a, :b, label: :bar) ...> %Graph{} = g = Graph.update_labelled_edge(g, :a, :b, :bar, weight: 2, label: :foo) - ...> Graph.edges(g) - [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 2}] + ...> Graph.edges(g) |> Enum.sort_by(& &1.label) + [%Graph.Edge{v1: :a, v2: :b, label: :foo, weight: 2}, %Graph.Edge{v1: :a, v2: :b}] """ @spec update_labelled_edge(t, vertex, vertex, label, Edge.edge_opts()) :: t | {:error, :no_such_edge} @@ -1509,7 +1603,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 = prune_edge_index(g, {v1_id, v1}, {v2_id, v2}, nil) + g = 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) @@ -1525,81 +1619,63 @@ defmodule Graph do end end - defp prune_edge_index( - %__MODULE__{ - multigraph: true, - edges: meta, - partition_by: partition_by - } = g, + # 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} = g, {v1_id, v1}, - {v2_id, v2}, - nil + {v2_id, v2} ) do + edge_key = {v1_id, v2_id} + meta - |> Map.get({v1_id, v2_id}) + |> Map.get(edge_key, %{}) |> Enum.reduce(g, fn {label, edge_meta}, acc -> edge = Edge.new(v1, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) - edge_partitions = partition_by.(edge) - - Enum.reduce(edge_partitions, acc, fn edge_p, acc -> - v1_key = {v1_id, edge_p} - v2_key = {v2_id, edge_p} - - updated_edge_index = - acc.edge_index - |> Map.delete(v1_key) - |> Map.delete(v2_key) - - %__MODULE__{ - acc - | edge_index: updated_edge_index - } - end) + 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 - } = g, + %__MODULE__{multigraph: true, edges: meta, partition_by: partition_by} = g, {v1_id, v1}, {v2_id, v2}, label ) do - [{_label, edge_meta} | _] = - meta - |> Map.get({v1_id, v2_id}) - |> Enum.filter(fn {edge_label, _v} -> - edge_label == label - end) + edge_key = {v1_id, v2_id} - edge = - Edge.new(v1, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) + case meta |> Map.get(edge_key, %{}) |> Map.fetch(label) do + {:ok, edge_meta} -> + edge = + Edge.new(v1, v2, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) - edge_partitions = partition_by.(edge) + prune_edge_key_from_partitions(g, edge_key, v1_id, v2_id, partition_by.(edge)) - edge_key = {v1_id, v2_id} + :error -> + g + end + end - Enum.reduce(edge_partitions, g, fn edge_p, acc -> + defp prune_edge_key_from_partitions(g, edge_key, v1_id, v2_id, partitions) do + Enum.reduce(partitions, g, fn edge_p, acc -> partition = acc.edge_index |> Map.get(edge_p, %{}) |> Enum.reduce(%{}, fn {k, v}, new_partition -> cond do - k == v1_id -> - remaining = MapSet.delete(v, edge_key) - - if MapSet.size(remaining) > 0 do - Map.put(new_partition, k, remaining) - else - new_partition - end - - k == v2_id -> + k == v1_id or k == v2_id -> remaining = MapSet.delete(v, edge_key) if MapSet.size(remaining) > 0 do @@ -1614,21 +1690,37 @@ defmodule Graph do end) updated_edge_index = - if not Enum.empty?(partition) do + if partition != %{} do Map.put(acc.edge_index, edge_p, partition) else Map.delete(acc.edge_index, edge_p) end - %__MODULE__{ - acc - | edge_index: updated_edge_index - } + %__MODULE__{acc | edge_index: updated_edge_index} end) end - defp prune_edge_index(%__MODULE__{multigraph: false} = g, _v1, _v2, _label) do - g + 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 """ @@ -1831,16 +1923,26 @@ 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} = 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) + + %__MODULE__{g | in_edges: oe, out_edges: ie, edges: meta2, edge_index: ei2} end @doc """ @@ -2487,8 +2589,8 @@ defmodule Graph do ## Example iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:a, :b, label: :foo}, {:b, :c}]) - ...> Graph.in_edges(g, :b) - [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 1}] + ...> Graph.in_edges(g, :b) |> Enum.sort_by(& &1.label) + [%Graph.Edge{v1: :a, v2: :b, label: :foo}, %Graph.Edge{v1: :a, v2: :b}] """ @spec in_edges(t, vertex) :: Edge.t() def in_edges(%__MODULE__{type: :undirected} = g, v) do @@ -2522,6 +2624,19 @@ 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__{ vertices: vs, @@ -2611,8 +2726,8 @@ defmodule Graph do ## Example iex> g = Graph.new |> Graph.add_edges([{:a, :b}, {:a, :b, label: :foo}, {:b, :c}]) - ...> Graph.out_edges(g, :a) - [%Graph.Edge{v1: :a, v2: :b, weight: 1, label: nil, properties: %{}}, %Graph.Edge{label: :foo, properties: %{}, v1: :a, v2: :b, weight: 1}] + ...> Graph.out_edges(g, :a) |> Enum.sort_by(& &1.label) + [%Graph.Edge{v1: :a, v2: :b, label: :foo}, %Graph.Edge{v1: :a, v2: :b}] """ @spec out_edges(t, vertex) :: Edge.t() def out_edges(%__MODULE__{type: :undirected} = g, v) do @@ -2647,6 +2762,27 @@ 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 @@ -2754,7 +2890,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 @@ -2764,28 +2902,32 @@ defmodule Graph do |> Enum.filter(&Map.has_key?(vertices, &1)) |> MapSet.new() - Enum.reduce(allowed, Graph.new(type: type), 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, edge_meta}, sg -> - Graph.add_edge(sg, v, v2, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) + Enum.reduce( + allowed, + Graph.new(type: type, multigraph: multigraph, partition_by: partition_by), + 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, edge_meta}, sg -> + Graph.add_edge(sg, v, v2, + label: label, + weight: edge_meta.weight, + properties: edge_meta.properties + ) + end) end) - end) - end) + end + ) end end diff --git a/lib/graph/pathfinding.ex b/lib/graph/pathfinding.ex index b21724a..b94af26 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. @@ -23,6 +26,10 @@ defmodule Graph.Pathfinding 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. @@ -37,28 +44,70 @@ defmodule Graph.Pathfinding do 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 + 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 `nil` if no path can be found. + """ + @spec all(Graph.t(), Graph.vertex(), Graph.vertex()) :: [[Graph.vertex()]] | nil + 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], []) 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 = Graph.new(vertex_identifier: vertex_identifier) |> Graph.add_vertex(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 +115,31 @@ 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 = Graph.new(vertex_identifier: vertex_identifier) |> Graph.add_vertex(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,29 +148,35 @@ defmodule Graph.Pathfinding do end end - @doc """ - Finds all paths between `a` and `b`, each path as a list of vertices. - Returns `nil` if no path can be found. - """ - @spec all(Graph.t(), Graph.vertex(), Graph.vertex()) :: [[Graph.vertex()]] | nil - 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], []) 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()) @@ -130,16 +184,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, vertex_identifier: vertex_identifier} = g, + %Graph{type: :directed, vertex_identifier: vertex_identifier} = g, target_id, %Graph{vertices: vs_tree} = tree, - hfun + hfun, + partitions ) do case PriorityQueue.pop(q) do {{:value, {v_id, ^target_id, _}}, _q1} -> @@ -150,11 +241,11 @@ defmodule Graph.Pathfinding do v2_id_tree = vertex_identifier.(v2_id) if Map.has_key?(vs_tree, v2_id_tree) 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 = @@ -164,16 +255,18 @@ defmodule Graph.Pathfinding do 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 @@ -187,7 +280,8 @@ defmodule Graph.Pathfinding do %Graph{type: :undirected, vertex_identifier: vertex_identifier} = g, target_id, %Graph{vertices: vs_tree} = tree, - hfun + hfun, + partitions ) do case PriorityQueue.pop(q) do {{:value, {v_id, ^target_id, _}}, _q1} -> @@ -198,12 +292,12 @@ defmodule Graph.Pathfinding do v2_id_tree = vertex_identifier.(v2_id) if Map.has_key?(vs_tree, v2_id_tree) 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 = tree @@ -211,17 +305,19 @@ defmodule Graph.Pathfinding do |> Graph.add_edge(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 985018c..c1104f3 100644 --- a/lib/graph/pathfindings/bellman_ford.ex +++ b/lib/graph/pathfindings/bellman_ford.ex @@ -12,10 +12,25 @@ defmodule Graph.Pathfindings.BellmanFord do Returns nil when graph has negative cycle. """ @spec call(Graph.t(), Graph.vertex()) :: %{Graph.vertex() => integer() | :infinity} | nil - def call(%Graph{vertices: vs, edges: meta} = g, a) do + 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 |> Graph.Utils.vertex_id() |> 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 +46,23 @@ 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}, partitions) do + Enum.flat_map(meta, fn {edge_key, edge_value} -> + edge_value + |> Enum.filter(fn {label, %{weight: weight, properties: properties}} -> + eps = partition_by.(%{label: label, weight: weight, properties: properties}) + Enum.any?(eps, fn ep -> ep in partitions end) + end) + |> Enum.map(fn {_label, %{weight: 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/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 2260fc2..4df0b28 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,6 +107,46 @@ defmodule Graph.Utils do end end + def edge_weight( + %Graph{type: :directed, edges: meta, partition_by: partition_by}, + a, + b, + partitions + ) do + meta + |> Map.fetch!({a, b}) + |> filter_by_partitions(partition_by, partitions) + |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> Enum.min() + end + + def edge_weight( + %Graph{type: :undirected, edges: meta, partition_by: partition_by}, + a, + b, + partitions + ) do + edge_meta = Map.get(meta, {a, b}) || Map.get(meta, {b, a}) + + case edge_meta do + nil -> + [] + + edge_meta when is_map(edge_meta) -> + edge_meta + |> filter_by_partitions(partition_by, partitions) + |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> Enum.min() + end + end + + defp filter_by_partitions(edge_meta, partition_by, partitions) do + Enum.filter(edge_meta, fn {label, %{weight: weight, properties: properties}} -> + eps = partition_by.(%{label: label, weight: weight, properties: properties}) + 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) diff --git a/mix.exs b/mix.exs index 568103a..c2f6091 100644 --- a/mix.exs +++ b/mix.exs @@ -21,7 +21,10 @@ defmodule Graph.Mixfile do "coveralls.detail": :test, "coveralls.post": :test, docs: :docs, - bench: :bench + bench: :bench, + "bench.multigraph": :bench, + "bench.multigraph_creation": :bench, + "bench.multigraph_memory": :bench ] ] end @@ -53,7 +56,9 @@ defmodule Graph.Mixfile do {:excoveralls, "~> 0.7", only: [:test]}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, ">= 0.0.0", only: :dev}, - {:ssl_verify_fun, "~> 1.1", manager: :rebar3, only: [:test], override: true} + {:ssl_verify_fun, "~> 1.1", manager: :rebar3, only: [:test], override: true}, + {:tidewave, "~> 0.4", only: :dev}, + {:bandit, "~> 1.0", only: :dev} ] end @@ -70,7 +75,12 @@ 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"], + tidewave: + "run --no-halt -e 'Agent.start(fn -> Bandit.start_link(plug: Tidewave, port: 4000) end)'" ] end diff --git a/mix.lock b/mix.lock index 5101bbb..87a7650 100644 --- a/mix.lock +++ b/mix.lock @@ -1,24 +1,39 @@ %{ + "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "benchee": {:hex, :benchee, "1.1.0", "f3a43817209a92a1fade36ef36b86e1052627fd8934a8b937ac9ab3a76c43062", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}], "hexpm", "7da57d545003165a012b587077f6ba90b89210fd88074ce3c60ce239eb5e6d93"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ex_doc": {:hex, :ex_doc, "0.28.5", "3e52a6d2130ce74d096859e477b97080c156d0926701c13870a4e1f752363279", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d2c4b07133113e9aa3e9ba27efb9088ba900e9e51caa383919676afdf09ab181"}, "excoveralls": {:hex, :excoveralls, "0.14.6", "610e921e25b180a8538229ef547957f7e04bd3d3e9a55c7c5b7d24354abbba70", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "0eceddaa9785cfcefbf3cd37812705f9d8ad34a758e513bb975b081dce4eb11e"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "tidewave": {:hex, :tidewave, "0.5.5", "a125dfc87f99daf0e2280b3a9719b874c616ead5926cdf9cdfe4fcc19a020eff", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "825ebb4fa20de005785efa21e5a88c04d81c3f57552638d12ff3def2f203dbf7"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, } diff --git a/test/graph_test.exs b/test/graph_test.exs index 948af25..f8947f8 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -118,7 +118,10 @@ defmodule GraphTest do ]) g = Graph.delete_edges(g, [{:b, :c}, {:b, :a}]) - refute Map.has_key?(g.edge_index, {g.vertex_identifier.(:b), {:complex, :label}}) + 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 @@ -177,6 +180,212 @@ defmodule GraphTest do 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 diff --git a/test/multigraph_model_test.exs b/test/multigraph_model_test.exs new file mode 100644 index 0000000..b0275a7 --- /dev/null +++ b/test/multigraph_model_test.exs @@ -0,0 +1,343 @@ +defmodule Graph.Multigraph.Model.Test do + use ExUnit.Case, async: true + use ExUnitProperties + + @moduletag timeout: :infinity + + @labels [:foo, :bar, :baz, :qux, nil] + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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 + + property "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/utils_test.exs b/test/utils_test.exs index f72724c..553ce4c 100644 --- a/test/utils_test.exs +++ b/test/utils_test.exs @@ -7,7 +7,7 @@ defmodule Graph.UtilsTest do test "sizeof/1" do assert 64 = sizeof({1, :foo, "bar"}) - assert 456 = sizeof(String.duplicate("bar", 128)) + assert sizeof(String.duplicate("bar", 128)) in [440, 456] assert 8 = sizeof([]) assert 24 = sizeof([1 | 2]) assert 56 = sizeof([1, 2, 3]) From 6e66639fa404989577490347a69cb3fddc20ee3f Mon Sep 17 00:00:00 2001 From: Zack White Date: Fri, 20 Feb 2026 20:30:10 -0700 Subject: [PATCH 21/22] move edge properties to field to prevent perf regressions --- lib/edge.ex | 9 +- lib/graph.ex | 305 +++++++++++++++---------- lib/graph/pathfindings/bellman_ford.ex | 20 +- lib/graph/serializers/dot.ex | 4 +- lib/graph/serializers/flowchart.ex | 4 +- lib/graph/utils.ex | 37 +-- 6 files changed, 228 insertions(+), 151 deletions(-) diff --git a/lib/edge.ex b/lib/edge.ex index e76ece3..630b6ec 100644 --- a/lib/edge.ex +++ b/lib/edge.ex @@ -63,13 +63,12 @@ defmodule Graph.Edge do def options_to_meta(opts) when is_list(opts) do label = Keyword.get(opts, :label) weight = Keyword.get(opts, :weight, 1) - properties = Keyword.get(opts, :properties, %{}) - case {label, %{weight: weight, properties: properties}} do - {label, %{weight: w} = meta} when is_number(w) -> - {label, meta} + case {label, weight} do + {_, w} = meta when is_number(w) -> + meta - _other -> + {_, _} -> raise ArgumentError, message: "invalid value for :weight, must be an integer" end end diff --git a/lib/graph.ex b/lib/graph.ex index 45264f9..d72db49 100644 --- a/lib/graph.ex +++ b/lib/graph.ex @@ -16,9 +16,9 @@ defmodule Graph do - A map of vertex ids to their in neighbors (`in_edges`), effectively the transposition of `out_edges` - A map of vertex ids to vertex labels (`vertex_labels`), (labels are only stored if a non-nil label was provided) - A map of edge ids (where an edge id is simply a tuple of `{vertex_id, vertex_id}`) to a map of edge metadata (`edges`) - - Edge metadata is a map of `label => %{weight: weight, properties: properties}`, and each entry in that map - represents a distinct edge. This allows us to support multiple edges in the same direction between the same - pair of vertices, but for many purposes simply treat them as a single logical edge. + - Edge metadata is a map of `label => weight`, and each entry in that map represents a distinct edge. This allows + us to support multiple edges in the same direction between the same pair of vertices, but for many purposes simply + treat them as a single logical edge. This structure is designed to be as efficient as possible once a graph is built, but it turned out that it is also quite efficient for manipulating the graph as well. For example, splitting an edge and introducing a new vertex on that @@ -48,6 +48,7 @@ defmodule Graph do out_edges: %{}, edges: %{}, edge_index: %{}, + edge_properties: %{}, vertex_labels: %{}, vertices: %{}, type: :directed, @@ -65,14 +66,8 @@ defmodule Graph do @type label :: term @type edge_weight :: integer | float @type edge_key :: {vertex_id, vertex_id} - # @type edge_value :: %{label => edge_weight} + @type edge_value :: %{label => edge_weight} @type edge_index_key :: label | term - @type edge_properties :: %{ - label: label, - weight: edge_weight, - properties: map - } - @type edge_value :: %{label => edge_properties()} @type graph_type :: :directed | :undirected @type vertices :: %{vertex_id => vertex} @type t :: %__MODULE__{ @@ -538,7 +533,7 @@ defmodule Graph do """ @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) @@ -546,10 +541,12 @@ 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: weight, properties: properties}} -> - Edge.new(source, target, label: label, weight: weight, properties: properties) + Enum.map(meta, fn {label, weight} -> + props = get_edge_props(ep, edge_key, label) + Edge.new(source, target, label: label, weight: weight, properties: props) end) end) end) @@ -604,7 +601,8 @@ defmodule Graph do out_edges: oe, edges: meta, vertices: vs, - vertex_identifier: vertex_identifier + vertex_identifier: vertex_identifier, + edge_properties: ep }, v ) do @@ -615,38 +613,36 @@ defmodule Graph do e_in = Enum.flat_map(v_all, 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 -> [] edge_meta when is_map(edge_meta) -> v2 = Map.get(vs, v2_id) - for {label, meta_value} <- edge_meta do - Edge.new(v2, v, - label: label, - weight: meta_value.weight, - properties: meta_value.properties - ) + for {label, weight} <- edge_meta do + 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 -> [] edge_meta when is_map(edge_meta) -> v2 = Map.get(vs, v2_id) - for {label, meta_value} <- edge_meta do - Edge.new(v, v2, - label: label, - weight: meta_value.weight, - properties: meta_value.properties - ) + for {label, weight} <- edge_meta do + props = get_edge_props(ep, edge_key, label) + Edge.new(v, v2, label: label, weight: weight, properties: props) end end end) @@ -737,14 +733,9 @@ defmodule Graph do g.edges |> Map.get(edge_key, []) - |> Enum.reduce([], fn {label, edge_meta}, acc -> - edge = - Edge.new(v1, v2, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) - + |> 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 @@ -790,14 +781,9 @@ defmodule Graph do g.edges |> Map.get(edge_key, []) - |> Enum.reduce([], fn {label, edge_meta}, acc -> - edge = - Edge.new(v1, v2, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) - + |> 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 @@ -815,19 +801,26 @@ defmodule Graph 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, :undirected) do - for {label, %{weight: weight, properties: properties}} <- edge_meta do + for {label, weight} <- edge_meta do if v1 > v2 do - Edge.new(v2, v1, label: label, weight: weight, properties: properties) + Edge.new(v2, v1, label: label, weight: weight) else - Edge.new(v1, v2, label: label, weight: weight, properties: properties) + Edge.new(v1, v2, label: label, weight: weight) end end end defp edge_list(v1, v2, edge_meta, _) do - for {label, %{weight: weight, properties: properties}} <- edge_meta do - Edge.new(v1, v2, label: label, weight: weight, properties: properties) + for {label, weight} <- edge_meta do + Edge.new(v1, v2, label: label, weight: weight) end end @@ -870,13 +863,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: weight, properties: properties}} <- Map.fetch(edge_meta, label) do - Edge.new(v1, v2, label: label, weight: weight, properties: properties) + {:ok, weight} <- Map.fetch(edge_meta, label) do + props = get_edge_props(ep, edge_key, label) + Edge.new(v1, v2, label: label, weight: weight, properties: props) else _ -> nil @@ -1280,7 +1279,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 = @@ -1296,14 +1295,24 @@ defmodule Graph do end edge_meta = Map.get(meta, {v1_id, v2_id}, %{}) - {label, options_meta} = Edge.options_to_meta(opts) - edge_meta = Map.put(edge_meta, label, options_meta) + {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 = if g.multigraph do - edge = Edge.new(v1, v2, label: label, weight: options_meta.weight, properties: opts) - - index_multigraph_edge(g, {v1_id, v2_id}, edge) + edge = Edge.new(v1, v2, label: label, weight: weight, properties: properties) + index_multigraph_edge(g, edge_key, edge) else g end @@ -1312,7 +1321,8 @@ defmodule Graph do 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 @@ -1451,10 +1461,12 @@ defmodule Graph do g = add_vertex(g, v3) - Enum.reduce(meta, g, fn {label, %{weight: weight, properties: properties}}, acc -> + Enum.reduce(meta, g, fn {label, weight}, acc -> + props = get_edge_props(g.edge_properties, {v1_id, v2_id}, label) + acc - |> add_edge(v1, v3, label: label, weight: weight, properties: properties) - |> add_edge(v3, v2, label: label, weight: weight, properties: properties) + |> 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} @@ -1523,18 +1535,48 @@ defmodule Graph do edge_key <- {v1_id, v2_id}, {:ok, meta} <- Map.fetch(em, edge_key), {:ok, _} <- Map.fetch(meta, old_label), - {new_label, new_attrs} <- Edge.options_to_meta(opts) do + {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_attrs) - %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + new_meta = Map.put(meta, old_label, new_weight) + %__MODULE__{g | edges: Map.put(em, edge_key, new_meta), edge_properties: ep} nil -> - new_meta = Map.put(meta, old_label, new_attrs) - %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + new_meta = Map.put(meta, old_label, new_weight) + %__MODULE__{g | edges: Map.put(em, edge_key, new_meta), edge_properties: ep} _ -> - new_meta = Map.put(Map.delete(meta, old_label), new_label, new_attrs) + new_meta = Map.put(Map.delete(meta, old_label), new_label, new_weight) + + # 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 if g.multigraph do g = @@ -1542,12 +1584,16 @@ defmodule Graph do |> prune_edge_index({v1_id, v1}, {v2_id, v2}, old_label) |> index_multigraph_edge( {v1_id, v2_id}, - Edge.new(v1, v2, label: new_label, weight: new_attrs.weight, properties: opts) + Edge.new(v1, v2, + label: new_label, + weight: new_weight, + properties: new_properties + ) ) - %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + %__MODULE__{g | edges: Map.put(em, edge_key, new_meta), edge_properties: ep} else - %__MODULE__{g | edges: Map.put(em, edge_key, new_meta)} + %__MODULE__{g | edges: Map.put(em, edge_key, new_meta), edge_properties: ep} end end else @@ -1624,7 +1670,13 @@ defmodule Graph do 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} = g, + %__MODULE__{ + multigraph: true, + edges: meta, + partition_by: partition_by, + edge_properties: ep + } = + g, {v1_id, v1}, {v2_id, v2} ) do @@ -1632,10 +1684,9 @@ defmodule Graph do meta |> Map.get(edge_key, %{}) - |> Enum.reduce(g, fn {label, edge_meta}, acc -> - edge = - Edge.new(v1, v2, label: label, weight: edge_meta.weight, properties: edge_meta.properties) - + |> 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 @@ -1645,7 +1696,13 @@ defmodule Graph do 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} = g, + %__MODULE__{ + multigraph: true, + edges: meta, + partition_by: partition_by, + edge_properties: ep + } = + g, {v1_id, v1}, {v2_id, v2}, label @@ -1653,14 +1710,9 @@ defmodule Graph do edge_key = {v1_id, v2_id} case meta |> Map.get(edge_key, %{}) |> Map.fetch(label) do - {:ok, edge_meta} -> - edge = - Edge.new(v1, v2, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) - + {:ok, weight} -> + props = get_edge_props(ep, edge_key, label) + edge = Edge.new(v1, v2, label: label, weight: weight, properties: props) prune_edge_key_from_partitions(g, edge_key, v1_id, v2_id, partition_by.(edge)) :error -> @@ -1927,7 +1979,10 @@ defmodule Graph do [%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, edge_index: ei} = 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) @@ -1942,7 +1997,17 @@ defmodule Graph do {partition, new_vertex_map} end) - %__MODULE__{g | in_edges: oe, out_edges: ie, edges: meta2, edge_index: ei2} + 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 """ @@ -2602,7 +2667,8 @@ defmodule Graph do vertices: vs, in_edges: ie, edges: meta, - vertex_identifier: vertex_identifier + vertex_identifier: vertex_identifier, + edge_properties: ep }, v ) do @@ -2610,13 +2676,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, edge_meta} -> - Edge.new(v1, v, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) + 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 @@ -2645,7 +2709,8 @@ defmodule Graph do multigraph: true, vertex_identifier: vertex_identifier, edge_index: edge_index, - partition_by: partition_by + partition_by: partition_by, + edge_properties: ep }, v, by: partition @@ -2670,14 +2735,9 @@ defmodule Graph do edges |> Map.get(edge_key, []) - |> Enum.map(fn {label, edge_meta} -> - edge = - Edge.new(v1, v, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) - + |> Enum.map(fn {label, weight} -> + props = get_edge_props(ep, edge_key, label) + edge = Edge.new(v1, v, label: label, weight: weight, properties: props) edge_partitions = partition_by.(edge) if Enum.any?(edge_partitions, fn edge_partition -> edge_partition == partition end) do @@ -2739,7 +2799,8 @@ defmodule Graph do vertices: vs, out_edges: oe, edges: meta, - vertex_identifier: vertex_identifier + vertex_identifier: vertex_identifier, + edge_properties: ep }, v ) do @@ -2747,13 +2808,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, edge_meta} -> - Edge.new(v, v2, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) + 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 @@ -2815,7 +2874,8 @@ defmodule Graph do multigraph: true, edge_index: edge_index, vertex_identifier: vertex_identifier, - partition_by: partition_by + partition_by: partition_by, + edge_properties: ep }, v, partitions, @@ -2845,14 +2905,9 @@ defmodule Graph do edges |> Map.get(edge_key, []) - |> Enum.reduce([], fn {label, edge_meta}, acc -> - edge = - Edge.new(v, v2, - label: label, - weight: edge_meta.weight, - properties: edge_meta.properties - ) - + |> Enum.reduce([], fn {label, weight}, acc -> + props = get_edge_props(ep, edge_key, label) + edge = Edge.new(v, v2, label: label, weight: weight, properties: props) edges_in_partitions = partition_by.(edge) if include_edge_for_filtered_partitions?(edge, edges_in_partitions, partitions, where_fun) do @@ -2919,11 +2974,15 @@ defmodule Graph do |> 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, edge_meta}, sg -> + 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: edge_meta.weight, - properties: edge_meta.properties + weight: weight, + properties: props ) end) end) diff --git a/lib/graph/pathfindings/bellman_ford.ex b/lib/graph/pathfindings/bellman_ford.ex index c1104f3..0184125 100644 --- a/lib/graph/pathfindings/bellman_ford.ex +++ b/lib/graph/pathfindings/bellman_ford.ex @@ -50,14 +50,24 @@ defmodule Graph.Pathfindings.BellmanFord do Enum.map(meta, &edge_weight/1) end - defp edges_with_weights(meta, %Graph{partition_by: partition_by}, partitions) do + 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: weight, properties: properties}} -> - eps = partition_by.(%{label: label, weight: weight, properties: properties}) + |> 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: weight}} -> + |> Enum.map(fn {_label, weight} -> {edge_key, weight} end) end) @@ -84,7 +94,7 @@ defmodule Graph.Pathfindings.BellmanFord do @spec edge_weight(term) :: float defp edge_weight({e, edge_value}), - do: {e, edge_value |> Map.values() |> List.first() |> Map.get(:weight)} + do: {e, edge_value |> Map.values() |> List.first()} defp has_negative_cycle?(distances, meta) do Enum.any?(meta, fn {{u, v}, weight} -> diff --git a/lib/graph/serializers/dot.ex b/lib/graph/serializers/dot.ex index baf5dd4..2266795 100644 --- a/lib/graph/serializers/dot.ex +++ b/lib/graph/serializers/dot.ex @@ -28,10 +28,10 @@ defmodule Graph.Serializers.DOT do |> Map.get(id, MapSet.new()) |> Enum.flat_map(fn id2 -> Enum.map(Map.fetch!(em, {id, id2}), fn - {nil, %{weight: weight}} -> + {nil, weight} -> {id, id2, weight} - {label, %{weight: weight}} -> + {label, weight} -> {id, id2, weight, Serializer.encode_label(label)} end) end) diff --git a/lib/graph/serializers/flowchart.ex b/lib/graph/serializers/flowchart.ex index 7a65cb8..392a940 100644 --- a/lib/graph/serializers/flowchart.ex +++ b/lib/graph/serializers/flowchart.ex @@ -38,8 +38,8 @@ defmodule Graph.Serializers.Flowchart do g.edges |> Map.fetch!({id, out_edge_id}) |> Enum.map(fn - {nil, %{weight: weight}} -> {id, out_edge_id, weight} - {label, %{weight: weight}} -> {id, out_edge_id, weight, encode_label(label)} + {nil, weight} -> {id, out_edge_id, weight} + {label, weight} -> {id, out_edge_id, weight, encode_label(label)} end) end) |> case do diff --git a/lib/graph/utils.ex b/lib/graph/utils.ex index 4df0b28..d7a9663 100644 --- a/lib/graph/utils.ex +++ b/lib/graph/utils.ex @@ -83,7 +83,7 @@ defmodule Graph.Utils do def edge_weight(%Graph{type: :directed, edges: meta}, a, b) do Map.fetch!(meta, {a, b}) - |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> Enum.map(fn {_label, weight} -> weight end) |> Enum.min() end @@ -96,37 +96,40 @@ defmodule Graph.Utils do edge_meta when is_map(edge_meta) -> edge_meta - |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> Enum.map(fn {_label, weight} -> weight end) |> Enum.min() end edge_meta when is_map(edge_meta) -> edge_meta - |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> Enum.map(fn {_label, weight} -> weight end) |> Enum.min() end end def edge_weight( - %Graph{type: :directed, edges: meta, partition_by: partition_by}, + %Graph{type: :directed, edges: meta, partition_by: partition_by, edge_properties: ep}, a, b, partitions ) do + edge_key = {a, b} + meta - |> Map.fetch!({a, b}) - |> filter_by_partitions(partition_by, partitions) - |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> 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}, + %Graph{type: :undirected, edges: meta, partition_by: partition_by, edge_properties: ep}, a, b, partitions ) do - edge_meta = Map.get(meta, {a, b}) || Map.get(meta, {b, a}) + edge_key = {a, b} + edge_meta = Map.get(meta, edge_key) || Map.get(meta, {b, a}) case edge_meta do nil -> @@ -134,15 +137,21 @@ defmodule Graph.Utils do edge_meta when is_map(edge_meta) -> edge_meta - |> filter_by_partitions(partition_by, partitions) - |> Enum.map(fn {_label, %{weight: weight}} -> weight end) + |> 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) do - Enum.filter(edge_meta, fn {label, %{weight: weight, properties: properties}} -> - eps = partition_by.(%{label: label, weight: weight, properties: properties}) + 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 From 32280656f808090df85f0facabac27a51a6d2f92 Mon Sep 17 00:00:00 2001 From: Zack White Date: Fri, 20 Feb 2026 20:46:55 -0700 Subject: [PATCH 22/22] adjust tests to work across versions --- test/graph_test.exs | 24 +++++++++++++++++------- test/priority_queue_test.exs | 4 +++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/test/graph_test.exs b/test/graph_test.exs index f8947f8..bb332c1 100644 --- a/test/graph_test.exs +++ b/test/graph_test.exs @@ -397,10 +397,12 @@ defmodule GraphTest do {: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}} - ] = Graph.out_edges(g, :a) + ] = edges end test "updating edge properties" do @@ -413,10 +415,12 @@ defmodule GraphTest do |> 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}} - ] = Graph.out_edges(g, :a) + ] = edges end test "adding edge struct with properties" do @@ -486,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 -[foo]-> :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 <-[foo]-> :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) diff --git a/test/priority_queue_test.exs b/test/priority_queue_test.exs index 20d5d40..c3435fd 100644 --- a/test/priority_queue_test.exs +++ b/test/priority_queue_test.exs @@ -10,7 +10,9 @@ defmodule PriorityQueue.Test do end) str = "#{inspect(pq)}" - assert "#PriorityQueue" = str + # Elixir 1.15+ uses ~c"..." for charlists, older versions use '...' + assert str == "#PriorityQueue" or + str == "#PriorityQueue" end test "can enqueue random elements and pull them out in priority order" do