Current section
Files
Jump to
Current section
Files
src/yog@builder@live.erl
-module(yog@builder@live).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/yog/builder/live.gleam").
-export([new/0, directed/0, undirected/0, add_edge/4, add_unweighted_edge/3, add_simple_edge/3, remove_edge/3, remove_node/2, sync/2, purge_pending/1, checkpoint/1, get_id/2, all_labels/1, node_count/1, pending_count/1, from_labeled/1]).
-export_type([transition/2, live_builder/2]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" A live builder for incremental graph construction with label-to-ID registry.\n"
"\n"
" Unlike the static `labeled` builder which follows a \"Build-Freeze-Analyze\" pattern,\n"
" `LiveBuilder` provides a **Transaction-style API** that tracks pending changes.\n"
" This allows efficient synchronization of an existing `Graph` with new labeled edges\n"
" in $O(\\Delta E)$ time, where $\\Delta E$ is the number of new edges since last sync.\n"
"\n"
" ## Use Cases\n"
"\n"
" - **REPL environments**: Incrementally build and analyze graphs\n"
" - **UI editors**: Add nodes/edges interactively without rebuilding\n"
" - **Streaming data**: Ingest new relationships as they arrive\n"
" - **Large graphs**: Avoid $O(E)$ rebuild for single-edge updates\n"
"\n"
" ## Guarantees\n"
"\n"
" - **ID Stability:** Once a label is mapped to a `NodeId`, that mapping is immutable\n"
" - **Idempotency:** Calling `sync` with no pending changes is effectively free\n"
" - **Opaque Integration:** Uses the same ID generation as static builders\n"
"\n"
" ## Important: Managing the Pending Queue\n"
"\n"
" The `LiveBuilder` queues changes in memory until `sync()` is called. In streaming\n"
" scenarios, if you add edges continuously without syncing, the pending queue will\n"
" grow unbounded and consume memory.\n"
"\n"
" **Best Practice:** Sync periodically based on your workload:\n"
"\n"
" ```gleam\n"
" // For high-frequency streaming (e.g., Kafka consumer)\n"
" // Sync every N messages or every T seconds\n"
" let #(builder, graph) = case live.pending_count(builder) > 1000 {\n"
" True -> live.sync(builder, graph)\n"
" False -> #(builder, graph)\n"
" }\n"
"\n"
" // For batch processing\n"
" // Build up a batch, then sync once\n"
" let builder = list.fold(batch, builder, fn(b, edge) {\n"
" live.add_edge(b, edge.0, edge.1, edge.2)\n"
" })\n"
" let #(builder, graph) = live.sync(builder, graph)\n"
" ```\n"
"\n"
" **Recovery:** If you need to discard pending changes without applying them,\n"
" use `purge_pending()` (abandon changes) or `checkpoint()` (keep registry).\n"
"\n"
" ## Limitations\n"
"\n"
" - **Memory:** Pending changes are stored in memory until synced\n"
" - **No Persistence:** The pending queue is lost if the process crashes\n"
" - **Single-threaded:** Not designed for concurrent updates from multiple actors\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" import yog/builder/live\n"
" import yog/pathfinding/dijkstra as pathfinding\n"
" import gleam/int\n"
"\n"
" // Initial setup - build base graph\n"
" let builder = live.new() |> live.add_edge(\"A\", \"B\", 10)\n"
" let #(builder, graph) = live.sync(builder, yog.directed())\n"
"\n"
" // Incremental update - add new edge efficiently\n"
" let builder = builder |> live.add_edge(\"B\", \"C\", 5)\n"
" let #(builder, graph) = live.sync(builder, graph) // O(1) for just this edge!\n"
"\n"
" // Use with algorithms - get IDs from registry\n"
" let assert Ok(a_id) = live.get_id(builder, \"A\")\n"
" let assert Ok(c_id) = live.get_id(builder, \"C\")\n"
" let path = pathfinding.shortest_path(graph, a_id, c_id, ...)\n"
" ```\n"
).
-type transition(NBB, NBC) :: {add_node, integer(), NBB} |
{add_edge, integer(), integer(), NBC} |
{remove_edge, integer(), integer()} |
{remove_node, integer()}.
-opaque live_builder(NBD, NBE) :: {live_builder,
gleam@dict:dict(NBD, integer()),
integer(),
list(transition(NBD, NBE))}.
-file("src/yog/builder/live.gleam", 115).
?DOC(
" Creates a new empty live builder.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder = live.new()\n"
" ```\n"
).
-spec new() -> live_builder(any(), any()).
new() ->
{live_builder, maps:new(), 0, []}.
-file("src/yog/builder/live.gleam", 129).
?DOC(
" Creates a new live builder with a directed graph type in mind.\n"
"\n"
" This is a convenience function - the builder itself doesn't store\n"
" the graph type, but it helps document intent.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder = live.directed()\n"
" ```\n"
).
-spec directed() -> live_builder(any(), any()).
directed() ->
new().
-file("src/yog/builder/live.gleam", 143).
?DOC(
" Creates a new live builder with an undirected graph type in mind.\n"
"\n"
" This is a convenience function - the builder itself doesn't store\n"
" the graph type, but it helps document intent.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder = live.undirected()\n"
" ```\n"
).
-spec undirected() -> live_builder(any(), any()).
undirected() ->
new().
-file("src/yog/builder/live.gleam", 158).
?DOC(
" Gets or creates a node ID for the given label.\n"
"\n"
" If the label already exists in the registry, returns its existing ID.\n"
" If not, assigns a new ID and queues an `AddNode` transition.\n"
"\n"
" > **Note:** This function is idempotent - calling it multiple times with the\n"
" > same label always returns the same ID without adding duplicate transitions.\n"
"\n"
" ## Complexity\n"
"\n"
" - $O(\\log N)$ for dict lookup/insert where N is number of registered labels\n"
).
-spec ensure_node(live_builder(NBR, NBS), NBR) -> {live_builder(NBR, NBS),
integer()}.
ensure_node(Builder, Label) ->
case gleam_stdlib:map_get(erlang:element(2, Builder), Label) of
{ok, Id} ->
{Builder, Id};
{error, _} ->
Id@1 = erlang:element(3, Builder),
New_registry = gleam@dict:insert(
erlang:element(2, Builder),
Label,
Id@1
),
Transition = {add_node, Id@1, Label},
New_pending = [Transition | erlang:element(4, Builder)],
{{live_builder, New_registry, Id@1 + 1, New_pending}, Id@1}
end.
-file("src/yog/builder/live.gleam", 198).
?DOC(
" Adds an edge between two labeled nodes.\n"
"\n"
" If either label doesn't exist in the registry, new nodes are created.\n"
" The edge is queued as a pending transition and will be applied on next `sync`.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder =\n"
" live.new()\n"
" |> live.add_edge(\"home\", \"work\", 10)\n"
" |> live.add_edge(\"work\", \"gym\", 5)\n"
" ```\n"
"\n"
" ## Complexity\n"
"\n"
" - $O(\\log N)$ per label lookup/insert\n"
).
-spec add_edge(live_builder(NBX, NBY), NBX, NBX, NBY) -> live_builder(NBX, NBY).
add_edge(Builder, Src_label, Dst_label, Weight) ->
{Builder@1, Src_id} = ensure_node(Builder, Src_label),
{Builder@2, Dst_id} = ensure_node(Builder@1, Dst_label),
Transition = {add_edge, Src_id, Dst_id, Weight},
{live_builder,
erlang:element(2, Builder@2),
erlang:element(3, Builder@2),
[Transition | erlang:element(4, Builder@2)]}.
-file("src/yog/builder/live.gleam", 223).
?DOC(
" Adds an unweighted edge between two labeled nodes.\n"
"\n"
" This is a convenience function for graphs where edges have no meaningful weight.\n"
" Uses `Nil` as the edge data type.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder: live.LiveBuilder(String, Nil) =\n"
" live.directed()\n"
" |> live.add_unweighted_edge(\"A\", \"B\")\n"
" ```\n"
).
-spec add_unweighted_edge(live_builder(NCD, nil), NCD, NCD) -> live_builder(NCD, nil).
add_unweighted_edge(Builder, Src_label, Dst_label) ->
add_edge(Builder, Src_label, Dst_label, nil).
-file("src/yog/builder/live.gleam", 244).
?DOC(
" Adds a simple edge with weight 1 between two labeled nodes.\n"
"\n"
" This is a convenience function for graphs with integer weights where\n"
" a default weight of 1 is appropriate (e.g., unweighted graphs, hop counts).\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder =\n"
" live.directed()\n"
" |> live.add_simple_edge(\"A\", \"B\")\n"
" |> live.add_simple_edge(\"B\", \"C\")\n"
" ```\n"
).
-spec add_simple_edge(live_builder(NCI, integer()), NCI, NCI) -> live_builder(NCI, integer()).
add_simple_edge(Builder, Src_label, Dst_label) ->
add_edge(Builder, Src_label, Dst_label, 1).
-file("src/yog/builder/live.gleam", 268).
?DOC(
" Removes an edge between two labeled nodes.\n"
"\n"
" If either label doesn't exist, no transition is queued.\n"
" The removal is queued as a pending transition.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder =\n"
" builder\n"
" |> live.remove_edge(\"A\", \"B\")\n"
" ```\n"
"\n"
" ## Complexity\n"
"\n"
" - $O(\\log N)$ per label lookup\n"
).
-spec remove_edge(live_builder(NCN, NCO), NCN, NCN) -> live_builder(NCN, NCO).
remove_edge(Builder, Src_label, Dst_label) ->
case {gleam_stdlib:map_get(erlang:element(2, Builder), Src_label),
gleam_stdlib:map_get(erlang:element(2, Builder), Dst_label)} of
{{ok, Src_id}, {ok, Dst_id}} ->
Transition = {remove_edge, Src_id, Dst_id},
{live_builder,
erlang:element(2, Builder),
erlang:element(3, Builder),
[Transition | erlang:element(4, Builder)]};
{_, _} ->
Builder
end.
-file("src/yog/builder/live.gleam", 305).
?DOC(
" Removes a node by its label.\n"
"\n"
" The node and all its connected edges are removed. The ID is NOT reused.\n"
" The label is removed from the registry so a future add would get a new ID.\n"
"\n"
" > **Warning:** Removing a node invalidates any cached IDs for that label.\n"
" > After sync, `get_id(builder, label)` will return `Error(Nil)`.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder =\n"
" builder\n"
" |> live.remove_node(\"obsolete_node\")\n"
" ```\n"
"\n"
" ## Complexity\n"
"\n"
" - $O(\\log N)$ for dict lookup/removal\n"
).
-spec remove_node(live_builder(NCT, NCU), NCT) -> live_builder(NCT, NCU).
remove_node(Builder, Label) ->
case gleam_stdlib:map_get(erlang:element(2, Builder), Label) of
{ok, Id} ->
New_registry = gleam@dict:delete(erlang:element(2, Builder), Label),
Transition = {remove_node, Id},
{live_builder,
New_registry,
erlang:element(3, Builder),
[Transition | erlang:element(4, Builder)]};
{error, _} ->
Builder
end.
-file("src/yog/builder/live.gleam", 370).
?DOC(" Applies a list of transitions to a graph.\n").
-spec apply_transitions(yog@model:graph(NDJ, NDK), list(transition(NDJ, NDK))) -> yog@model:graph(NDJ, NDK).
apply_transitions(Graph, Transitions) ->
gleam@list:fold(Transitions, Graph, fun(G, Transition) -> case Transition of
{add_node, Id, Label} ->
yog@model:add_node(G, Id, Label);
{add_edge, Src, Dst, Weight} ->
yog@model:add_edge(G, Src, Dst, Weight);
{remove_edge, Src@1, Dst@1} ->
yog@model:remove_edge(G, Src@1, Dst@1);
{remove_node, Id@1} ->
yog@model:remove_node(G, Id@1)
end end).
-file("src/yog/builder/live.gleam", 349).
?DOC(
" Synchronizes pending changes to the given graph.\n"
"\n"
" Applies all pending transitions (in order) to the provided graph,\n"
" then returns a new builder with an empty pending list and the updated graph.\n"
"\n"
" > **Note:** If there are no pending changes, this returns immediately\n"
" > with the same builder and graph (effectively O(1)).\n"
"\n"
" > **Warning:** The graph type (directed/undirected) is determined by the\n"
" > input graph. Make sure to provide a graph of the correct type on first sync.\n"
"\n"
" > **Performance:** For large pending queues (>1000 items), consider syncing\n"
" > more frequently to avoid memory pressure and reduce sync latency.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" // Initial build\n"
" let builder = live.new() |> live.add_edge(\"A\", \"B\", 10)\n"
" let #(builder, graph) = live.sync(builder, yog.directed())\n"
"\n"
" // Incremental update\n"
" let builder = builder |> live.add_edge(\"B\", \"C\", 5)\n"
" let #(builder, graph) = live.sync(builder, graph) // Only applies B->C!\n"
" ```\n"
"\n"
" ## Complexity\n"
"\n"
" - $O(\\Delta E)$ where $\\Delta E$ is the number of pending transitions\n"
).
-spec sync(live_builder(NCZ, NDA), yog@model:graph(NCZ, NDA)) -> {live_builder(NCZ, NDA),
yog@model:graph(NCZ, NDA)}.
sync(Builder, Graph) ->
case erlang:element(4, Builder) of
[] ->
{Builder, Graph};
Pending ->
Transitions = lists:reverse(Pending),
New_graph = apply_transitions(Graph, Transitions),
{{live_builder,
erlang:element(2, Builder),
erlang:element(3, Builder),
[]},
New_graph}
end.
-file("src/yog/builder/live.gleam", 411).
?DOC(
" Discards all pending transitions without applying them.\n"
"\n"
" This is a \"hard reset\" that abandons all queued changes. The registry\n"
" (label→ID mappings) is preserved. Use this when you detect an error\n"
" in your batch and want to start fresh.\n"
"\n"
" > **Compare:** `checkpoint()` also clears pending but is intended for\n"
" > marking progress. `purge_pending()` is for abandoning work.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" // Queue some changes\n"
" let builder = live.add_edge(builder, \"A\", \"B\", 10)\n"
"\n"
" // Oops, wrong data! Purge and start over.\n"
" let builder = live.purge_pending(builder)\n"
" // builder has no pending changes, registry still has A and B\n"
" ```\n"
).
-spec purge_pending(live_builder(NDS, NDT)) -> live_builder(NDS, NDT).
purge_pending(Builder) ->
{live_builder, erlang:element(2, Builder), erlang:element(3, Builder), []}.
-file("src/yog/builder/live.gleam", 435).
?DOC(
" Marks a checkpoint by discarding pending transitions.\n"
"\n"
" This is useful when you want to \"commit\" progress and discard the\n"
" pending queue without applying it to a graph. The registry is preserved.\n"
"\n"
" > **Compare:** `purge_pending()` has the same effect but different intent.\n"
" > Use `checkpoint()` for progress tracking, `purge_pending()` for error recovery.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let builder =\n"
" live.new()\n"
" |> live.add_edge(\"A\", \"B\", 10)\n"
" |> live.checkpoint() // Discard the pending edge\n"
"\n"
" // builder now has no pending changes\n"
" let #(builder, graph) = live.sync(builder, yog.directed())\n"
" // graph is empty - the edge was discarded\n"
" ```\n"
).
-spec checkpoint(live_builder(NDY, NDZ)) -> live_builder(NDY, NDZ).
checkpoint(Builder) ->
{live_builder, erlang:element(2, Builder), erlang:element(3, Builder), []}.
-file("src/yog/builder/live.gleam", 456).
?DOC(
" Looks up the node ID for a given label.\n"
"\n"
" Returns `Ok(id)` if the label has been registered, `Error(Nil)` if not.\n"
" Use this to get node IDs for use with graph algorithms.\n"
"\n"
" > **Note:** After `remove_node`, this will return `Error(Nil)` for that label.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let assert Ok(home_id) = live.get_id(builder, \"home\")\n"
" let path = dijkstra.shortest_path(graph, home_id, ...)\n"
" ```\n"
"\n"
" ## Complexity\n"
"\n"
" - $O(\\log N)$ for dict lookup\n"
).
-spec get_id(live_builder(NEE, any()), NEE) -> {ok, integer()} | {error, nil}.
get_id(Builder, Label) ->
gleam_stdlib:map_get(erlang:element(2, Builder), Label).
-file("src/yog/builder/live.gleam", 468).
?DOC(
" Returns all labels that have been registered.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let labels = live.all_labels(builder)\n"
" // [\"A\", \"B\", \"C\"]\n"
" ```\n"
).
-spec all_labels(live_builder(NEK, any())) -> list(NEK).
all_labels(Builder) ->
maps:keys(erlang:element(2, Builder)).
-file("src/yog/builder/live.gleam", 479).
?DOC(
" Returns the number of registered labels (nodes).\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" live.node_count(builder) // 5\n"
" ```\n"
).
-spec node_count(live_builder(any(), any())) -> integer().
node_count(Builder) ->
maps:size(erlang:element(2, Builder)).
-file("src/yog/builder/live.gleam", 496).
?DOC(
" Returns the number of pending transitions.\n"
"\n"
" Useful for debugging or deciding whether to sync based on batch size.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" // Sync every 1000 changes to avoid unbounded growth\n"
" case live.pending_count(builder) > 1000 {\n"
" True -> live.sync(builder, graph)\n"
" False -> #(builder, graph)\n"
" }\n"
" ```\n"
).
-spec pending_count(live_builder(any(), any())) -> integer().
pending_count(Builder) ->
erlang:length(erlang:element(4, Builder)).
-file("src/yog/builder/live.gleam", 522).
?DOC(
" Creates a live builder from an existing labeled builder.\n"
"\n"
" This allows migration from static to incremental building.\n"
" The pending list starts empty - call `sync` separately to apply changes.\n"
"\n"
" > **Note:** This uses the labeled builder's exported registry. The label→ID\n"
" > mappings are preserved exactly.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" import yog/builder/labeled\n"
"\n"
" // Start with static builder\n"
" let static = labeled.directed() |> labeled.add_edge(\"A\", \"B\", 10)\n"
" let graph = labeled.to_graph(static)\n"
"\n"
" // Convert to live for incremental updates\n"
" let live_builder = live.from_labeled(static)\n"
" let live_builder = live.add_edge(live_builder, \"B\", \"C\", 5)\n"
" let #(live_builder, graph) = live.sync(live_builder, graph)\n"
" ```\n"
).
-spec from_labeled(yog@builder@labeled:builder(NEX, NEY)) -> live_builder(NEX, NEY).
from_labeled(Labeled_builder) ->
Registry = yog@builder@labeled:to_registry(Labeled_builder),
Next_id = yog@builder@labeled:next_id(Labeled_builder),
{live_builder, Registry, Next_id, []}.