Packages
nous
0.12.2
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/decisions/store/ets.ex
defmodule Nous.Decisions.Store.ETS do
@moduledoc """
ETS-backed decision graph store.
Uses two unnamed ETS tables (one for nodes, one for edges) so multiple
instances can coexist. Graph queries use in-memory BFS traversal.
## Quick Start
{:ok, state} = Nous.Decisions.Store.ETS.init([])
node = Nous.Decisions.Node.new(%{type: :goal, label: "Ship v1.0"})
{:ok, state} = Nous.Decisions.Store.ETS.add_node(state, node)
"""
@behaviour Nous.Decisions.Store
alias Nous.Decisions.{Node, Edge}
@impl true
@doc """
Create two ETS tables for nodes and edges.
## Options
None currently supported.
"""
@spec init(keyword()) :: {:ok, map()}
def init(_opts) do
nodes = :ets.new(:decision_nodes, [:set, :public])
edges = :ets.new(:decision_edges, [:set, :public])
{:ok, %{nodes: nodes, edges: edges}}
end
@impl true
@spec add_node(map(), Node.t()) :: {:ok, map()}
def add_node(%{nodes: nodes} = state, %Node{} = node) do
:ets.insert(nodes, {node.id, node})
{:ok, state}
end
@impl true
@spec update_node(map(), String.t(), map()) :: {:ok, map()} | {:error, :not_found}
def update_node(%{nodes: nodes} = state, id, updates) do
case get_node(state, id) do
{:ok, node} ->
now = DateTime.utc_now()
updated = struct(node, Map.put(updates, :updated_at, now))
:ets.insert(nodes, {id, updated})
{:ok, state}
error ->
error
end
end
@impl true
@spec get_node(map(), String.t()) :: {:ok, Node.t()} | {:error, :not_found}
def get_node(%{nodes: nodes}, id) do
case :ets.lookup(nodes, id) do
[{^id, node}] -> {:ok, node}
[] -> {:error, :not_found}
end
end
@impl true
@spec delete_node(map(), String.t()) :: {:ok, map()}
def delete_node(%{nodes: nodes, edges: edges} = state, id) do
:ets.delete(nodes, id)
# Remove edges that reference this node
edges
|> all_edges()
|> Enum.each(fn edge ->
if edge.from_id == id || edge.to_id == id do
:ets.delete(edges, edge.id)
end
end)
{:ok, state}
end
@impl true
@spec add_edge(map(), Edge.t()) :: {:ok, map()}
def add_edge(%{edges: edges} = state, %Edge{} = edge) do
:ets.insert(edges, {edge.id, edge})
{:ok, state}
end
@impl true
@spec get_edges(map(), String.t(), :outgoing | :incoming) :: {:ok, [Edge.t()]}
def get_edges(%{edges: edges}, node_id, direction) do
results =
edges
|> all_edges()
|> Enum.filter(fn edge ->
case direction do
:outgoing -> edge.from_id == node_id
:incoming -> edge.to_id == node_id
end
end)
{:ok, results}
end
@impl true
@spec query(map(), atom(), keyword()) :: {:ok, [Node.t()]}
def query(state, :active_goals, _opts) do
results =
state.nodes
|> all_nodes()
|> Enum.filter(fn node -> node.type == :goal && node.status == :active end)
{:ok, results}
end
def query(state, :recent_decisions, opts) do
limit = Keyword.get(opts, :limit, 10)
results =
state.nodes
|> all_nodes()
|> Enum.filter(fn node -> node.type == :decision end)
|> Enum.sort_by(& &1.created_at, {:desc, DateTime})
|> Enum.take(limit)
{:ok, results}
end
def query(state, :path_between, opts) do
from_id = Keyword.fetch!(opts, :from_id)
to_id = Keyword.fetch!(opts, :to_id)
{:ok, bfs_path(state, from_id, to_id)}
end
def query(state, :descendants, opts) do
node_id = Keyword.fetch!(opts, :node_id)
{:ok, bfs_reachable(state, node_id, :outgoing)}
end
def query(state, :ancestors, opts) do
node_id = Keyword.fetch!(opts, :node_id)
{:ok, bfs_reachable(state, node_id, :incoming)}
end
def query(_state, _query_type, _opts) do
{:ok, []}
end
# -- Private helpers --
defp all_nodes(table) do
:ets.tab2list(table) |> Enum.map(fn {_id, node} -> node end)
end
defp all_edges(table) do
:ets.tab2list(table) |> Enum.map(fn {_id, edge} -> edge end)
end
# BFS to find a path between two nodes, returning nodes along the path.
defp bfs_path(state, from_id, to_id) do
case get_node(state, from_id) do
{:ok, start_node} ->
do_bfs_path(state, [[start_node]], to_id, MapSet.new([from_id]))
{:error, :not_found} ->
[]
end
end
defp do_bfs_path(_state, [], _to_id, _visited), do: []
defp do_bfs_path(state, [current_path | rest], to_id, visited) do
current_node = hd(current_path)
if current_node.id == to_id do
Enum.reverse(current_path)
else
{:ok, edges} = get_edges(state, current_node.id, :outgoing)
{new_paths, new_visited} =
Enum.reduce(edges, {[], visited}, fn edge, {paths, vis} ->
if MapSet.member?(vis, edge.to_id) do
{paths, vis}
else
case get_node(state, edge.to_id) do
{:ok, next_node} ->
{[[next_node | current_path] | paths], MapSet.put(vis, edge.to_id)}
{:error, :not_found} ->
{paths, vis}
end
end
end)
do_bfs_path(state, rest ++ Enum.reverse(new_paths), to_id, new_visited)
end
end
# BFS to find all reachable nodes in a given direction.
defp bfs_reachable(state, start_id, direction) do
do_bfs_reachable(state, [start_id], direction, MapSet.new([start_id]), [])
end
defp do_bfs_reachable(_state, [], _direction, _visited, acc), do: Enum.reverse(acc)
defp do_bfs_reachable(state, [current_id | rest], direction, visited, acc) do
{:ok, edges} = get_edges(state, current_id, direction)
neighbor_ids =
Enum.map(edges, fn edge ->
case direction do
:outgoing -> edge.to_id
:incoming -> edge.from_id
end
end)
{new_queue, new_visited, new_acc} =
Enum.reduce(neighbor_ids, {rest, visited, acc}, fn nid, {q, vis, a} ->
if MapSet.member?(vis, nid) do
{q, vis, a}
else
case get_node(state, nid) do
{:ok, node} ->
{q ++ [nid], MapSet.put(vis, nid), [node | a]}
{:error, :not_found} ->
{q, vis, a}
end
end
end)
do_bfs_reachable(state, new_queue, direction, new_visited, new_acc)
end
end