Current section

Files

Jump to
yog_ex lib yog dag.ex
Raw

lib/yog/dag.ex

defmodule Yog.DAG do
@moduledoc """
Directed Acyclic Graph (DAG) data structure.
A DAG is a wrapper around a `Yog.Graph` that guarantees acyclicity at the type level.
This enables total functions (functions that always succeed) for operations like
topological sorting that would be partial for general graphs.
## Example
iex> graph = Yog.Graph.new(:directed)
iex> {:ok, dag} = Yog.DAG.from_graph(graph)
iex> is_struct(dag, Yog.DAG)
true
## Protocols
`Yog.DAG` implements the `Enumerable` and `Inspect` protocols:
- **Enumerable**: Iterates over nodes as `{id, data}` tuples via the underlying graph
- **Inspect**: Compact representation showing node and edge counts
"""
alias Yog.DAG.Model
@type t :: %__MODULE__{
graph: Yog.Graph.t()
}
@enforce_keys [:graph]
defstruct [:graph]
@doc """
Creates a new empty DAG.
## Example
iex> dag = Yog.DAG.new()
iex> Yog.Model.node_count(Yog.DAG.to_graph(dag))
0
"""
@spec new() :: t()
def new do
%__MODULE__{
graph: Yog.Graph.new(:directed)
}
end
@doc """
Attempts to create a DAG from a graph.
Validates that the graph is directed and contains no cycles.
## Example
iex> graph = Yog.from_unweighted_edges(:directed, [{1, 2}, {2, 3}])
iex> {:ok, dag} = Yog.DAG.from_graph(graph)
iex> Yog.DAG.to_graph(dag) == graph
true
iex> graph = Yog.from_unweighted_edges(:directed, [{1, 2}, {2, 1}])
iex> Yog.DAG.from_graph(graph)
{:error, :cycle_detected}
"""
@spec from_graph(Yog.Graph.t()) :: {:ok, t()} | {:error, :cycle_detected}
def from_graph(graph), do: Model.from_graph(graph)
@doc """
Unwraps a DAG back into a regular Graph.
## Example
iex> dag = Yog.DAG.new()
iex> graph = Yog.DAG.to_graph(dag)
iex> Yog.graph?(graph)
true
"""
@spec to_graph(t()) :: Yog.Graph.t()
def to_graph(%__MODULE__{graph: graph}), do: graph
# ============================================================
# Construction Helpers
# ============================================================
@doc """
Creates a DAG from a list of edges.
Each edge is a tuple `{from, to}` or `{from, to, weight}`.
Returns `{:ok, dag}` if the graph is acyclic, otherwise `{:error, :cycle_detected}`.
## Examples
iex> {:ok, dag} = Yog.DAG.from_edges([{:a, :b}, {:b, :c}])
iex> Yog.DAG.topological_sort(dag)
[:a, :b, :c]
iex> Yog.DAG.from_edges([{:a, :b}, {:b, :a}])
{:error, :cycle_detected}
"""
@spec from_edges([{Yog.node_id(), Yog.node_id()} | {Yog.node_id(), Yog.node_id(), any()}]) ::
{:ok, t()} | {:error, :cycle_detected}
def from_edges(edges) do
Model.from_edges(edges)
end
@doc """
Creates a DAG from a list of edges with a default weight.
## Examples
iex> {:ok, dag} = Yog.DAG.from_edges([{:a, :b}, {:b, :c}], 10)
iex> graph = Yog.DAG.to_graph(dag)
iex> Yog.Model.edge_data(graph, :a, :b)
10
"""
@spec from_edges([{Yog.node_id(), Yog.node_id()}], any()) ::
{:ok, t()} | {:error, :cycle_detected}
def from_edges(edges, default_weight) do
Model.from_edges(edges, default_weight)
end
# ============================================================
# Query
# ============================================================
@doc "Checks if a node exists in the DAG."
def has_node?(dag, id), do: Yog.Model.has_node?(dag.graph, id)
@doc "Checks if an edge exists in the DAG."
def has_edge?(dag, from, to), do: Yog.Model.has_edge?(dag.graph, from, to)
@doc "Returns the number of nodes in the DAG."
def node_count(dag), do: Yog.Model.node_count(dag.graph)
@doc "Returns the number of edges in the DAG."
def edge_count(dag), do: Yog.Graph.edge_count(dag.graph)
@doc "Returns all node IDs in the DAG."
def nodes(dag), do: Yog.Model.all_nodes(dag.graph)
@doc "Returns all outgoing edges from a node as [{to, weight}]."
def successors(dag, id), do: Yog.Model.successors(dag.graph, id)
@doc "Returns all incoming edges to a node as [{from, weight}]."
def predecessors(dag, id), do: Yog.Model.predecessors(dag.graph, id)
@doc "Returns the in-degree of a node."
def in_degree(dag, id), do: Yog.Model.in_degree(dag.graph, id)
@doc "Returns the out-degree of a node."
def out_degree(dag, id), do: Yog.Model.out_degree(dag.graph, id)
@doc "Checks if `from` can reach `to` in the DAG."
def reachable?(dag, from, to), do: Yog.Traversal.reachable?(dag.graph, from, to)
# ============================================================
# Modification
# ============================================================
@doc """
Adds a node to the DAG.
## Example
iex> dag = Yog.DAG.new() |> Yog.DAG.add_node(1, "A")
iex> Yog.DAG.to_graph(dag) |> Yog.node(1)
"A"
"""
defdelegate add_node(dag, id, data), to: Yog.DAG.Model
@doc """
Removes a node and all its connected edges from the DAG.
## Example
iex> dag = Yog.DAG.new() |> Yog.DAG.add_node(1, "A")
iex> dag = Yog.DAG.remove_node(dag, 1)
iex> Yog.DAG.to_graph(dag) |> Yog.has_node?(1)
false
"""
defdelegate remove_node(dag, id), to: Yog.DAG.Model
@doc """
Adds an edge to the DAG, validating for cycles.
## Example
iex> dag = Yog.DAG.new()
iex> {:ok, dag} = Yog.DAG.add_edge(dag, 1, 2, 10)
iex> Yog.DAG.add_edge(dag, 2, 1, 5)
{:error, :cycle_detected}
"""
defdelegate add_edge(dag, from, to, weight), to: Yog.DAG.Model
@doc """
Removes an edge from the DAG.
## Example
iex> {:ok, dag} = Yog.DAG.new() |> Yog.DAG.add_edge(1, 2, 10)
iex> dag = Yog.DAG.remove_edge(dag, 1, 2)
iex> Yog.DAG.to_graph(dag) |> Yog.has_edge?(1, 2)
false
"""
defdelegate remove_edge(dag, from, to), to: Yog.DAG.Model
# ============================================================
# Algorithms
# ============================================================
@doc """
Returns a topological ordering of all nodes in the DAG.
## Example
iex> {:ok, dag} = Yog.from_unweighted_edges(:directed, [{1, 2}, {2, 3}]) |> Yog.DAG.from_graph()
iex> Yog.DAG.topological_sort(dag)
[1, 2, 3]
"""
defdelegate topological_sort(dag), to: Yog.DAG.Algorithm
@doc """
Finds the longest path (critical path) in a weighted DAG.
## Example
iex> {:ok, dag} = Yog.from_edges(:directed, [{1, 2, 5}, {2, 3, 3}]) |> Yog.DAG.from_graph()
iex> Yog.DAG.longest_path(dag)
[1, 2, 3]
"""
defdelegate longest_path(dag), to: Yog.DAG.Algorithm
@doc """
Returns the topological generations of a DAG.
## Example
iex> {:ok, dag} = Yog.from_unweighted_edges(:directed, [{1, 2}, {1, 3}, {2, 4}, {3, 4}]) |> Yog.DAG.from_graph()
iex> Yog.DAG.topological_generations(dag)
[[1], [2, 3], [4]]
"""
defdelegate topological_generations(dag), to: Yog.DAG.Algorithm
@doc """
Finds the shortest path between two nodes in a weighted DAG.
## Example
iex> {:ok, dag} = Yog.from_edges(:directed, [{1, 2, 3}, {2, 3, 2}]) |> Yog.DAG.from_graph()
iex> {:ok, path} = Yog.DAG.shortest_path(dag, 1, 3)
iex> path.weight
5
"""
defdelegate shortest_path(dag, from, to), to: Yog.DAG.Algorithm
@doc """
Finds the lowest common ancestors (LCAs) of two nodes.
## Example
iex> {:ok, dag} = Yog.from_unweighted_edges(:directed, [{1, 3}, {2, 3}]) |> Yog.DAG.from_graph()
iex> Yog.DAG.lowest_common_ancestors(dag, 3, 3)
[3]
"""
defdelegate lowest_common_ancestors(dag, node_a, node_b), to: Yog.DAG.Algorithm
@doc "Returns all source nodes (in-degree 0)."
defdelegate sources(dag), to: Yog.DAG.Algorithm
@doc "Returns all sink nodes (out-degree 0)."
defdelegate sinks(dag), to: Yog.DAG.Algorithm
@doc "Returns all ancestors of a node (includes the node itself)."
defdelegate ancestors(dag, node), to: Yog.DAG.Algorithm
@doc "Returns all descendants of a node (includes the node itself)."
defdelegate descendants(dag, node), to: Yog.DAG.Algorithm
@doc "Computes single-source shortest distances to all reachable nodes."
defdelegate single_source_distances(dag, from), to: Yog.DAG.Algorithm
@doc "Finds the longest path between two specific nodes."
defdelegate longest_path(dag, from, to), to: Yog.DAG.Algorithm
@doc "Counts the number of distinct paths between two nodes."
defdelegate path_count(dag, from, to), to: Yog.DAG.Algorithm
end
defimpl Enumerable, for: Yog.DAG do
@moduledoc """
Enumerable implementation for `Yog.DAG`.
Iterates over nodes as `{id, data}` tuples via the underlying graph.
## Examples
iex> {:ok, dag} =
...> Yog.directed()
...> |> Yog.add_node(1, "A")
...> |> Yog.add_node(2, "B")
...> |> Yog.DAG.from_graph()
iex> Enum.to_list(dag)
[{1, "A"}, {2, "B"}]
iex> Enum.count(dag)
2
"""
def count(%Yog.DAG{graph: graph}) do
Enumerable.count(graph)
end
def member?(%Yog.DAG{graph: graph}, element) do
Enumerable.member?(graph, element)
end
def reduce(%Yog.DAG{graph: graph}, acc, fun) do
Enumerable.reduce(graph, acc, fun)
end
def slice(%Yog.DAG{graph: graph}) do
Enumerable.slice(graph)
end
end
defimpl Inspect, for: Yog.DAG do
@moduledoc """
Inspect implementation for `Yog.DAG`.
Provides a compact representation showing node and edge counts.
## Examples
iex> {:ok, dag} =
...> Yog.directed()
...> |> Yog.add_node(1, "A")
...> |> Yog.DAG.from_graph()
iex> inspect(dag)
"#Yog.DAG<1 node, 0 edges>"
"""
import Inspect.Algebra
def inspect(%Yog.DAG{graph: graph}, _opts) do
node_count = Yog.Model.node_count(graph)
edge_count = Yog.Graph.edge_count(graph)
node_str = if node_count == 1, do: "node", else: "nodes"
edge_str = if edge_count == 1, do: "edge", else: "edges"
concat([
"#Yog.DAG<",
"#{node_count} #{node_str}, ",
"#{edge_count} #{edge_str}",
">"
])
end
end