Current section

Files

Jump to
nous lib nous eval suite.ex
Raw

lib/nous/eval/suite.ex

defmodule Nous.Eval.Suite do
@moduledoc """
A collection of test cases with shared configuration.
Suites group related test cases and provide shared defaults for model,
timeout, and other settings.
## Example
suite = Suite.new(
name: "weather_agent_tests",
description: "Tests for the weather agent",
default_model: "lmstudio:ministral-3-14b-reasoning",
test_cases: [
TestCase.new(id: "basic", input: "What's the weather?", ...),
TestCase.new(id: "city", input: "Weather in Tokyo?", ...)
]
)
## Loading from YAML
{:ok, suite} = Suite.from_yaml("test/eval/suites/weather.yaml")
## Filtering
# Filter by tags
filtered = Suite.filter_by_tags(suite, [:basic, :tool])
# Exclude tags
filtered = Suite.exclude_tags(suite, [:slow])
"""
alias Nous.Eval.TestCase
@type t :: %__MODULE__{
name: String.t(),
description: String.t() | nil,
test_cases: [TestCase.t()],
default_model: String.t() | nil,
default_instructions: String.t() | nil,
default_timeout: non_neg_integer(),
parallelism: non_neg_integer(),
retry_failed: non_neg_integer(),
setup: (-> map()) | nil,
teardown: (map() -> :ok) | nil,
metadata: map()
}
@enforce_keys [:name]
defstruct [
:name,
:description,
:default_instructions,
:setup,
:teardown,
test_cases: [],
default_model: nil,
default_timeout: 30_000,
parallelism: 1,
retry_failed: 0,
metadata: %{}
]
@doc """
Create a new test suite.
## Options
* `:name` - Suite name (required)
* `:description` - Human-readable description
* `:test_cases` - List of TestCase structs
* `:default_model` - Default model for all test cases
* `:default_instructions` - Default system instructions
* `:default_timeout` - Default timeout in ms (default: 30_000)
* `:parallelism` - Number of concurrent tests (default: 1)
* `:retry_failed` - Retry count for failed tests (default: 0)
* `:setup` - Function called before suite runs
* `:teardown` - Function called after suite runs
* `:metadata` - Additional metadata
## Example
suite = Suite.new(
name: "my_tests",
default_model: "lmstudio:ministral-3-14b-reasoning",
test_cases: [
TestCase.new(id: "test1", input: "Hello"),
TestCase.new(id: "test2", input: "World")
]
)
"""
@spec new(keyword()) :: t()
def new(opts) when is_list(opts) do
name = Keyword.fetch!(opts, :name)
%__MODULE__{
name: to_string(name),
description: Keyword.get(opts, :description),
test_cases: Keyword.get(opts, :test_cases, []),
default_model: Keyword.get(opts, :default_model),
default_instructions: Keyword.get(opts, :default_instructions),
default_timeout: Keyword.get(opts, :default_timeout, 30_000),
parallelism: Keyword.get(opts, :parallelism, 1),
retry_failed: Keyword.get(opts, :retry_failed, 0),
setup: Keyword.get(opts, :setup),
teardown: Keyword.get(opts, :teardown),
metadata: Keyword.get(opts, :metadata, %{})
}
end
@doc """
Add a test case to the suite.
"""
@spec add_case(t(), TestCase.t()) :: t()
def add_case(%__MODULE__{} = suite, %TestCase{} = test_case) do
%{suite | test_cases: suite.test_cases ++ [test_case]}
end
@doc """
Add multiple test cases to the suite.
"""
@spec add_cases(t(), [TestCase.t()]) :: t()
def add_cases(%__MODULE__{} = suite, test_cases) when is_list(test_cases) do
%{suite | test_cases: suite.test_cases ++ test_cases}
end
@doc """
Filter test cases by tags (include only cases with ANY of the specified tags).
"""
@spec filter_by_tags(t(), [atom()]) :: t()
def filter_by_tags(%__MODULE__{} = suite, tags) when is_list(tags) do
filtered =
Enum.filter(suite.test_cases, fn tc ->
Enum.any?(tc.tags, &(&1 in tags))
end)
%{suite | test_cases: filtered}
end
@doc """
Exclude test cases with any of the specified tags.
"""
@spec exclude_tags(t(), [atom()]) :: t()
def exclude_tags(%__MODULE__{} = suite, tags) when is_list(tags) do
filtered =
Enum.reject(suite.test_cases, fn tc ->
Enum.any?(tc.tags, &(&1 in tags))
end)
%{suite | test_cases: filtered}
end
@doc """
Get test case by ID.
"""
@spec get_case(t(), String.t()) :: TestCase.t() | nil
def get_case(%__MODULE__{test_cases: cases}, id) do
Enum.find(cases, fn tc -> tc.id == id end)
end
@doc """
Get number of test cases.
"""
@spec count(t()) :: non_neg_integer()
def count(%__MODULE__{test_cases: cases}), do: length(cases)
@doc """
Load a suite from a YAML file.
## Example YAML
name: my_suite
default_model: lmstudio:ministral-3-14b-reasoning
default_timeout: 30000
test_cases:
- id: greeting
input: "Say hello"
expected:
contains: ["hello"]
eval_type: contains
tags: [basic]
- id: math
input: "What is 2+2?"
expected: "4"
eval_type: exact_match
"""
@spec from_yaml(String.t()) :: {:ok, t()} | {:error, term()}
def from_yaml(path) do
Nous.Eval.YamlLoader.load_suite(path)
end
@doc """
Load a suite from a YAML file, raising on error.
"""
@spec from_yaml!(String.t()) :: t()
def from_yaml!(path) do
case from_yaml(path) do
{:ok, suite} -> suite
{:error, reason} -> raise "Failed to load suite: #{inspect(reason)}"
end
end
@doc """
Load all suites from a directory.
Loads all .yaml and .yml files from the directory.
"""
@spec from_directory(String.t()) :: {:ok, [t()]} | {:error, term()}
def from_directory(dir) do
Nous.Eval.YamlLoader.load_directory(dir)
end
@doc """
Validate a suite and all its test cases.
"""
@spec validate(t()) :: :ok | {:error, term()}
def validate(%__MODULE__{} = suite) do
cond do
is_nil(suite.name) or suite.name == "" ->
{:error, "Suite name is required"}
suite.test_cases == [] ->
{:error, "Suite must have at least one test case"}
true ->
validate_test_cases(suite.test_cases)
end
end
defp validate_test_cases([]), do: :ok
defp validate_test_cases([tc | rest]) do
case TestCase.validate(tc) do
:ok -> validate_test_cases(rest)
{:error, reason} -> {:error, "Test case #{tc.id}: #{reason}"}
end
end
@doc """
Get all unique tags used in the suite.
"""
@spec all_tags(t()) :: [atom()]
def all_tags(%__MODULE__{test_cases: cases}) do
cases
|> Enum.flat_map(& &1.tags)
|> Enum.uniq()
|> Enum.sort()
end
end