Packages
nous
0.17.0
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/mix/tasks/nous.optimize.ex
defmodule Mix.Tasks.Nous.Optimize do
@moduledoc """
Run parameter optimization for Nous agent configurations.
## Usage
# Optimize with default parameters (temperature, max_tokens)
mix nous.optimize --suite test/eval/suites/basic.yaml
# Use different optimization strategy
mix nous.optimize --suite basic.yaml --strategy bayesian
mix nous.optimize --suite basic.yaml --strategy random
mix nous.optimize --suite basic.yaml --strategy grid_search
# Configure trials and timeout
mix nous.optimize --suite basic.yaml --trials 50 --timeout 1800000
# Optimize specific metric
mix nous.optimize --suite basic.yaml --metric pass_rate
mix nous.optimize --suite basic.yaml --metric latency_p50 --minimize
# Custom parameter file
mix nous.optimize --suite basic.yaml --params params.exs
# Early stopping
mix nous.optimize --suite basic.yaml --early-stop 0.95
# Output results
mix nous.optimize --suite basic.yaml --output results.json
## Options
* `--suite` - Path to evaluation suite file (required)
* `--strategy` - Optimization strategy: grid_search, random, bayesian (default: bayesian)
* `--trials` - Number of trials to run (default: 20)
* `--metric` - Metric to optimize: score, pass_rate, latency_p50, latency_p95, total_tokens, cost (default: score)
* `--minimize` - Minimize metric instead of maximize (useful for latency/cost)
* `--timeout` - Total timeout in ms (default: 3600000 = 1 hour)
* `--early-stop` - Stop if metric reaches threshold
* `--params` - Path to parameters file (Elixir script returning list of parameters)
* `--output` - Output file path for JSON results
* `--verbose` - Show detailed progress
* `--quiet` - Suppress progress output
## Parameters File
Create a `.exs` file that returns a list of parameters:
# params.exs
alias Nous.Eval.Optimizer.Parameter
[
Parameter.float(:temperature, 0.0, 1.0, step: 0.1),
Parameter.integer(:max_tokens, 100, 1000, step: 100),
Parameter.choice(:model, ["gpt-4", "gpt-3.5-turbo"])
]
## Default Parameters
If no params file is specified, optimizes these parameters:
* `temperature` - Float from 0.0 to 1.0 (step: 0.1)
* `max_tokens` - Integer from 256 to 2048 (step: 256)
## Example
# Quick optimization with Bayesian search
mix nous.optimize --suite test/eval/suites/basic.yaml --trials 30
# Grid search for small parameter space
mix nous.optimize --suite basic.yaml --strategy grid_search --max-trials 50
# Minimize latency
mix nous.optimize --suite basic.yaml --metric latency_p50 --minimize
"""
use Mix.Task
alias Nous.Eval.Optimizer
alias Nous.Eval.Optimizer.Parameter
@shortdoc "Run parameter optimization for Nous agents"
@switches [
suite: :string,
strategy: :string,
trials: :integer,
metric: :string,
minimize: :boolean,
timeout: :integer,
early_stop: :float,
params: :string,
output: :string,
verbose: :boolean,
quiet: :boolean,
max_trials: :integer,
n_initial: :integer,
gamma: :float
]
@impl Mix.Task
def run(args) do
# Start the application
{:ok, _} = Application.ensure_all_started(:nous)
{opts, _remaining, _invalid} = OptionParser.parse(args, switches: @switches)
# Validate required options
unless opts[:suite] do
Mix.shell().error("Error: --suite is required")
Mix.shell().info("\nUsage: mix nous.optimize --suite <path>")
exit({:shutdown, 1})
end
# Load suite
suite = load_suite(opts[:suite])
# Load parameters
parameters = load_parameters(opts)
# Build optimization options
opt_opts = build_opts(opts)
# Print header
unless opts[:quiet] do
print_header(suite, parameters, opts)
end
# Run optimization
{:ok, result} = Optimizer.optimize(suite, parameters, opt_opts)
output_results(result, opts)
end
defp load_suite(path) do
# Try path as-is first, then with default directory
full_path =
cond do
File.exists?(path) -> path
File.exists?("test/eval/suites/#{path}") -> "test/eval/suites/#{path}"
true -> path
end
case Nous.Eval.Suite.from_yaml(full_path) do
{:ok, suite} ->
suite
{:error, reason} ->
Mix.shell().error("Failed to load suite '#{path}': #{inspect(reason)}")
exit({:shutdown, 1})
end
end
defp load_parameters(opts) do
case opts[:params] do
nil ->
# Default parameters
[
Parameter.float(:temperature, 0.0, 1.0, step: 0.1),
Parameter.integer(:max_tokens, 256, 2048, step: 256)
]
path ->
load_parameters_from_file(path)
end
end
# Prefer data formats (YAML/JSON) — they're parsed as pure data and never
# execute code. `.exs` files are still supported for the conditional-parameter
# DSL, but they run arbitrary Elixir, so they're gated behind an explicit
# extension check and a warning rather than being the default path.
defp load_parameters_from_file(path) do
case Path.extname(path) do
ext when ext in [".yaml", ".yml"] ->
path |> YamlElixir.read_from_file!() |> parse_data_parameters(path)
".json" ->
path |> File.read!() |> JSON.decode!() |> parse_data_parameters(path)
".exs" ->
Mix.shell().info(
"[nous.optimize] Evaluating Elixir parameters file #{inspect(path)} — " <>
"this executes arbitrary code. Prefer a .yaml/.json params file unless " <>
"you need conditional parameters."
)
load_exs_parameters(path)
other ->
Mix.shell().error(
"Unsupported parameters file extension #{inspect(other)}; use .yaml, .json, or .exs"
)
exit({:shutdown, 1})
end
end
defp parse_data_parameters(data, path) when is_list(data) do
data
|> Enum.map(&Parameter.from_map/1)
|> Enum.reduce({[], []}, fn
{:ok, param}, {ok, errs} -> {[param | ok], errs}
{:error, reason}, {ok, errs} -> {ok, [reason | errs]}
end)
|> case do
{params, []} ->
Enum.reverse(params)
{_params, errors} ->
Mix.shell().error(
"Invalid parameters in #{path}:\n - " <> Enum.join(Enum.reverse(errors), "\n - ")
)
exit({:shutdown, 1})
end
end
defp parse_data_parameters(_other, path) do
Mix.shell().error("Parameters file #{path} must contain a list of parameter maps")
exit({:shutdown, 1})
end
defp load_exs_parameters(path) do
case Code.eval_file(path) do
{params, _} when is_list(params) ->
params
_ ->
Mix.shell().error("Parameters file must return a list of parameters")
exit({:shutdown, 1})
end
end
defp build_opts(opts) do
strategy =
case opts[:strategy] do
"grid_search" ->
:grid_search
"grid" ->
:grid_search
"random" ->
:random
"bayesian" ->
:bayesian
nil ->
:bayesian
other ->
Mix.shell().error("Unknown strategy: #{other}")
exit({:shutdown, 1})
end
metric =
case opts[:metric] do
"score" ->
:score
"pass_rate" ->
:pass_rate
"latency_p50" ->
:latency_p50
"latency_p95" ->
:latency_p95
"latency_p99" ->
:latency_p99
"total_tokens" ->
:total_tokens
"cost" ->
:cost
nil ->
:score
other ->
Mix.shell().error("Unknown metric: #{other}")
exit({:shutdown, 1})
end
opt_opts = [
strategy: strategy,
metric: metric,
maximize: !opts[:minimize],
verbose: opts[:verbose] && !opts[:quiet]
]
opt_opts =
case strategy do
:grid_search ->
opt_opts
|> maybe_add(:max_trials, opts[:trials] || opts[:max_trials])
|> maybe_add(:timeout, opts[:timeout])
|> maybe_add(:early_stop, opts[:early_stop])
:random ->
opt_opts
|> maybe_add(:n_trials, opts[:trials] || 20)
|> maybe_add(:timeout, opts[:timeout])
|> maybe_add(:early_stop, opts[:early_stop])
:bayesian ->
opt_opts
|> maybe_add(:n_trials, opts[:trials] || 20)
|> maybe_add(:n_initial, opts[:n_initial])
|> maybe_add(:gamma, opts[:gamma])
|> maybe_add(:timeout, opts[:timeout])
|> maybe_add(:early_stop, opts[:early_stop])
end
opt_opts
end
defp maybe_add(opts, _key, nil), do: opts
defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value)
defp print_header(suite, parameters, opts) do
strategy = opts[:strategy] || "bayesian"
metric = opts[:metric] || "score"
trials = opts[:trials] || 20
Mix.shell().info("""
╔═══════════════════════════════════════════════════════════╗
║ Nous Parameter Optimization ║
╚═══════════════════════════════════════════════════════════╝
Suite: #{suite.name}
Strategy: #{strategy}
Metric: #{metric} (#{if opts[:minimize], do: "minimize", else: "maximize"})
Trials: #{trials}
Parameters:
""")
Enum.each(parameters, fn param ->
Mix.shell().info(" • #{param.name}: #{format_param(param)}")
end)
Mix.shell().info("")
end
defp format_param(%Parameter{type: :float, min: min, max: max, step: step}) do
if step do
"float [#{min}, #{max}] step=#{step}"
else
"float [#{min}, #{max}]"
end
end
defp format_param(%Parameter{type: :integer, min: min, max: max, step: step}) do
if step do
"integer [#{min}, #{max}] step=#{step}"
else
"integer [#{min}, #{max}]"
end
end
defp format_param(%Parameter{type: :choice, choices: choices}) do
"choice #{inspect(choices)}"
end
defp format_param(%Parameter{type: :bool}) do
"boolean"
end
defp output_results(result, opts) do
unless opts[:quiet] do
print_results(result)
end
if opts[:output] do
json = result_to_json(result)
File.write!(opts[:output], json)
Mix.shell().info("\nResults written to: #{opts[:output]}")
end
end
defp print_results(result) do
Mix.shell().info("""
════════════════════════════════════════════════════════════
OPTIMIZATION RESULTS
════════════════════════════════════════════════════════════
Best Score: #{Float.round(result.best_score, 4)}
Best Configuration:
""")
Enum.each(result.best_config, fn {key, value} ->
Mix.shell().info(" #{key}: #{inspect(value)}")
end)
Mix.shell().info("""
Statistics:
Total trials: #{result.total_trials}
Duration: #{format_duration(result.duration_ms)}
Avg score: #{Float.round(result.avg_score, 4)}
Score std dev: #{Float.round(result.std_score, 4)}
Top 5 Configurations:
""")
result.all_trials
|> Enum.sort_by(& &1.score, :desc)
|> Enum.take(5)
|> Enum.with_index(1)
|> Enum.each(fn {trial, idx} ->
config_str =
trial.config
|> Enum.map(fn {k, v} -> "#{k}=#{inspect(v)}" end)
|> Enum.join(", ")
Mix.shell().info(" #{idx}. #{Float.round(trial.score, 4)} | #{config_str}")
end)
Mix.shell().info("")
end
defp format_duration(ms) when ms < 1000, do: "#{ms}ms"
defp format_duration(ms) when ms < 60_000, do: "#{Float.round(ms / 1000, 1)}s"
defp format_duration(ms), do: "#{Float.round(ms / 60_000, 1)}min"
defp result_to_json(result) do
%{
best_config: result.best_config,
best_score: result.best_score,
total_trials: result.total_trials,
duration_ms: result.duration_ms,
avg_score: result.avg_score,
std_score: result.std_score,
all_trials:
Enum.map(result.all_trials, fn trial ->
%{
config: trial.config,
score: trial.score,
metrics: trial.metrics,
duration_ms: trial.duration_ms
}
end),
generated_at: DateTime.to_iso8601(DateTime.utc_now())
}
|> Nous.JSON.pretty_encode!()
end
end