DSPy Variable Abstraction for Elixir Port
Overview
The Variable abstraction in the Elixir port of DSPy provides a unified way to declare tunable parameters that can be optimized by any optimizer, regardless of whether they represent prompts, weights, or other discrete choices. This design takes advantage of Elixir’s powerful metaprogramming capabilities and actor model to create a clean, composable system.
Core Design Principles
- Separation of Concerns: Variables are declared independently from optimizers
- Type Safety: Leverage Elixir’s type system and behaviours
- Composability: Variables can be composed and transformed
- Observability: All variable changes are trackable through the actor system
- Immutability: Variable updates create new versions, maintaining history
API Specification
Variable Behaviour
defmodule DSPy.Variable do
@moduledoc """
Behaviour for all DSPy variables that can be optimized.
"""
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
type: variable_type(),
value: any(),
constraints: list(constraint()),
metadata: map(),
version: non_neg_integer(),
history: list(version_entry())
}
@type variable_type :: :string | :weight | :discrete | :continuous | :structured
@type constraint :: {constraint_type(), any()}
@type constraint_type :: :range | :options | :pattern | :length | :custom
@type version_entry :: {non_neg_integer(), any(), DateTime.t(), map()}
@callback validate(value :: any(), constraints :: list(constraint())) ::
{:ok, any()} | {:error, String.t()}
@callback transform(value :: any(), transformation :: map()) ::
{:ok, any()} | {:error, String.t()}
@callback serialize(value :: any()) :: binary()
@callback deserialize(binary()) :: {:ok, any()} | {:error, String.t()}
end
Variable Types
defmodule DSPy.Variable.String do
@moduledoc """
String variable for prompts, instructions, and text parameters.
"""
use DSPy.Variable
defstruct [:id, :name, :value, :constraints, :metadata, :version, :history,
template: nil,
format: :plain,
max_length: nil,
min_length: nil]
@impl true
def validate(value, constraints) do
with :ok <- validate_length(value, constraints),
:ok <- validate_pattern(value, constraints),
:ok <- validate_template(value, constraints) do
{:ok, value}
end
end
end
defmodule DSPy.Variable.Weight do
@moduledoc """
Numeric weight variable for model parameters.
"""
use DSPy.Variable
defstruct [:id, :name, :value, :constraints, :metadata, :version, :history,
shape: nil,
dtype: :f32,
initialization: :random_normal,
regularization: nil]
@impl true
def validate(value, constraints) do
with :ok <- validate_shape(value, constraints),
:ok <- validate_range(value, constraints) do
{:ok, value}
end
end
end
defmodule DSPy.Variable.Discrete do
@moduledoc """
Discrete choice variable for selecting from options.
"""
use DSPy.Variable
defstruct [:id, :name, :value, :constraints, :metadata, :version, :history,
options: [],
probabilities: nil,
allow_multiple: false]
@impl true
def validate(value, constraints) do
options = Keyword.get(constraints, :options, [])
if value in options do
{:ok, value}
else
{:error, "Value must be one of: #{inspect(options)}"}
end
end
end
Variable Declaration DSL
defmodule DSPy.Module do
@moduledoc """
Enhanced module macro with variable declarations.
"""
defmacro __using__(_opts) do
quote do
import DSPy.Module
import DSPy.Variable.DSL
Module.register_attribute(__MODULE__, :variables, accumulate: true)
Module.register_attribute(__MODULE__, :predictors, accumulate: true)
@before_compile DSPy.Module
end
end
defmacro variable(name, type, opts \\ []) do
quote do
@variables {unquote(name), unquote(type), unquote(opts)}
end
end
end
# Example usage
defmodule ChainOfThought do
use DSPy.Module
# Declare variables
variable :reasoning_prompt, :string,
default: "Let's think step by step",
constraints: [
length: [min: 10, max: 100],
pattern: ~r/think|reason|analyze/i
]
variable :temperature, :continuous,
default: 0.7,
constraints: [
range: {0.0, 2.0}
]
variable :reasoning_style, :discrete,
default: :analytical,
options: [:analytical, :creative, :systematic, :intuitive]
variable :attention_weights, :weight,
shape: {768, 768},
initialization: :xavier_uniform
def forward(state, inputs) do
# Variables are automatically accessible
prompt = get_variable(state, :reasoning_prompt)
temp = get_variable(state, :temperature)
style = get_variable(state, :reasoning_style)
# Use variables in computation
# ...
end
end
Variable Registry
defmodule DSPy.Variable.Registry do
@moduledoc """
GenServer that manages all variables in the system.
"""
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def register_variable(module, name, type, opts) do
GenServer.call(__MODULE__, {:register, module, name, type, opts})
end
def get_variable(module, name) do
GenServer.call(__MODULE__, {:get, module, name})
end
def update_variable(module, name, value, metadata \\ %{}) do
GenServer.call(__MODULE__, {:update, module, name, value, metadata})
end
def get_variables_for_module(module) do
GenServer.call(__MODULE__, {:get_all, module})
end
def get_variables_by_type(type) do
GenServer.call(__MODULE__, {:get_by_type, type})
end
# Implementation handles versioning, history, and notifications
end
Optimizer Integration
defmodule DSPy.Optimizer.Behaviour do
@moduledoc """
Enhanced optimizer behaviour with variable support.
"""
@callback compile(program :: module(), opts :: keyword()) ::
{:ok, optimized_program :: module()} | {:error, term()}
@callback optimize_variables(variables :: list(DSPy.Variable.t()), opts :: keyword()) ::
{:ok, updates :: list({variable_id :: String.t(), new_value :: any()})} |
{:error, term()}
@callback supports_variable_type?(type :: atom()) :: boolean()
end
defmodule DSPy.Optimizer.VariableOptimizer do
@moduledoc """
Mixin for optimizers to handle variable optimization.
"""
defmacro __using__(_opts) do
quote do
def optimize_program(program, trainset, opts \\ []) do
# Get all variables from the program
variables = DSPy.Variable.Registry.get_variables_for_module(program)
# Group by type for efficient optimization
grouped = Enum.group_by(variables, & &1.type)
# Optimize each group
updates = Enum.flat_map(grouped, fn {type, vars} ->
if supports_variable_type?(type) do
{:ok, type_updates} = optimize_variable_group(type, vars, trainset, opts)
type_updates
else
[]
end
end)
# Apply updates atomically
apply_variable_updates(program, updates)
end
defp optimize_variable_group(type, variables, trainset, opts) do
strategy = get_optimization_strategy(type)
strategy.optimize(variables, trainset, opts)
end
end
end
end
Example Optimizers
defmodule DSPy.Optimizer.BayesianOptimizer do
@moduledoc """
Bayesian optimization for continuous and discrete variables.
"""
use DSPy.Optimizer.VariableOptimizer
@behaviour DSPy.Optimizer.Behaviour
def supports_variable_type?(type) when type in [:continuous, :discrete], do: true
def supports_variable_type?(_), do: false
def optimize_variables(variables, opts) do
# Bayesian optimization logic
acquisition_fn = opts[:acquisition_fn] || :expected_improvement
surrogate_model = build_surrogate_model(variables)
candidates = generate_candidates(variables, surrogate_model, acquisition_fn)
# Evaluate candidates
results = Enum.map(candidates, fn candidate ->
score = evaluate_candidate(candidate, opts[:metric])
{candidate, score}
end)
# Select best
best = Enum.max_by(results, fn {_, score} -> score end)
{:ok, best}
end
end
defmodule DSPy.Optimizer.PromptOptimizer do
@moduledoc """
Specialized optimizer for string/prompt variables.
"""
use DSPy.Optimizer.VariableOptimizer
@behaviour DSPy.Optimizer.Behaviour
def supports_variable_type?(:string), do: true
def supports_variable_type?(_), do: false
def optimize_variables(variables, opts) do
# Use LLM to generate prompt variants
variants = Enum.flat_map(variables, fn var ->
generate_prompt_variants(var, opts[:num_variants] || 10)
end)
# Evaluate variants
scored = Enum.map(variants, fn {var_id, variant} ->
score = evaluate_prompt(variant, opts[:trainset], opts[:metric])
{var_id, variant, score}
end)
# Select best per variable
updates = scored
|> Enum.group_by(fn {var_id, _, _} -> var_id end)
|> Enum.map(fn {var_id, group} ->
{_, best_variant, _} = Enum.max_by(group, fn {_, _, score} -> score end)
{var_id, best_variant}
end)
{:ok, updates}
end
defp generate_prompt_variants(variable, num_variants) do
# Use an LLM to generate variations
prompt = """
Generate #{num_variants} variations of this prompt:
"#{variable.value}"
Constraints: #{inspect(variable.constraints)}
Each variation should maintain the same intent but use different phrasing.
"""
# Call LLM and parse results
# ...
end
end
Variable Composition
defmodule DSPy.Variable.Composite do
@moduledoc """
Compose multiple variables into complex structures.
"""
defstruct [:variables, :composition_fn, :constraints]
def compose(variables, composition_fn, opts \\ []) do
%__MODULE__{
variables: variables,
composition_fn: composition_fn,
constraints: opts[:constraints] || []
}
end
def evaluate(composite, variable_values) do
composite.composition_fn.(variable_values)
end
end
# Example: Composing a complex prompt from multiple variables
defmodule AdvancedChainOfThought do
use DSPy.Module
variable :task_description, :string
variable :reasoning_style, :discrete, options: [:analytical, :creative]
variable :output_format, :string
composite_variable :full_prompt, [:task_description, :reasoning_style, :output_format],
fn values ->
"""
Task: #{values.task_description}
Approach this #{values.reasoning_style}ly.
#{values.output_format}
"""
end
end
Variable Constraints and Validation
defmodule DSPy.Variable.Constraints do
@moduledoc """
Constraint system for variables.
"""
def validate_constraint(value, {:range, {min, max}}) do
if value >= min and value <= max do
:ok
else
{:error, "Value #{value} outside range [#{min}, #{max}]"}
end
end
def validate_constraint(value, {:options, allowed}) do
if value in allowed do
:ok
else
{:error, "Value must be one of: #{inspect(allowed)}"}
end
end
def validate_constraint(value, {:pattern, regex}) do
if Regex.match?(regex, to_string(value)) do
:ok
else
{:error, "Value does not match pattern: #{inspect(regex)}"}
end
end
def validate_constraint(value, {:custom, validator_fn}) do
validator_fn.(value)
end
def validate_all(value, constraints) do
Enum.reduce_while(constraints, :ok, fn constraint, _ ->
case validate_constraint(value, constraint) do
:ok -> {:cont, :ok}
error -> {:halt, error}
end
end)
end
end
Variable History and Versioning
defmodule DSPy.Variable.History do
@moduledoc """
Track variable changes over time.
"""
defstruct [:variable_id, :entries, :max_entries]
def new(variable_id, max_entries \\ 100) do
%__MODULE__{
variable_id: variable_id,
entries: [],
max_entries: max_entries
}
end
def add_entry(history, value, metadata) do
entry = {
System.unique_integer([:positive]),
value,
DateTime.utc_now(),
metadata
}
entries = [entry | history.entries] |> Enum.take(history.max_entries)
%{history | entries: entries}
end
def get_trajectory(history, metric_fn) do
history.entries
|> Enum.reverse()
|> Enum.map(fn {version, value, time, meta} ->
score = meta[:score] || metric_fn.(value)
%{version: version, value: value, time: time, score: score}
end)
end
end
Integration with Existing DSPy Components
defmodule DSPy.Predict do
@moduledoc """
Enhanced Predict with variable support.
"""
defmacro predict(signature, opts \\ []) do
quote do
# Check if any variables are referenced in the signature
variables = extract_variables_from_signature(unquote(signature))
# Create a predictor that uses current variable values
fn state ->
# Resolve variable values
resolved_signature = resolve_signature_variables(
unquote(signature),
state.variables
)
# Standard prediction logic with resolved values
DSPy.Predict.Base.predict(resolved_signature, unquote(opts))
end
end
end
end
Variable-Aware Teleprompters
defmodule DSPy.Teleprompter.VariableAware do
@moduledoc """
Base teleprompter that can optimize variables.
"""
def compile(student, trainset, opts \\ []) do
# Extract all variables
variables = DSPy.Variable.Registry.get_variables_for_module(student)
# Separate optimizable variables
{prompt_vars, other_vars} = Enum.split_with(variables, & &1.type == :string)
# Optimize prompts
optimized_prompts = optimize_prompts(prompt_vars, trainset, opts)
# Optimize other variables
optimized_others = optimize_other_variables(other_vars, trainset, opts)
# Create new program with optimized variables
create_optimized_program(student, optimized_prompts ++ optimized_others)
end
end
Benefits of This Design
- Clean Separation: Variables are first-class citizens, not buried in optimizer logic
- Type Safety: Elixir’s type system ensures variables are used correctly
- Extensibility: New variable types can be added without changing optimizers
- Observability: All changes are tracked through the actor system
- Composability: Variables can be composed into complex structures
- Optimizer Agnostic: Any optimizer can work with any variable type it supports
This design leverages Elixir’s strengths while providing a clean, extensible foundation for the variable abstraction that DSPy needs.