Current section
Files
Jump to
Current section
Files
lib/yog/dag/model.ex
defmodule Yog.DAG.Model do
@moduledoc """
Core Directed Acyclic Graph (DAG) type and basic operations.
This module provides the `Yog.DAG` struct that wraps a regular directed `Yog.Graph`
and guarantees acyclicity at the type level. Unlike a general graph, a DAG allows
for specialized algorithms like topological sorting and critical path analysis
to be total functions.
## Design Goals
- **Safety**: Ensure acyclicity at creation and during all edge insertions.
- **Efficiency**: Use targeted path checks for O(V+E) validation on insertion.
- **Interoperability**: Easy conversion to and from regular `Yog.Graph` structures.
## Example
iex> dag = Yog.DAG.Model.new(:directed)
iex> {:ok, dag} = Yog.DAG.Model.add_edge(dag, 1, 2, "depends")
iex> Yog.DAG.Model.add_edge(dag, 2, 1, "cycle")
{:error, :cycle_detected}
"""
alias Yog.DAG
alias Yog.Property.Cyclicity
@typedoc """
An opaque wrapper around a `Graph` that guarantees acyclicity at the type level.
Unlike a regular `Graph`, a `DAG` is statically proven to contain no cycles,
enabling total functions for operations like topological sorting.
"""
@type t :: %DAG{graph: Yog.Graph.t()}
@typedoc "Error type representing why a graph cannot be treated as a DAG."
@type error :: :cycle_detected
@doc """
Creates a new, empty DAG. Only `:directed` graphs are supported.
## Example
iex> dag = Yog.DAG.Model.new(:directed)
iex> Yog.Graph.node_count(Yog.DAG.Model.to_graph(dag))
0
"""
@spec new(Yog.Model.graph_type()) :: t()
def new(:directed) do
%DAG{graph: Yog.Graph.new(:directed)}
end
def new(:undirected) do
raise ArgumentError, "DAG must be directed; received :undirected"
end
@doc """
Creates a DAG from a list of edges.
## Example
iex> {:ok, dag} = Yog.DAG.Model.from_edges([{1, 2}, {2, 3}])
iex> Yog.DAG.Model.to_graph(dag) |> Yog.Model.has_edge?(1, 2)
true
"""
@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
edges
|> Enum.reduce(Yog.Graph.new(:directed), fn
{from, to}, g -> Yog.add_edge_ensure(g, from, to, 1)
{from, to, weight}, g -> Yog.add_edge_ensure(g, from, to, weight)
end)
|> from_graph()
end
@doc """
Creates a DAG from a list of edges with a default weight.
## Example
iex> {:ok, dag} = Yog.DAG.Model.from_edges([{1, 2}, {2, 3}], 10)
iex> Yog.DAG.Model.to_graph(dag) |> Yog.Model.edge_data(1, 2)
10
"""
@spec from_edges([{Yog.node_id(), Yog.node_id()}], any()) ::
{:ok, t()} | {:error, :cycle_detected}
def from_edges(edges, default_weight) do
edges
|> Enum.reduce(Yog.Graph.new(:directed), fn {from, to}, g ->
Yog.add_edge_ensure(g, from, to, default_weight)
end)
|> from_graph()
end
@doc """
Attempts to create a `DAG` from a regular `Graph`.
Validates that the graph contains no cycles. If validation passes, returns
`{:ok, dag}`; otherwise returns `{:error, :cycle_detected}`.
**Time Complexity:** O(V + E)
## Example
iex> graph = Yog.from_unweighted_edges(:directed, [{1, 2}, {2, 3}])
iex> {:ok, dag} = Yog.DAG.Model.from_graph(graph)
iex> Yog.DAG.Model.to_graph(dag) == graph
true
iex> graph = Yog.from_unweighted_edges(:directed, [{1, 2}, {2, 1}])
iex> Yog.DAG.Model.from_graph(graph)
{:error, :cycle_detected}
"""
@spec from_graph(Yog.graph()) :: {:ok, t()} | {:error, :cycle_detected}
def from_graph(%Yog.Graph{kind: :undirected}) do
{:error, :cycle_detected}
end
def from_graph(%Yog.Graph{} = graph) do
if Cyclicity.acyclic?(graph) do
{:ok, %DAG{graph: graph}}
else
{:error, :cycle_detected}
end
end
@doc """
Unwraps a `DAG` back into a regular `Graph`.
This is useful when you need to use operations that work on any graph type,
or when you want to export the DAG to formats that accept general graphs.
## Example
iex> dag = Yog.DAG.Model.new(:directed)
iex> graph = Yog.DAG.Model.to_graph(dag)
iex> Yog.graph?(graph)
true
"""
@spec to_graph(t()) :: Yog.graph()
def to_graph(%DAG{graph: graph}), do: graph
@doc """
Adds a node to the DAG.
Adding a node cannot create a cycle, so this operation is infallible.
**Time Complexity:** O(1)
## Example
iex> dag = Yog.DAG.Model.new(:directed) |> Yog.DAG.Model.add_node(1, "A")
iex> Yog.DAG.Model.to_graph(dag) |> Yog.node(1)
"A"
"""
@spec add_node(t(), Yog.node_id(), any()) :: t()
def add_node(%DAG{graph: graph}, id, data) do
%DAG{graph: Yog.Model.add_node(graph, id, data)}
end
@doc """
Removes a node and all its connected edges from the DAG.
Removing nodes/edges cannot create a cycle, so this operation is infallible.
**Time Complexity:** O(deg(v)) - proportional to the number of edges
connected to the node.
## Example
iex> dag = Yog.DAG.Model.new(:directed) |> Yog.DAG.Model.add_node(1, "A")
iex> dag = Yog.DAG.Model.remove_node(dag, 1)
iex> Yog.DAG.Model.to_graph(dag) |> Yog.has_node?(1)
false
"""
@spec remove_node(t(), Yog.node_id()) :: t()
def remove_node(%DAG{graph: graph}, id) do
%DAG{graph: Yog.Model.remove_node(graph, id)}
end
@doc """
Removes an edge from the DAG.
Removing edges cannot create a cycle, so this operation is infallible.
**Time Complexity:** O(1)
## Example
iex> {:ok, dag} = Yog.DAG.Model.new(:directed) |> Yog.DAG.Model.add_edge(1, 2, 10)
iex> dag = Yog.DAG.Model.remove_edge(dag, 1, 2)
iex> Yog.DAG.Model.to_graph(dag) |> Yog.has_edge?(1, 2)
false
"""
@spec remove_edge(t(), Yog.node_id(), Yog.node_id()) :: t()
def remove_edge(%DAG{graph: graph}, from, to) do
%DAG{graph: Yog.Model.remove_edge(graph, from, to)}
end
@doc """
Adds an edge to the DAG.
Because adding an edge can potentially create a cycle, this operation must
validate the resulting graph. Returns `{:ok, dag}` if no cycle is created,
and `{:error, :cycle_detected}` otherwise.
**Time Complexity:** O(V + E) (due to required cycle check on insertion).
## Example
iex> dag = Yog.DAG.Model.new(:directed)
iex> {:ok, dag} = Yog.DAG.Model.add_edge(dag, 1, 2, 10)
iex> Yog.DAG.Model.add_edge(dag, 2, 1, 5)
{:error, :cycle_detected}
"""
@spec add_edge(t(), Yog.node_id(), Yog.node_id(), any()) ::
{:ok, t()} | {:error, :cycle_detected}
def add_edge(%DAG{graph: graph}, from, to, weight) do
# An edge from A to B creates a cycle ONLY if there's already a path from B to A.
# We use a targeted BFS (reachable?) which terminates early to avoid full O(V+E)
# Kahn's topological sort checking per edge.
if Yog.Traversal.reachable?(graph, to, from) do
{:error, :cycle_detected}
else
new_graph = Yog.Model.add_edge_ensure(graph, from, to, weight)
{:ok, %DAG{graph: new_graph}}
end
end
end