Yog.Model (YogEx v0.70.0)

Copy Markdown View Source

Core graph data structures and basic operations for the yog library.

This module defines the fundamental Graph type and provides all basic operations for creating and manipulating graphs. The graph uses an adjacency list representation with dual indexing (both outgoing and incoming edges) for efficient traversal in both directions.

Graph Types

  • Directed Graph: Edges have a direction (one-way relationships)
  • Undirected Graph: Edges are bidirectional (mutual relationships)

Type Parameters

  • node_data: The type of data stored at each node (e.g., String, City, Task)
  • edge_data: The type of data stored on edges, typically weights (e.g., Int, Float)

Quick Start

iex> graph =
...>   Yog.Model.new(:undirected)
...>   |> Yog.Model.add_node(1, "Alice")
...>   |> Yog.Model.add_node(2, "Bob")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> {:ok, g} = graph
iex> Yog.Model.successors(g, 1)
[{2, 10}]

Design Notes

The dual-map representation enables O(1) edge existence checks and O(1) transpose operations, at the cost of increased memory usage and slightly more complex edge updates.

Migration Note: This module was ported from Gleam to pure Elixir in v0.53.0. The API remains unchanged.

Summary

Types

A simple graph data structure that can be directed or undirected.

The type of graph: :directed or :undirected.

Unique identifier for a node in the graph.

Functions

Adds an edge to the graph with the given weight.

Same as add_edge/4 but raises on error.

Ensures both endpoint nodes exist, then adds an edge.

Deprecated compatibility alias for add_edge_ensure/5.

Ensures both endpoint nodes exist using a callback, then adds an edge.

Adds an edge, but if an edge already exists between src and dst, it combines the new weight with the existing one using with_combine.

Adds multiple edges to the graph in a single operation.

Adds a node to the graph with the given ID and data. If a node with this ID already exists, its data will be replaced.

Adds multiple simple edges (weight = 1) to the graph.

Adds multiple unweighted edges (weight = nil) to the graph.

Returns all edges in the graph as triplets {from, to, weight}.

Returns all node IDs in the graph. This includes all nodes, even isolated nodes with no edges.

Returns the number of edges in the graph.

Checks if the graph contains an edge between src and dst.

Checks if the graph contains a node with the given ID.

Returns all neighbor node IDs (without weights).

Gets all nodes connected to the given node, regardless of direction. Useful for algorithms like finding "connected components".

Creates a new empty graph of the specified type.

Gets the data associated with a node.

Returns the number of nodes in the graph. Equivalent to order/1.

Returns all nodes in the graph as a map.

Returns the number of nodes in the graph (graph order).

Returns all predecessor node IDs (without weights).

Gets nodes you came FROM to reach the given node (predecessors). Returns a list of tuples containing the source node ID and edge data.

Removes a directed edge from src to dst.

Removes a node and all its connected edges (incoming and outgoing).

Returns all successor node IDs (without weights).

Gets nodes you can travel TO from the given node (successors). Returns a list of tuples containing the destination node ID and edge data.

Gets the type of the graph (:directed or :undirected).

Types

graph()

@type graph() :: Yog.Graph.t()

A simple graph data structure that can be directed or undirected.

This is an alias for Yog.Graph.t().

graph_type()

@type graph_type() :: :directed | :undirected

The type of graph: :directed or :undirected.

node_id()

@type node_id() :: integer()

Unique identifier for a node in the graph.

Functions

add_edge(graph, src, dst, weight)

@spec add_edge(graph(), node_id(), node_id(), term()) ::
  {:ok, graph()} | {:error, String.t()}

Adds an edge to the graph with the given weight.

For directed graphs, adds a single edge from src to dst. For undirected graphs, adds edges in both directions.

Returns {:error, reason} if either endpoint node doesn't exist in graph.nodes. Use add_edge_ensure/5 to auto-create missing nodes with a default value, or add_node/3 to explicitly add nodes before adding edges.

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> Yog.Model.successors(graph, 1)
[{2, 10}]

iex> # Error when nodes don't exist
iex> Yog.Model.new(:directed) |> Yog.Model.add_edge(1, 2, 10)
{:error, "Nodes 1 and 2 do not exist"}

add_edge!(graph, from, to, weight)

@spec add_edge!(graph(), node_id(), node_id(), term()) :: graph()

Same as add_edge/4 but raises on error.

Example

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge!(1, 2, 10)
iex> Yog.Model.successors(graph, 1)
[{2, 10}]

add_edge_ensure(graph, src, dst, weight, default)

@spec add_edge_ensure(graph(), node_id(), node_id(), term(), term()) :: graph()

Ensures both endpoint nodes exist, then adds an edge.

If src or dst is not already in the graph, it is created with the supplied default node data before the edge is added. Nodes that already exist are left unchanged.

Always succeeds and returns a Graph (never fails).

Example

iex> # Nodes 1 and 2 are created automatically with data "unknown"
iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_edge_ensure(1, 2, 10, "unknown")
iex> Yog.Model.node(graph, 1)
"unknown"

iex> # Existing nodes keep their data; only missing ones get the default
iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "Alice")
...>   |> Yog.Model.add_edge_ensure(1, 2, 5, "anon")
iex> # Node 1 is still "Alice", node 2 is "anon"
iex> {Yog.Model.node(graph, 1), Yog.Model.node(graph, 2)}
{"Alice", "anon"}

add_edge_ensured(graph, from, to, weight, default)

This function is deprecated. Use add_edge_ensure/5 instead.

Deprecated compatibility alias for add_edge_ensure/5.

add_edge_with(graph, src, dst, weight, make_fn)

@spec add_edge_with(graph(), node_id(), node_id(), term(), (node_id() -> term())) ::
  graph()

Ensures both endpoint nodes exist using a callback, then adds an edge.

If src or dst is not already in the graph, it is created by calling the by function with the node ID to generate the node data. Nodes that already exist are left unchanged.

Always succeeds and returns a Graph (never fails).

Example

iex> # Nodes 1 and 2 are created automatically with value that's the same as NodeId
iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_edge_with(1, 2, 10, fn x -> x end)
iex> Yog.Model.node(graph, 1)
1

iex> # Existing nodes keep their data; only missing ones get generated data
iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "1")
...>   |> Yog.Model.add_edge_with(1, 2, 5, fn n -> to_string(n) <> ":new" end)
iex> # Node 1 is still "1", node 2 is "2:new"
iex> {Yog.Model.node(graph, 1), Yog.Model.node(graph, 2)}
{"1", "2:new"}

add_edge_with_combine(graph, src, dst, weight, with_combine)

@spec add_edge_with_combine(graph(), node_id(), node_id(), term(), (term(), term() ->
                                                                term())) ::
  {:ok, graph()} | {:error, String.t()}

Adds an edge, but if an edge already exists between src and dst, it combines the new weight with the existing one using with_combine.

The combine function receives (existing_weight, new_weight) and should return the combined weight.

Returns {:error, reason} if either endpoint node doesn't exist in graph.nodes.

Time Complexity: O(1)

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> {:ok, graph} = Yog.Model.add_edge_with_combine(graph, 1, 2, 5, &Kernel.+/2)
iex> # Edge 1->2 now has weight 15 (10 + 5)
iex> Yog.Model.successors(graph, 1)
[{2, 15}]

Use Cases

  • Edge contraction in graph algorithms (Stoer-Wagner min-cut)
  • Multi-graph support (adding parallel edges with combined weights)
  • Incremental graph building (accumulating weights from multiple sources)

add_edge_with_combine!(graph, src, dst, weight, with_combine)

@spec add_edge_with_combine!(graph(), node_id(), node_id(), term(), (term(), term() ->
                                                                 term())) ::
  graph()

Same as add_edge_with_combine/5 but raises on error.

Example

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge!(1, 2, 10)
...>   |> Yog.Model.add_edge_with_combine!(1, 2, 5, &Kernel.+/2)
iex> Yog.Model.successors(graph, 1)
[{2, 15}]

add_edges(graph, edges)

@spec add_edges(graph(), [{node_id(), node_id(), term()}]) ::
  {:ok, graph()} | {:error, String.t()}

Adds multiple edges to the graph in a single operation.

Fails fast on the first edge that references non-existent nodes. Returns {:error, reason} if any endpoint node doesn't exist.

This is more ergonomic than chaining multiple add_edge calls as it only requires unwrapping a single Result.

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_node(3, "C")
...>   |> Yog.Model.add_edges([{1, 2, 10}, {2, 3, 5}, {1, 3, 15}])
iex> length(Yog.Model.successors(graph, 1))
2

add_node(graph, id, data)

@spec add_node(graph(), node_id(), term()) :: graph()

Adds a node to the graph with the given ID and data. If a node with this ID already exists, its data will be replaced.

Example

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "Node A")
...>   |> Yog.Model.add_node(2, "Node B")
iex> Yog.Model.order(graph)
2

add_simple_edges(graph, edges)

@spec add_simple_edges(graph(), [{node_id(), node_id()}]) ::
  {:ok, graph()} | {:error, String.t()}

Adds multiple simple edges (weight = 1) to the graph.

Fails fast on the first edge that references non-existent nodes. Convenient for unweighted graphs where all edges have weight 1.

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_node(3, "C")
...>   |> Yog.Model.add_simple_edges([{1, 2}, {2, 3}, {1, 3}])
iex> Yog.Model.successors(graph, 1) |> Enum.sort()
[{2, 1}, {3, 1}]

add_unweighted_edges(graph, edges)

@spec add_unweighted_edges(graph(), [{node_id(), node_id()}]) ::
  {:ok, graph()} | {:error, String.t()}

Adds multiple unweighted edges (weight = nil) to the graph.

Fails fast on the first edge that references non-existent nodes. Convenient for graphs where edges carry no weight information.

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_node(3, "C")
...>   |> Yog.Model.add_unweighted_edges([{1, 2}, {2, 3}, {1, 3}])
iex> Yog.Model.successors(graph, 1) |> Enum.sort()
[{2, nil}, {3, nil}]

all_edges(graph)

@spec all_edges(graph()) :: [{node_id(), node_id(), number()}]

Returns all edges in the graph as triplets {from, to, weight}.

For directed graphs, returns all edges. For undirected graphs, returns each edge only once (where from <= to).

This is particularly useful for graph export formats.

Examples

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, nil)
...>   |> Yog.Model.add_node(2, nil)
...>   |> Yog.Model.add_edge!(1, 2, 5)
iex> Yog.Model.all_edges(graph)
[{1, 2, 5}]

iex> graph =
...>   Yog.Model.new(:undirected)
...>   |> Yog.Model.add_node(1, nil)
...>   |> Yog.Model.add_node(2, nil)
...>   |> Yog.Model.add_edge!(1, 2, 5)
iex> edges = Yog.Model.all_edges(graph)
iex> length(edges)
1

all_nodes(graph)

@spec all_nodes(graph()) :: [node_id()]

Returns all node IDs in the graph. This includes all nodes, even isolated nodes with no edges.

Example

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
iex> Yog.Model.all_nodes(graph)
[1, 2]

edge_count(graph)

@spec edge_count(graph()) :: integer()

Returns the number of edges in the graph.

For undirected graphs, each edge is counted once (the pair {u, v}). For directed graphs, each directed edge (u -> v) is counted once.

Time Complexity: O(V)

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> Yog.Model.edge_count(graph)
1

has_edge?(graph, src, dst)

@spec has_edge?(graph(), node_id(), node_id()) :: boolean()

Checks if the graph contains an edge between src and dst.

Returns true if an edge exists, false otherwise.

Time Complexity: O(1)

has_node?(graph, id)

@spec has_node?(graph(), node_id()) :: boolean()

Checks if the graph contains a node with the given ID.

Time Complexity: O(1)

neighbor_ids(graph, id)

@spec neighbor_ids(graph(), node_id()) :: [node_id()]

Returns all neighbor node IDs (without weights).

Example

iex> graph =
...>   Yog.directed()
...>   |> Yog.add_node(1, "A")
...>   |> Yog.add_node(2, "B")
...>   |> Yog.add_node(3, "C")
...>   |> Yog.add_edge!(from: 1, to: 2, with: 10)
...>   |> Yog.add_edge!(from: 1, to: 3, with: 20)
iex> Yog.Model.neighbor_ids(graph, 1) |> Enum.sort()
[2, 3]

neighbors(graph, id)

@spec neighbors(graph(), node_id()) :: [{node_id(), term()}]

Gets all nodes connected to the given node, regardless of direction. Useful for algorithms like finding "connected components".

For undirected graphs, this is equivalent to successors. For directed graphs, this combines successors and predecessors without duplicates.

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_node(3, "C")
...>   |> Yog.Model.add_edges([{1, 2, 10}, {3, 1, 20}])
iex> Yog.Model.neighbors(graph, 1) |> Enum.sort()
[{2, 10}, {3, 20}]

new(graph_type)

@spec new(graph_type()) :: graph()

Creates a new empty graph of the specified type.

Example

iex> graph = Yog.Model.new(:directed)
iex> Yog.Model.order(graph)
0

node(graph, id)

@spec node(graph(), node_id()) :: term() | nil

Gets the data associated with a node.

Returns nil if the node doesn't exist.

Example

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
iex> Yog.Model.node(graph, 1)
"A"

node_count(graph)

@spec node_count(graph()) :: integer()

Returns the number of nodes in the graph. Equivalent to order/1.

Time Complexity: O(1)

Example

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
iex> Yog.Model.node_count(graph)
2

nodes(graph)

@spec nodes(graph()) :: map()

Returns all nodes in the graph as a map.

Example

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
iex> nodes = Yog.Model.nodes(graph)
iex> nodes[1]
"A"

order(graph)

@spec order(graph()) :: integer()

Returns the number of nodes in the graph (graph order).

Time Complexity: O(1)

Example

iex> graph =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
iex> Yog.Model.order(graph)
2

predecessor_ids(graph, id)

@spec predecessor_ids(graph(), node_id()) :: [node_id()]

Returns all predecessor node IDs (without weights).

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> Yog.Model.predecessor_ids(graph, 2)
[1]

predecessors(graph, id)

@spec predecessors(graph(), node_id()) :: [{node_id(), term()}]

Gets nodes you came FROM to reach the given node (predecessors). Returns a list of tuples containing the source node ID and edge data.

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> Yog.Model.predecessors(graph, 2)
[{1, 10}]

remove_edge(graph, src, dst)

@spec remove_edge(graph(), node_id(), node_id()) :: graph()

Removes a directed edge from src to dst.

For directed graphs, this removes the single directed edge from src to dst. For undirected graphs, this removes the edges in both directions (from src to dst and from dst to src).

Time Complexity: O(1)

Example

iex> # Directed graph - removes single directed edge
iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> graph = Yog.Model.remove_edge(graph, 1, 2)
iex> # Edge 1->2 is removed
iex> Yog.Model.successors(graph, 1)
[]

iex> # Undirected graph - removes both directions
iex> {:ok, graph} =
...>   Yog.Model.new(:undirected)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> graph = Yog.Model.remove_edge(graph, 1, 2)
iex> # Edge between 1 and 2 is fully removed
iex> Yog.Model.successors(graph, 1)
[]

remove_node(graph, id)

@spec remove_node(graph(), node_id()) :: graph()

Removes a node and all its connected edges (incoming and outgoing).

Time Complexity: O(deg(v)) - proportional to the number of edges connected to the node, not the whole graph.

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_node(3, "C")
...>   |> Yog.Model.add_edges([{1, 2, 10}, {2, 3, 20}])
iex> graph = Yog.Model.remove_node(graph, 2)
iex> # Node 2 is removed, along with edges 1->2 and 2->3
iex> Yog.Model.order(graph)
2

successor_ids(graph, id)

@spec successor_ids(graph(), node_id()) :: [node_id()]

Returns all successor node IDs (without weights).

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> Yog.Model.successor_ids(graph, 1)
[2]

successors(graph, id)

@spec successors(graph(), node_id()) :: [{node_id(), term()}]

Gets nodes you can travel TO from the given node (successors). Returns a list of tuples containing the destination node ID and edge data.

Example

iex> {:ok, graph} =
...>   Yog.Model.new(:directed)
...>   |> Yog.Model.add_node(1, "A")
...>   |> Yog.Model.add_node(2, "B")
...>   |> Yog.Model.add_edge(1, 2, 10)
iex> Yog.Model.successors(graph, 1)
[{2, 10}]

type(graph)

@spec type(graph()) :: graph_type()

Gets the type of the graph (:directed or :undirected).

Example

iex> graph = Yog.Model.new(:directed)
iex> Yog.Model.type(graph)
:directed