Current section

Files

Jump to
yog_ex lib yog builder live.ex
Raw

lib/yog/builder/live.ex

defmodule Yog.Builder.Live do
@moduledoc """
A live builder for incremental graph construction with label-to-ID registry.
Unlike the static `Yog.Builder.Labeled` which follows a "Build-Freeze-Analyze" pattern,
`Live` provides a **Transaction-style API** that tracks pending changes.
This allows efficient synchronization of an existing `Graph` with new labeled edges
in O(ΔE) time, where ΔE is the number of new edges since last sync.
## Use Cases
- **REPL environments**: Incrementally build and analyze graphs
- **UI editors**: Add nodes/edges interactively without rebuilding
- **Streaming data**: Ingest new relationships as they arrive
- **Large graphs**: Avoid O(E) rebuild for single-edge updates
## Guarantees
- **ID Stability:** Once a label is mapped to a `NodeId`, that mapping is immutable
- **Idempotency:** Calling `sync/2` with no pending changes is effectively free
- **Opaque Integration:** Uses the same ID generation as static builders
## Important: Managing the Pending Queue
The `Live` builder queues changes in memory until `sync/2` is called. In streaming
scenarios, if you add edges continuously without syncing, the pending queue will
grow unbounded and consume memory.
**Best Practice:** Sync periodically based on your workload:
# For high-frequency streaming (e.g., Kafka consumer)
# Sync every N messages or every T seconds
{builder, graph} =
if Yog.Builder.Live.pending_count(builder) > 1000 do
Yog.Builder.Live.sync(builder, graph)
else
{builder, graph}
end
# For batch processing
# Build up a batch, then sync once
builder = Enum.reduce(batch, builder, fn {from, to, weight}, b ->
Yog.Builder.Live.add_edge(b, from, to, weight)
end)
{builder, graph} = Yog.Builder.Live.sync(builder, graph)
## Recovery
If you need to discard pending changes without applying them:
- Use `purge_pending/1` to abandon changes
- Use `checkpoint/1` to keep registry but clear pending
## Limitations
- **Memory:** Pending changes are stored in memory until synced
- **No Persistence:** The pending queue is lost if the process crashes
- **Single-threaded:** Not designed for concurrent updates from multiple actors
## Example Usage
# Initial setup - build base graph
builder = Yog.Builder.Live.new() |> Yog.Builder.Live.add_edge("A", "B", 10)
{builder, graph} = Yog.Builder.Live.sync(builder, Yog.directed())
# Incremental update - add new edge efficiently
builder = Yog.Builder.Live.add_edge(builder, "B", "C", 5)
{builder, graph} = Yog.Builder.Live.sync(builder, graph) # O(1) for just this edge!
# Use with algorithms - get IDs from registry
{:ok, a_id} = Yog.Builder.Live.get_id(builder, "A")
{:ok, c_id} = Yog.Builder.Live.get_id(builder, "C")
> **Migration Note:** This module was ported from Gleam to pure Elixir in v0.53.0.
> The API remains unchanged.
"""
alias Yog.Builder.Labeled
alias Yog.Model
@typedoc "Live builder type: {:live_builder, registry, next_id, pending}"
@type builder :: {:live_builder, map(), integer(), [transition()]}
@typedoc "Any type can be used as a label"
@type label :: term()
@typedoc "A pending transition"
@type transition ::
{:add_node, Yog.node_id(), label()}
| {:add_edge, Yog.node_id(), Yog.node_id(), term()}
| {:remove_edge, Yog.node_id(), Yog.node_id()}
| {:remove_node, Yog.node_id()}
# ============= Constructors =============
@doc """
Creates a new live builder for directed graphs.
## Examples
iex> builder = Yog.Builder.Live.directed()
iex> is_tuple(builder)
true
"""
@spec directed() :: builder()
def directed, do: new()
@doc """
Creates a new live builder for undirected graphs.
## Examples
iex> builder = Yog.Builder.Live.undirected()
iex> is_tuple(builder)
true
"""
@spec undirected() :: builder()
def undirected, do: new()
@doc """
Creates a new live builder with the specified graph type.
## Examples
iex> builder = Yog.Builder.Live.new()
iex> is_tuple(builder)
true
"""
@spec new() :: builder()
def new do
{:live_builder, %{}, 0, []}
end
@doc """
Creates a live builder from an existing labeled builder.
This is useful for transitioning from static to incremental building.
## Examples
iex> labeled = Yog.Builder.Labeled.directed()
...> |> Yog.Builder.Labeled.add_edge("A", "B", 5)
iex> Yog.Builder.Live.from_labeled(labeled)
...> |> is_tuple()
true
"""
@spec from_labeled(Labeled.builder()) :: builder()
def from_labeled(labeled_builder) do
registry = Labeled.to_registry(labeled_builder)
next_id = Labeled.next_id(labeled_builder)
{:live_builder, registry, next_id, []}
end
# ============= Edge Operations =============
@doc """
Adds an edge between two labeled nodes with a weight.
The change is queued until `sync/2` is called.
## Examples
iex> builder = Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
iex> Yog.Builder.Live.pending_count(builder) > 0
true
"""
@spec add_edge(builder(), label(), label(), term()) :: builder()
def add_edge(builder, from, to, weight) do
{builder_with_src, src_id} = ensure_node(builder, from)
{builder_with_both, dst_id} = ensure_node(builder_with_src, to)
{:live_builder, registry, next_id, pending} = builder_with_both
transition = {:add_edge, src_id, dst_id, weight}
{:live_builder, registry, next_id, [transition | pending]}
end
@doc """
Adds an unweighted edge (weight = nil) between two labeled nodes.
## Examples
iex> Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_unweighted_edge("A", "B")
...> |> is_tuple()
true
"""
@spec add_unweighted_edge(builder(), label(), label()) :: builder()
def add_unweighted_edge(builder, from, to) do
add_edge(builder, from, to, nil)
end
@doc """
Adds a simple edge with weight 1 between two labeled nodes.
## Examples
iex> Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_simple_edge("A", "B")
...> |> is_tuple()
true
"""
@spec add_simple_edge(builder(), label(), label()) :: builder()
def add_simple_edge(builder, from, to) do
add_edge(builder, from, to, 1)
end
@doc """
Removes an edge between two labeled nodes.
The change is queued until `sync/2` is called.
## Examples
iex> Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
...> |> Yog.Builder.Live.remove_edge("A", "B")
...> |> is_tuple()
true
"""
@spec remove_edge(builder(), label(), label()) :: builder()
def remove_edge({:live_builder, registry, next_id, pending} = builder, from, to) do
case {Map.fetch(registry, from), Map.fetch(registry, to)} do
{{:ok, src_id}, {:ok, dst_id}} ->
transition = {:remove_edge, src_id, dst_id}
{:live_builder, registry, next_id, [transition | pending]}
_ ->
# One or both nodes don't exist, nothing to remove
builder
end
end
@doc """
Removes a node by its label.
Also removes all edges connected to this node.
The change is queued until `sync/2` is called.
## Examples
iex> Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
...> |> Yog.Builder.Live.remove_node("A")
...> |> is_tuple()
true
"""
@spec remove_node(builder(), label()) :: builder()
def remove_node({:live_builder, registry, next_id, pending}, label) do
case Map.fetch(registry, label) do
{:ok, id} ->
new_registry = Map.delete(registry, label)
transition = {:remove_node, id}
{:live_builder, new_registry, next_id, [transition | pending]}
:error ->
# Node doesn't exist, nothing to remove
{:live_builder, registry, next_id, pending}
end
end
# ============= Synchronization =============
@doc """
Applies all pending changes to the graph.
Returns `{builder, updated_graph}` where the builder has cleared its pending queue.
This is an O(ΔE) operation where ΔE is the number of pending edges.
## Examples
iex> builder = Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
iex> {_builder, graph} = Yog.Builder.Live.sync(builder, Yog.directed())
iex> length(Yog.all_nodes(graph))
2
"""
@spec sync(builder(), Yog.graph()) :: {builder(), Yog.graph()}
def sync({:live_builder, registry, next_id, pending}, graph) do
case pending do
[] ->
# No pending changes - fast path
{{:live_builder, registry, next_id, []}, graph}
_ ->
# Reverse to apply in insertion order (we prepended)
transitions = Enum.reverse(pending)
# Apply all transitions
new_graph = apply_transitions(graph, transitions)
# Return builder with empty pending
{{:live_builder, registry, next_id, []}, new_graph}
end
end
@doc """
Discards all pending changes without applying them.
The registry (label-to-ID mappings) is preserved.
## Examples
iex> builder = Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
iex> builder = Yog.Builder.Live.purge_pending(builder)
iex> Yog.Builder.Live.pending_count(builder)
0
"""
@spec purge_pending(builder()) :: builder()
def purge_pending({:live_builder, registry, next_id, _pending}) do
{:live_builder, registry, next_id, []}
end
@doc """
Creates a checkpoint by clearing pending changes while preserving the registry.
Similar to `purge_pending/1` but conceptually marks a save point.
## Examples
iex> builder = Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
iex> builder = Yog.Builder.Live.checkpoint(builder)
iex> Yog.Builder.Live.pending_count(builder)
0
"""
@spec checkpoint(builder()) :: builder()
def checkpoint({:live_builder, registry, next_id, _pending}) do
{:live_builder, registry, next_id, []}
end
# ============= Queries =============
@doc """
Looks up the internal node ID for a given label.
Returns `{:ok, id}` if the label exists in the registry,
`{:error, nil}` otherwise.
## Examples
iex> builder = Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
...> |> Yog.Builder.Live.sync(Yog.directed())
...> |> elem(0)
iex> Yog.Builder.Live.get_id(builder, "A")
{:ok, 0}
"""
@spec get_id(builder(), label()) :: {:ok, Yog.node_id()} | {:error, nil}
def get_id({:live_builder, registry, _next_id, _pending}, label) do
case Map.fetch(registry, label) do
{:ok, id} -> {:ok, id}
:error -> {:error, nil}
end
end
@doc """
Returns all labels that have been registered.
## Examples
iex> builder = Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
iex> labels = Yog.Builder.Live.all_labels(builder)
iex> Enum.sort(labels)
["A", "B"]
"""
@spec all_labels(builder()) :: [label()]
def all_labels({:live_builder, registry, _next_id, _pending}) do
Map.keys(registry)
end
@doc """
Returns the number of registered nodes.
## Examples
iex> builder = Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
iex> Yog.Builder.Live.node_count(builder)
2
"""
@spec node_count(builder()) :: integer()
def node_count({:live_builder, registry, _next_id, _pending}) do
map_size(registry)
end
@doc """
Returns the number of pending changes.
Use this to monitor queue growth and trigger syncs when needed.
## Examples
iex> builder = Yog.Builder.Live.new()
...> |> Yog.Builder.Live.add_edge("A", "B", 10)
iex> Yog.Builder.Live.pending_count(builder) > 0
true
"""
@spec pending_count(builder()) :: integer()
def pending_count({:live_builder, _registry, _next_id, pending}) do
length(pending)
end
# ============= Private Helpers =============
defp ensure_node({:live_builder, registry, next_id, pending}, label) do
case Map.fetch(registry, label) do
{:ok, id} ->
{{:live_builder, registry, next_id, pending}, id}
:error ->
id = next_id
new_registry = Map.put(registry, label, id)
transition = {:add_node, id, label}
new_builder = {:live_builder, new_registry, id + 1, [transition | pending]}
{new_builder, id}
end
end
defp apply_transitions(graph, transitions) do
Enum.reduce(transitions, graph, fn transition, g ->
case transition do
{:add_node, id, label} ->
Model.add_node(g, id, label)
{:add_edge, src, dst, weight} ->
case Model.add_edge(g, src, dst, weight) do
{:ok, new_g} -> new_g
{:error, _} -> g
end
{:remove_edge, src, dst} ->
Model.remove_edge(g, src, dst)
{:remove_node, id} ->
Model.remove_node(g, id)
end
end)
end
end