Packages

CrucibleFramework: A thin orchestration layer for experiment pipelines. Provides pipeline execution, stage behaviour, and optional persistence.

Current section

Files

Jump to
crucible_framework lib crucible tinkex session.ex
Raw

lib/crucible/tinkex/session.ex

defmodule Crucible.Tinkex.Session do
@moduledoc """
Manages the lifecycle of a Tinkex training session.
This GenServer wraps Tinkex SDK clients for training and inference,
providing experiment orchestration, checkpoint management, and
telemetry integration for the Crucible Framework.
## Features
- Session lifecycle management (init, training, evaluation, completion)
- Forward-backward pass execution with automatic metrics buffering
- Optimizer step execution
- Checkpoint saving and loading
- Sampling client creation for inference
- Telemetry event emission for all operations
## Usage
# Start a session
{:ok, session} = Crucible.Tinkex.Session.start_link(experiment: experiment)
# Execute training step
{:ok, metrics} = GenServer.call(session, {:forward_backward, batch, opts})
# Run optimizer step
{:ok, result} = GenServer.call(session, {:optim_step, adam_params, opts})
# Save checkpoint
{:ok, checkpoint} = GenServer.call(session, {:save_checkpoint, step})
# Create sampler for inference
{:ok, sampler} = GenServer.call(session, {:create_sampler, checkpoint_name})
"""
use GenServer
alias Crucible.Tinkex.Config
@type status :: :initializing | :ready | :training | :evaluating | :completed | :failed
@type t :: %__MODULE__{
experiment_id: String.t(),
service_client: pid() | nil,
training_client: pid() | nil,
sampling_client: pid() | nil,
config: Config.t(),
checkpoints: list(),
metrics_buffer: list(),
started_at: DateTime.t() | nil,
status: status()
}
defstruct [
:experiment_id,
:service_client,
:training_client,
:sampling_client,
:config,
:checkpoints,
:metrics_buffer,
:started_at,
:status
]
@doc """
Starts a new training session.
## Options
- `:experiment` - Experiment map with :id, :name, and :config (required)
- `:name` - Optional registered name for the GenServer
## Examples
{:ok, session} = Crucible.Tinkex.Session.start_link(
experiment: %{
id: "exp-123",
name: "My Experiment",
config: config
}
)
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
@doc """
Stops the session gracefully.
"""
@spec stop(pid()) :: :ok
def stop(session) do
GenServer.stop(session)
end
@impl true
def init(opts) do
experiment = Keyword.fetch!(opts, :experiment)
config = experiment.config
case start_clients(config) do
{:ok, service_client, training_client} ->
state = %__MODULE__{
experiment_id: experiment.id,
service_client: service_client,
training_client: training_client,
sampling_client: nil,
config: config,
checkpoints: [],
metrics_buffer: [],
started_at: DateTime.utc_now(),
status: :ready
}
emit_telemetry(:session_start, state)
{:ok, state}
{:error, reason} ->
{:stop, reason}
end
end
@impl true
def handle_call({:forward_backward, batch, opts}, from, state) do
new_state = %{state | status: :training}
emit_telemetry(:forward_backward_start, new_state, %{batch_size: length(batch)})
case do_forward_backward(new_state, batch, opts) do
{:ok, task} ->
# Handle async completion
spawn_link(fn ->
result = Task.await(task, :infinity)
handle_forward_backward_result(from, result, new_state)
end)
{:noreply, new_state}
{:error, _reason} = error ->
emit_telemetry(:forward_backward_error, new_state, %{error: error})
{:reply, error, new_state}
end
end
@impl true
def handle_call({:optim_step, adam_params, opts}, from, state) do
emit_telemetry(:optim_step_start, state)
case do_optim_step(state, adam_params, opts) do
{:ok, task} ->
spawn_link(fn ->
result = Task.await(task, :infinity)
handle_optim_step_result(from, result, state)
end)
{:noreply, state}
{:error, _reason} = error ->
emit_telemetry(:optim_step_error, state, %{error: error})
{:reply, error, state}
end
end
@impl true
def handle_call({:save_checkpoint, step}, _from, state) do
checkpoint_name = Crucible.Tinkex.checkpoint_name(state.experiment_id, step)
case do_save_checkpoint(state, checkpoint_name) do
{:ok, task} ->
result = Task.await(task, :infinity)
case result do
{:ok, path_data} ->
checkpoint = %{
name: checkpoint_name,
step: step,
path: extract_path(path_data),
created_at: DateTime.utc_now()
}
new_state = %{state | checkpoints: [checkpoint | state.checkpoints]}
emit_telemetry(:checkpoint_saved, new_state, checkpoint)
{:reply, {:ok, checkpoint}, new_state}
{:error, _reason} = error ->
{:reply, error, state}
end
{:error, _reason} = error ->
{:reply, error, state}
end
end
@impl true
def handle_call({:create_sampler, checkpoint_name}, _from, state) do
checkpoint = find_checkpoint(state.checkpoints, checkpoint_name)
case checkpoint do
nil ->
{:reply, {:error, :checkpoint_not_found}, state}
%{path: path} ->
case do_create_sampler(state, path) do
{:ok, sampler_pid} ->
new_state = %{state | sampling_client: sampler_pid}
emit_telemetry(:sampler_created, new_state, %{checkpoint: checkpoint_name})
{:reply, {:ok, sampler_pid}, new_state}
{:error, _reason} = error ->
{:reply, error, state}
end
end
end
@impl true
def handle_call({:sample, prompt, opts}, _from, state) do
case state.sampling_client do
nil ->
{:reply, {:error, :no_sampler}, state}
sampler ->
case do_sample(sampler, prompt, opts) do
{:ok, task} ->
result = Task.await(task, :infinity)
{:reply, result, state}
{:error, _reason} = error ->
{:reply, error, state}
end
end
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
@impl true
def handle_cast({:training_result, metrics}, state) do
new_state = %{state | metrics_buffer: [metrics | state.metrics_buffer]}
{:noreply, new_state}
end
@impl true
def terminate(_reason, state) do
emit_telemetry(:session_end, state)
:ok
end
# Private functions
defp start_clients(config) do
# Start Tinkex ServiceClient and TrainingClient
service_opts = [
config: tinkex_config(config)
]
with {:ok, service} <- start_service_client(service_opts),
{:ok, training} <- start_training_client(service, config) do
{:ok, service, training}
else
{:error, reason} -> {:error, reason}
end
end
defp start_service_client(opts) do
if Code.ensure_loaded?(Tinkex.ServiceClient) do
Tinkex.ServiceClient.start_link(opts)
else
{:error, :tinkex_not_available}
end
end
defp start_training_client(service, config) do
if Code.ensure_loaded?(Tinkex.ServiceClient) do
training_opts = [
base_model: config.base_model,
lora_config: config.lora_config
]
Tinkex.ServiceClient.create_lora_training_client(service, training_opts)
else
{:error, :tinkex_not_available}
end
end
defp tinkex_config(config) do
if Code.ensure_loaded?(Tinkex.Config) do
Tinkex.Config.new(
api_key: config.api_key,
base_url: config.base_url
)
else
%{
api_key: config.api_key,
base_url: config.base_url
}
end
end
defp do_forward_backward(state, batch, opts) do
if Code.ensure_loaded?(Tinkex.TrainingClient) do
# Format batch for Tinkex
data = format_for_tinkex(batch)
loss_fn = Keyword.get(opts, :loss_fn, :cross_entropy)
Tinkex.TrainingClient.forward_backward(
state.training_client,
data,
loss_fn,
opts
)
else
{:error, :tinkex_not_available}
end
end
defp do_optim_step(state, adam_params, opts) do
if Code.ensure_loaded?(Tinkex.TrainingClient) do
Tinkex.TrainingClient.optim_step(
state.training_client,
adam_params,
opts
)
else
{:error, :tinkex_not_available}
end
end
defp do_save_checkpoint(state, checkpoint_name) do
if Code.ensure_loaded?(Tinkex.TrainingClient) do
path = "tinker://#{state.experiment_id}/checkpoints/#{checkpoint_name}"
Tinkex.TrainingClient.save_weights_for_sampler(
state.training_client,
path: path
)
else
{:error, :tinkex_not_available}
end
end
defp do_create_sampler(state, model_path) do
if Code.ensure_loaded?(Tinkex.ServiceClient) do
# Create sampling client from the service client with the model_path
opts = [model_path: model_path]
Tinkex.ServiceClient.create_sampling_client(state.service_client, opts)
else
{:error, :tinkex_not_available}
end
end
defp do_sample(sampler, prompt, opts) do
if Code.ensure_loaded?(Tinkex.SamplingClient) do
sampling_params = build_sampling_params(opts)
# Build model input from prompt
model_input =
if Code.ensure_loaded?(Tinkex.Types.ModelInput) do
Tinkex.Types.ModelInput.from_text(prompt, opts[:model] || "meta-llama/Llama-3.2-1B")
else
%{text: prompt}
end
Tinkex.SamplingClient.sample(sampler, model_input, sampling_params, opts)
else
{:error, :tinkex_not_available}
end
end
defp format_for_tinkex(batch) do
Enum.map(batch, fn example ->
%{
model_input: example[:input] || example["input"],
loss_fn_inputs: %{
target: example[:output] || example["output"]
}
}
end)
end
defp build_sampling_params(opts) do
%{
temperature: Keyword.get(opts, :temperature, 0.7),
top_p: Keyword.get(opts, :top_p, 0.95),
max_tokens: Keyword.get(opts, :max_tokens, 512)
}
end
defp find_checkpoint(checkpoints, name) do
Enum.find(checkpoints, fn cp -> cp.name == name end)
end
defp extract_path(path_data) when is_binary(path_data), do: path_data
defp extract_path(%{"path" => path}), do: path
defp extract_path(%{path: path}), do: path
defp extract_path(_), do: nil
defp handle_forward_backward_result(from, result, state) do
case result do
{:ok, output} ->
metrics = %{
loss: output.loss || 0.0,
timestamp: DateTime.utc_now()
}
emit_telemetry(:forward_backward_stop, state, metrics)
GenServer.reply(from, {:ok, metrics})
{:error, _reason} = error ->
emit_telemetry(:forward_backward_error, state, %{error: error})
GenServer.reply(from, error)
end
end
defp handle_optim_step_result(from, result, state) do
case result do
{:ok, output} ->
metrics = %{
grad_norm: output.grad_norm || 0.0,
timestamp: DateTime.utc_now()
}
emit_telemetry(:optim_step_stop, state, metrics)
GenServer.reply(from, {:ok, metrics})
{:error, _reason} = error ->
emit_telemetry(:optim_step_error, state, %{error: error})
GenServer.reply(from, error)
end
end
defp emit_telemetry(event, state, metadata \\ %{}) do
:telemetry.execute(
[:crucible, :tinkex, event],
%{timestamp: System.system_time(:millisecond)},
Map.merge(metadata, %{
experiment_id: state.experiment_id,
status: state.status
})
)
end
end