Skip to content

Commit 8381eaf

Browse files
committed
cover multigraph support, add benchmarks, & document
1 parent d501b53 commit 8381eaf

17 files changed

Lines changed: 1452 additions & 224 deletions

.github/workflows/elixir.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ jobs:
1313
- elixir: 1.14.5
1414
otp: 24.3
1515

16-
- elixir: 1.15.4
16+
- elixir: 1.15.8
1717
otp: 25.3
1818

1919
- elixir: 1.16.3
2020
otp: 26.2
2121

22-
- otp: 27.2
23-
elixir: 1.18.1
22+
- elixir: 1.18.2
23+
otp: 27.2
2424
steps:
2525
- uses: actions/checkout@v2
2626
- uses: erlef/setup-beam@v1

README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,93 @@ def deps do
4545
end
4646
```
4747

48+
## Multigraphs
49+
50+
Libgraph supports multigraphs — graphs where multiple edges with different labels can exist between
51+
the same pair of vertices. When `multigraph: true` is enabled, an edge adjacency index is maintained
52+
that allows O(1) lookup of edges by partition key, avoiding full edge scans.
53+
54+
### Creating a multigraph
55+
56+
```elixir
57+
g =
58+
Graph.new(multigraph: true)
59+
|> Graph.add_edges([
60+
{:a, :b, label: :uses},
61+
{:a, :b, label: :contains},
62+
{:b, :c, label: :uses},
63+
{:b, :c, label: :owns, weight: 3}
64+
])
65+
```
66+
67+
### Querying by partition
68+
69+
By default, edges are partitioned by their label (via `Graph.Utils.by_edge_label/1`). You can
70+
query edges belonging to a specific partition:
71+
72+
```elixir
73+
# Get only :uses edges
74+
Graph.edges(g, by: :uses)
75+
#=> [%Graph.Edge{v1: :a, v2: :b, label: :uses}, %Graph.Edge{v1: :b, v2: :c, label: :uses}]
76+
77+
# Get out edges from :a with label :contains
78+
Graph.out_edges(g, :a, by: :contains)
79+
#=> [%Graph.Edge{v1: :a, v2: :b, label: :contains}]
80+
81+
# Filter edges with a predicate
82+
Graph.edges(g, where: fn edge -> edge.weight > 2 end)
83+
#=> [%Graph.Edge{v1: :b, v2: :c, label: :owns, weight: 3}]
84+
```
85+
86+
### Custom partition functions
87+
88+
You can provide a custom `partition_by` function to control how edges are indexed:
89+
90+
```elixir
91+
g = Graph.new(multigraph: true, partition_by: fn edge -> [edge.weight] end)
92+
|> Graph.add_edges([{:a, :b, weight: 1}, {:b, :c, weight: 2}])
93+
94+
Graph.edges(g, by: 1)
95+
#=> [%Graph.Edge{v1: :a, v2: :b, weight: 1}]
96+
```
97+
98+
### Partition-filtered traversals
99+
100+
BFS, DFS, Dijkstra, A*, and Bellman-Ford all support a `by:` option to restrict traversal to
101+
edges in specific partitions:
102+
103+
```elixir
104+
g =
105+
Graph.new(multigraph: true)
106+
|> Graph.add_edges([
107+
{:a, :b, label: :fast, weight: 1},
108+
{:a, :c, label: :slow, weight: 10},
109+
{:b, :d, label: :fast, weight: 1},
110+
{:c, :d, label: :slow, weight: 1}
111+
])
112+
113+
# Shortest path using only :fast edges
114+
Graph.dijkstra(g, :a, :d, by: :fast)
115+
#=> [:a, :b, :d]
116+
117+
# BFS following only :fast edges
118+
Graph.Reducers.Bfs.map(g, & &1, by: :fast)
119+
#=> [:a, :b, :d]
120+
```
121+
122+
### Edge properties
123+
124+
Edges now support an arbitrary `properties` map for storing additional metadata:
125+
126+
```elixir
127+
g = Graph.new()
128+
|> Graph.add_edge(:a, :b, label: :link, properties: %{color: "red", style: :dashed})
129+
130+
[edge] = Graph.edges(g)
131+
edge.properties
132+
#=> %{color: "red", style: :dashed}
133+
```
134+
48135
## Rationale
49136

50137
The original motivation for me to start working on this library is the fact that `:digraph` requires a

bench/multigraph.exs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
defmodule MultigraphBench.Helpers do
2+
def build_graphs(num_vertices, num_edges, num_labels) do
3+
labels = Enum.map(1..num_labels, fn i -> :"label_#{i}" end)
4+
5+
edges =
6+
for _ <- 1..num_edges do
7+
v1 = :rand.uniform(num_vertices)
8+
v2 = :rand.uniform(num_vertices)
9+
label = Enum.random(labels)
10+
{v1, v2, label: label, weight: :rand.uniform(100)}
11+
end
12+
13+
plain =
14+
Enum.reduce(edges, Graph.new(), fn {v1, v2, opts}, g ->
15+
Graph.add_edge(g, v1, v2, opts)
16+
end)
17+
18+
multi =
19+
Enum.reduce(edges, Graph.new(multigraph: true), fn {v1, v2, opts}, g ->
20+
Graph.add_edge(g, v1, v2, opts)
21+
end)
22+
23+
target_label = Enum.random(labels)
24+
some_vertex = :rand.uniform(num_vertices)
25+
26+
{plain, multi, target_label, some_vertex}
27+
end
28+
end
29+
30+
alias MultigraphBench.Helpers
31+
32+
Benchee.run(
33+
%{
34+
"scan all edges + filter (no multigraph)" => fn {g_plain, _g_multi, target_label, _v} ->
35+
g_plain |> Graph.edges() |> Enum.filter(fn e -> e.label == target_label end)
36+
end,
37+
"indexed lookup (multigraph by:)" => fn {_g_plain, g_multi, target_label, _v} ->
38+
Graph.edges(g_multi, by: target_label)
39+
end,
40+
"scan out_edges + filter (no multigraph)" => fn {g_plain, _g_multi, target_label, v} ->
41+
g_plain |> Graph.out_edges(v) |> Enum.filter(fn e -> e.label == target_label end)
42+
end,
43+
"indexed out_edges (multigraph by:)" => fn {_g_plain, g_multi, target_label, v} ->
44+
Graph.out_edges(g_multi, v, by: target_label)
45+
end
46+
},
47+
inputs: %{
48+
"1k vertices, 5k edges, 10 labels" => Helpers.build_graphs(1_000, 5_000, 10),
49+
"10k vertices, 50k edges, 50 labels" => Helpers.build_graphs(10_000, 50_000, 50)
50+
},
51+
time: 10,
52+
memory_time: 5
53+
)

bench/multigraph_creation.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule MultigraphCreationBench.Helpers do
2+
def build_edges(size) do
3+
labels = Enum.map(1..10, fn i -> :"label_#{i}" end)
4+
5+
for i <- 1..size do
6+
v1 = :rand.uniform(div(size, 5))
7+
v2 = :rand.uniform(div(size, 5))
8+
label = Enum.random(labels)
9+
{v1, v2, label: label, weight: :rand.uniform(100)}
10+
end
11+
end
12+
end
13+
14+
alias MultigraphCreationBench.Helpers
15+
16+
Benchee.run(
17+
%{
18+
"plain graph (no index)" => fn edges ->
19+
Enum.reduce(edges, Graph.new(), fn {v1, v2, opts}, g ->
20+
Graph.add_edge(g, v1, v2, opts)
21+
end)
22+
end,
23+
"multigraph (indexed)" => fn edges ->
24+
Enum.reduce(edges, Graph.new(multigraph: true), fn {v1, v2, opts}, g ->
25+
Graph.add_edge(g, v1, v2, opts)
26+
end)
27+
end
28+
},
29+
inputs: %{
30+
"10k edges" => Helpers.build_edges(10_000),
31+
"100k edges" => Helpers.build_edges(100_000)
32+
},
33+
time: 10,
34+
memory_time: 5
35+
)

bench/multigraph_memory.exs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
defmodule MultigraphMemoryBench.Helpers do
2+
def build_graphs(num_edges, num_labels) do
3+
num_vertices = div(num_edges, 5)
4+
labels = Enum.map(1..num_labels, fn i -> :"label_#{i}" end)
5+
6+
edges =
7+
for _ <- 1..num_edges do
8+
v1 = :rand.uniform(num_vertices)
9+
v2 = :rand.uniform(num_vertices)
10+
label = Enum.random(labels)
11+
{v1, v2, label: label, weight: :rand.uniform(100)}
12+
end
13+
14+
plain =
15+
Enum.reduce(edges, Graph.new(), fn {v1, v2, opts}, g ->
16+
Graph.add_edge(g, v1, v2, opts)
17+
end)
18+
19+
multi =
20+
Enum.reduce(edges, Graph.new(multigraph: true), fn {v1, v2, opts}, g ->
21+
Graph.add_edge(g, v1, v2, opts)
22+
end)
23+
24+
{plain, multi}
25+
end
26+
end
27+
28+
alias MultigraphMemoryBench.Helpers
29+
30+
IO.puts("Multigraph Memory Overhead Report")
31+
IO.puts("==================================\n")
32+
33+
for {name, {size, labels}} <- [
34+
{"1k edges / 5 labels", {1_000, 5}},
35+
{"10k edges / 10 labels", {10_000, 10}},
36+
{"10k edges / 100 labels", {10_000, 100}},
37+
{"100k edges / 50 labels", {100_000, 50}}
38+
] do
39+
{plain, multi} = Helpers.build_graphs(size, labels)
40+
41+
plain_info = Graph.info(plain)
42+
multi_info = Graph.info(multi)
43+
44+
ratio = multi_info.size_in_bytes / plain_info.size_in_bytes
45+
46+
IO.puts(
47+
"#{name}: plain=#{plain_info.size_in_bytes}B, multi=#{multi_info.size_in_bytes}B, ratio=#{Float.round(ratio, 2)}x"
48+
)
49+
end

lib/edge.ex

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,21 @@ defmodule Graph.Edge do
2525
@type edge_opts :: [edge_opt]
2626

2727
@doc """
28-
Defines a new edge and accepts optional values for weight and label.
29-
The defaults of a weight of 1 and no label will be used if the options do
30-
not specify otherwise.
28+
Defines a new edge and accepts optional values for weight, label, and properties.
29+
30+
## Options
31+
32+
- `:weight` - the weight of the edge (integer or float, default: `1`)
33+
- `:label` - the label for the edge (default: `nil`)
34+
- `:properties` - an arbitrary map of additional metadata (default: `%{}`)
3135
3236
An error will be thrown if weight is not an integer or float.
3337
34-
## Example
38+
## Examples
39+
40+
iex> edge = Graph.Edge.new(:a, :b, label: :foo, weight: 2, properties: %{color: "red"})
41+
...> {edge.label, edge.weight, edge.properties}
42+
{:foo, 2, %{color: "red"}}
3543
3644
iex> Graph.new |> Graph.add_edge(Graph.Edge.new(:a, :b, weight: "1"))
3745
** (ArgumentError) invalid value for :weight, must be an integer

0 commit comments

Comments
 (0)