Packages
A Fair Source multi-agent runtime with deterministic agent scoring and replayable run history.
Current section
Files
Jump to
Current section
Files
lib/syntropy/recommendation_store.ex
defmodule Syntropy.RecommendationStore do
@moduledoc """
Retains structural recommendations for Trust V1.
"""
use GenServer
alias Syntropy.{ClusterInfo, Persistence}
@history_limit 100
@type recommendation :: %{
id: String.t(),
node_id: String.t(),
kind: String.t(),
risk_level: String.t(),
approval_required: boolean(),
candidate_agent_ids: [String.t()],
evidence: map(),
status: String.t(),
applied_agent_id: String.t() | nil,
created_at: DateTime.t()
}
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@spec record_pending(map()) ::
{:created, recommendation()} | {:existing, recommendation()} | {:error, term()}
def record_pending(attrs) do
GenServer.call(__MODULE__, {:record_pending, attrs})
end
@spec recent(non_neg_integer()) :: [recommendation()]
def recent(limit \\ @history_limit) do
GenServer.call(__MODULE__, {:recent, limit})
end
@spec get(String.t()) :: recommendation() | nil
def get(recommendation_id) do
GenServer.call(__MODULE__, {:get, recommendation_id})
end
@spec approve(String.t()) :: {:ok, recommendation()} | {:error, term()}
def approve(recommendation_id) do
GenServer.call(__MODULE__, {:approve, recommendation_id})
end
@spec reject(String.t()) :: {:ok, recommendation()} | {:error, term()}
def reject(recommendation_id) do
GenServer.call(__MODULE__, {:reject, recommendation_id})
end
@spec apply(String.t(), String.t()) :: {:ok, recommendation()} | {:error, term()}
def apply(recommendation_id, applied_agent_id) do
GenServer.call(__MODULE__, {:apply, recommendation_id, applied_agent_id})
end
@spec reset() :: :ok
def reset do
GenServer.call(__MODULE__, :reset)
end
@impl true
def init(_opts) do
recommendations = Persistence.load_recent_recommendations(Persistence.hydrate_limit())
{:ok,
%{
counter: Persistence.counter_from_ids(recommendations, :id, "proposal"),
recommendations: recommendations
}}
end
@impl true
def handle_call({:record_pending, attrs}, _from, state) do
candidate_agent_ids = normalize_candidate_ids(attrs.candidate_agent_ids)
candidate_agents =
attrs
|> Map.get(:evidence, %{})
|> Map.get(:candidate_agents, [])
|> normalize_candidate_agents()
case find_active(state.recommendations, attrs.kind, candidate_agent_ids, candidate_agents) do
nil ->
node_id = ClusterInfo.node_id()
recommendation = %{
id: ClusterInfo.scoped_id("proposal", state.counter + 1, node_id),
node_id: node_id,
kind: attrs.kind,
risk_level: Map.get(attrs, :risk_level, "high"),
approval_required: Map.get(attrs, :approval_required, true),
candidate_agent_ids: candidate_agent_ids,
evidence: Map.put(attrs.evidence, :candidate_agents, candidate_agents),
status: "pending_approval",
applied_agent_id: nil,
created_at: DateTime.utc_now()
}
case Persistence.persist_recommendation(recommendation)
|> Persistence.observe_write("recommendation_upsert") do
:ok ->
recommendations =
[recommendation | state.recommendations]
|> Enum.take(@history_limit)
{:reply, {:created, recommendation},
%{state | counter: state.counter + 1, recommendations: recommendations}}
{:error, reason} ->
{:reply, {:error, {:persistence_failed, reason}}, state}
end
recommendation ->
{:reply, {:existing, recommendation}, state}
end
end
@impl true
def handle_call({:recent, limit}, _from, state) do
{:reply, Enum.take(state.recommendations, max(limit, 0)), state}
end
@impl true
def handle_call({:get, recommendation_id}, _from, state) do
recommendation = Enum.find(state.recommendations, &(&1.id == recommendation_id))
{:reply, recommendation, state}
end
@impl true
def handle_call({:approve, recommendation_id}, _from, state) do
update_status(state, recommendation_id, "approved")
end
@impl true
def handle_call({:reject, recommendation_id}, _from, state) do
update_status(state, recommendation_id, "rejected")
end
@impl true
def handle_call({:apply, recommendation_id, applied_agent_id}, _from, state) do
case Enum.find_index(state.recommendations, &(&1.id == recommendation_id)) do
nil ->
{:reply, {:error, :not_found}, state}
index ->
recommendation = Enum.at(state.recommendations, index)
updated =
recommendation
|> Map.put(:status, "applied")
|> Map.put(:applied_agent_id, applied_agent_id)
case Persistence.persist_recommendation(updated)
|> Persistence.observe_write("recommendation_upsert") do
:ok ->
recommendations = List.replace_at(state.recommendations, index, updated)
{:reply, {:ok, updated}, %{state | recommendations: recommendations}}
{:error, reason} ->
{:reply, {:error, {:persistence_failed, reason}}, state}
end
end
end
@impl true
def handle_call(:reset, _from, _state) do
{:reply, :ok, %{counter: 0, recommendations: []}}
end
defp update_status(state, recommendation_id, status) do
case Enum.find_index(state.recommendations, &(&1.id == recommendation_id)) do
nil ->
{:reply, {:error, :not_found}, state}
index ->
recommendation = Enum.at(state.recommendations, index)
updated = Map.put(recommendation, :status, status)
case Persistence.persist_recommendation(updated)
|> Persistence.observe_write("recommendation_upsert") do
:ok ->
recommendations = List.replace_at(state.recommendations, index, updated)
{:reply, {:ok, updated}, %{state | recommendations: recommendations}}
{:error, reason} ->
{:reply, {:error, {:persistence_failed, reason}}, state}
end
end
end
defp find_active(recommendations, kind, candidate_agent_ids, candidate_agents) do
candidate_identity = candidate_identity(candidate_agent_ids, candidate_agents)
Enum.find(recommendations, fn recommendation ->
recommendation.kind == kind and
recommendation.status in ["pending_approval", "approved", "applied"] and
candidate_identity(
recommendation.candidate_agent_ids,
get_in(recommendation, [:evidence, :candidate_agents]) || []
) == candidate_identity
end)
end
defp normalize_candidate_ids(candidate_agent_ids) do
candidate_agent_ids
|> Enum.sort()
|> Enum.uniq()
end
defp normalize_candidate_agents(candidate_agents) do
candidate_agents
|> Enum.map(fn candidate ->
%{
id: candidate.id,
runtime_id: candidate.runtime_id,
node_id: candidate.node_id
}
end)
|> Enum.sort_by(& &1.runtime_id)
|> Enum.uniq_by(& &1.runtime_id)
end
defp candidate_identity(candidate_agent_ids, candidate_agents) do
case normalize_candidate_agents(candidate_agents) do
[] ->
normalize_candidate_ids(candidate_agent_ids)
normalized_candidates ->
Enum.map(normalized_candidates, & &1.runtime_id)
end
end
end