Current section
Files
Jump to
Current section
Files
lib/crucible/ensemble/adapter_pool.ex
defmodule Crucible.Ensemble.AdapterPool do
@moduledoc """
Manages a pool of Tinkex sampling clients for ensemble inference.
Provides lifecycle management for multiple sampling clients, with support
for filtering by tags, weight-based selection, and dynamic client addition/removal.
## Examples
{:ok, pool} = AdapterPool.start_link([])
# Add clients with adapter specs
adapter = %{name: "model-1", checkpoint_path: "path/1", weight: 0.5, tags: [:prod]}
:ok = AdapterPool.add_client(pool, adapter, client_pid)
# Get all clients for parallel inference
clients = AdapterPool.all_clients(pool)
# Filter by tags
prod_clients = AdapterPool.clients_by_tags(pool, [:prod])
"""
use GenServer
require Logger
@type adapter_spec :: %{
name: String.t(),
checkpoint_path: String.t(),
weight: float(),
tags: [atom()]
}
@type t :: pid()
defstruct adapters: %{}, clients: %{}
# Client API
@doc """
Starts the adapter pool GenServer.
## Options
* `:name` - GenServer name for registration
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
server_opts = Keyword.take(opts, [:name])
GenServer.start_link(__MODULE__, %{}, server_opts)
end
@doc """
Creates an adapter pool from adapter specs and a training session.
Automatically starts sampling clients for each adapter.
## Options
* `:adapters` - List of adapter specs (required)
* `:session` - Tinkex training session (required)
* `:config` - Additional pool configuration
"""
@spec create(keyword()) :: {:ok, t()} | {:error, term()}
def create(opts) do
adapters = Keyword.fetch!(opts, :adapters)
session = Keyword.fetch!(opts, :session)
{:ok, pool} = start_link(Keyword.get(opts, :name, []))
# Start sampling clients for each adapter
results =
Enum.map(adapters, fn adapter ->
case start_sampling_client(session, adapter) do
{:ok, client} ->
:ok = add_client(pool, adapter, client)
:ok
{:error, reason} ->
Logger.error("Failed to start client for #{adapter.name}: #{inspect(reason)}")
{:error, adapter.name, reason}
end
end)
errors = Enum.filter(results, &match?({:error, _, _}, &1))
if errors == [] do
emit_telemetry(:pool_created, %{adapter_count: length(adapters)})
{:ok, pool}
else
{:error, {:client_creation_failed, errors}}
end
end
@doc """
Gets a specific sampling client by adapter name.
"""
@spec get_client(t(), String.t()) :: {:ok, term()} | {:error, :not_found}
def get_client(pool, adapter_name) do
GenServer.call(pool, {:get_client, adapter_name})
end
@doc """
Gets all clients with their adapter specs.
Returns a list of `{adapter_spec, client}` tuples.
"""
@spec all_clients(t()) :: [{adapter_spec(), term()}]
def all_clients(pool) do
GenServer.call(pool, :all_clients)
end
@doc """
Gets clients matching all specified tags.
"""
@spec clients_by_tags(t(), [atom()]) :: [{adapter_spec(), term()}]
def clients_by_tags(pool, tags) do
GenServer.call(pool, {:clients_by_tags, tags})
end
@doc """
Adds a client to the pool.
"""
@spec add_client(t(), adapter_spec(), term()) :: :ok
def add_client(pool, adapter_spec, client) do
GenServer.call(pool, {:add_client, adapter_spec, client})
end
@doc """
Removes a client from the pool.
"""
@spec remove_client(t(), String.t()) :: :ok | {:error, :not_found}
def remove_client(pool, adapter_name) do
GenServer.call(pool, {:remove_client, adapter_name})
end
@doc """
Returns the count of clients in the pool.
"""
@spec count(t()) :: non_neg_integer()
def count(pool) do
GenServer.call(pool, :count)
end
# Server Callbacks
@impl true
def init(_opts) do
{:ok, %__MODULE__{adapters: %{}, clients: %{}}}
end
@impl true
def handle_call({:get_client, name}, _from, state) do
case Map.get(state.clients, name) do
nil -> {:reply, {:error, :not_found}, state}
client -> {:reply, {:ok, client}, state}
end
end
@impl true
def handle_call(:all_clients, _from, state) do
clients =
state.adapters
|> Enum.map(fn {name, adapter} ->
{adapter, Map.get(state.clients, name)}
end)
{:reply, clients, state}
end
@impl true
def handle_call({:clients_by_tags, tags}, _from, state) do
clients =
state.adapters
|> Enum.filter(fn {_name, adapter} ->
Enum.all?(tags, &(&1 in adapter.tags))
end)
|> Enum.map(fn {name, adapter} ->
{adapter, Map.get(state.clients, name)}
end)
{:reply, clients, state}
end
@impl true
def handle_call({:add_client, adapter_spec, client}, _from, state) do
name = adapter_spec.name
new_state = %{
state
| adapters: Map.put(state.adapters, name, adapter_spec),
clients: Map.put(state.clients, name, client)
}
emit_telemetry(:client_added, %{adapter_name: name})
{:reply, :ok, new_state}
end
@impl true
def handle_call({:remove_client, name}, _from, state) do
case Map.get(state.clients, name) do
nil ->
{:reply, {:error, :not_found}, state}
_client ->
new_state = %{
state
| adapters: Map.delete(state.adapters, name),
clients: Map.delete(state.clients, name)
}
emit_telemetry(:client_removed, %{adapter_name: name})
{:reply, :ok, new_state}
end
end
@impl true
def handle_call(:count, _from, state) do
{:reply, map_size(state.clients), state}
end
# Private Functions
defp start_sampling_client(session, adapter) do
# Start sampling client from training session
# This integrates with Tinkex.TrainingClient
try do
case Code.ensure_loaded(Tinkex.TrainingClient) do
{:module, _} ->
if function_exported?(Tinkex.TrainingClient, :create_sampling_client_async, 2) do
task =
apply(Tinkex.TrainingClient, :create_sampling_client_async, [
session.training_client,
adapter.checkpoint_path
])
Task.await(task, :infinity)
else
# Fallback: return a mock client for development
{:ok, {:mock_client, adapter.name}}
end
{:error, _} ->
# Tinkex not available, return mock client
{:ok, {:mock_client, adapter.name}}
end
rescue
e ->
{:error, Exception.message(e)}
end
end
defp emit_telemetry(event, metadata) do
:telemetry.execute(
[:crucible, :ensemble, :adapter_pool, event],
%{timestamp: System.system_time(:millisecond)},
metadata
)
end
end