Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@ 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:
- elixir: 1.14.5
otp: 24.3

- elixir: 1.15.8
otp: 25.3

- elixir: 1.16.3
otp: 26.2

- elixir: 1.18.2
otp: 27.2
steps:
- uses: actions/checkout@v2
- uses: erlef/setup-beam@v1
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ erl_crash.dump
# QuickCheck files
/.eqc-info
/*.eqc
.elixir_ls/
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions bench/multigraph.exs
Original file line number Diff line number Diff line change
@@ -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
)
35 changes: 35 additions & 0 deletions bench/multigraph_creation.exs
Original file line number Diff line number Diff line change
@@ -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
)
49 changes: 49 additions & 0 deletions bench/multigraph_memory.exs
Original file line number Diff line number Diff line change
@@ -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
31 changes: 23 additions & 8 deletions lib/edge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,54 @@ defmodule Graph.Edge do
defstruct v1: nil,
v2: nil,
weight: 1,
label: nil
label: nil,
properties: %{}

@type t :: %__MODULE__{
v1: Graph.vertex(),
v2: Graph.vertex(),
weight: integer | float,
label: term
label: term,
properties: map
}
@type edge_opt ::
{:weight, integer | float}
| {:label, term}
| {:properties, map}
@type edge_opts :: [edge_opt]

@doc """
Defines a new edge and accepts optional values for weight and label.
The defaults of a weight of 1 and no label will be used if the options do
not specify otherwise.
Defines a new edge and accepts optional values for weight, label, and properties.

## Options

- `:weight` - the weight of the edge (integer or float, default: `1`)
- `:label` - the label for the edge (default: `nil`)
- `:properties` - an arbitrary map of additional metadata (default: `%{}`)

An error will be thrown if weight is not an integer or float.

## Example
## Examples

iex> edge = Graph.Edge.new(:a, :b, label: :foo, weight: 2, properties: %{color: "red"})
...> {edge.label, edge.weight, edge.properties}
{:foo, 2, %{color: "red"}}

iex> Graph.new |> Graph.add_edge(Graph.Edge.new(:a, :b, weight: "1"))
** (ArgumentError) invalid value for :weight, must be an integer
"""
@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

Expand Down
Loading
Loading