Current section
Files
Jump to
Current section
Files
lib/cyclium/test/strategy_case.ex
defmodule Cyclium.Test.StrategyCase do
@moduledoc """
Test helpers for verifying strategy contract compliance.
Validates that a strategy module correctly implements all callbacks
defined in `Cyclium.EpisodeRunner.Strategy` and that the episode loop
terminates within budget.
## Usage
defmodule MyApp.Strategies.ClientHealthTest do
use ExUnit.Case, async: true
use Cyclium.Test.StrategyCase
@episode build_test_episode(actor_id: "test_actor", expectation_id: "health_check")
@trigger %Cyclium.Trigger.Manual{source: "test"}
test "init returns valid state" do
assert_valid_init(MyStrategy, @episode, @trigger)
end
test "strategy terminates" do
assert_strategy_terminates(MyStrategy, @episode, @trigger,
max_steps: 20,
handle_step: fn action, state -> default_step_handler(action, state) end
)
end
end
"""
defmacro __using__(_opts) do
quote do
import Cyclium.Test.StrategyCase
end
end
@doc """
Build a minimal test episode struct for strategy testing.
"""
def build_test_episode(overrides \\ []) do
defaults = %{
id: Ecto.UUID.generate(),
actor_id: "test_actor",
expectation_id: "test_expectation",
trigger_type: :manual,
trigger_ref: %{},
status: :running,
budget: %{max_turns: 12, max_tokens: 25_000, max_wall_ms: 120_000},
turns_used: 0,
tokens_used: 0,
attempts: 0,
max_attempts: 3,
started_at: DateTime.utc_now()
}
struct(Cyclium.Schemas.Episode, Map.new(overrides) |> then(&Map.merge(defaults, &1)))
end
@doc """
Build a minimal episode context map matching what EpisodeRunner passes.
"""
def build_episode_ctx(episode \\ nil) do
episode = episode || build_test_episode()
%{
episode_id: episode.id,
actor_id: episode.actor_id,
expectation_id: episode.expectation_id,
budget: episode.budget,
turns_used: episode.turns_used,
tokens_used: episode.tokens_used
}
end
@doc """
Build a minimal step struct for handle_result testing.
"""
def build_test_step(overrides \\ []) do
defaults = %{
id: Ecto.UUID.generate(),
episode_id: Ecto.UUID.generate(),
step_no: 1,
kind: :tool_call,
created_at: DateTime.utc_now()
}
struct(Cyclium.Schemas.EpisodeStep, Map.new(overrides) |> then(&Map.merge(defaults, &1)))
end
@doc """
Assert that `strategy.init/2` returns `{:ok, map()}`.
"""
defmacro assert_valid_init(strategy, episode, trigger) do
quote bind_quoted: [strategy: strategy, episode: episode, trigger: trigger] do
result = strategy.init(episode, trigger)
assert {:ok, state} = result
assert is_map(state), "init/2 must return {:ok, map()}, got: {:ok, #{inspect(state)}}"
state
end
end
@doc """
Assert that `strategy.next_step/2` returns a valid action shape.
"""
defmacro assert_valid_next_step(strategy, state, episode_ctx) do
quote bind_quoted: [strategy: strategy, state: state, episode_ctx: episode_ctx] do
action = strategy.next_step(state, episode_ctx)
Cyclium.Test.StrategyCase.validate_next_step_shape!(action)
action
end
end
@doc """
Assert that `strategy.handle_result/3` returns a valid response.
"""
defmacro assert_valid_handle_result(strategy, state, step, result) do
quote bind_quoted: [strategy: strategy, state: state, step: step, result: result] do
response = strategy.handle_result(state, step, result)
Cyclium.Test.StrategyCase.validate_handle_result_shape!(response)
end
end
@doc """
Assert that `strategy.converge/2` returns valid output.
"""
defmacro assert_valid_converge(strategy, state, episode_ctx) do
quote bind_quoted: [strategy: strategy, state: state, episode_ctx: episode_ctx] do
result = strategy.converge(state, episode_ctx)
Cyclium.Test.StrategyCase.validate_converge_shape!(result)
end
end
@doc """
Simulate a strategy through the episode loop and assert it terminates
within `max_steps`. Uses `handle_step` callback to produce mock results
for each step action.
## Options
* `:max_steps` — maximum loop iterations before failing (default: 50)
* `:handle_step` — `(action, state) -> {:result, term()} | :skip`
callback to produce mock step results. Defaults to `default_step_handler/2`.
"""
defmacro assert_strategy_terminates(strategy, episode, trigger, opts \\ []) do
quote bind_quoted: [strategy: strategy, episode: episode, trigger: trigger, opts: opts] do
max_steps = Keyword.get(opts, :max_steps, 50)
handle_step =
Keyword.get(opts, :handle_step, &Cyclium.Test.StrategyCase.default_step_handler/2)
{:ok, initial_state} = strategy.init(episode, trigger)
ctx = Cyclium.Test.StrategyCase.build_episode_ctx(episode)
Cyclium.Test.StrategyCase.run_loop(strategy, initial_state, ctx, handle_step, max_steps, 0)
end
end
@doc false
def run_loop(_strategy, _state, _ctx, _handle_step, max_steps, step_count)
when step_count >= max_steps do
flunk("Strategy did not terminate within #{max_steps} steps")
end
def run_loop(strategy, state, ctx, handle_step, max_steps, step_count) do
action = strategy.next_step(state, ctx)
validate_next_step_shape!(action)
case action do
:done ->
{:ok, state, step_count}
:converge ->
result = strategy.converge(state, ctx)
case result do
{:ok, %Cyclium.ConvergeResult{}} -> {:ok, state, step_count}
{:partial, %Cyclium.ConvergeResult{}, _} -> {:ok, state, step_count}
other -> flunk("converge/2 returned invalid shape: #{inspect(other)}")
end
_ ->
case handle_step.(action, state) do
{:result, mock_result} ->
step = build_test_step(step_no: step_count + 1)
case strategy.handle_result(state, step, mock_result) do
{:ok, new_state} ->
new_ctx = Map.update!(ctx, :turns_used, &(&1 + 1))
run_loop(strategy, new_state, new_ctx, handle_step, max_steps, step_count + 1)
{:retry, new_state} ->
run_loop(strategy, new_state, ctx, handle_step, max_steps, step_count + 1)
{:abort, reason} ->
{:aborted, reason, step_count}
end
:skip ->
new_ctx = Map.update!(ctx, :turns_used, &(&1 + 1))
run_loop(strategy, state, new_ctx, handle_step, max_steps, step_count + 1)
end
end
end
@doc """
Default step handler that returns generic mock results for each action type.
Override in tests for strategy-specific behavior.
"""
def default_step_handler(action, _state) do
case action do
{:tool_call, _cap, _action, _args} -> {:result, %{"status" => "ok"}}
{:synthesize, _ctx} -> {:result, %{"response" => "mock synthesis"}}
{:observe, _data} -> {:result, %{"observed" => true}}
{:output, _type, _payload} -> :skip
{:checkpoint, _phase} -> :skip
{:approval, _req} -> :skip
{:wait, _ref} -> :skip
end
end
@doc false
def validate_next_step_shape!(action) do
valid =
case action do
:done -> true
:converge -> true
{:tool_call, cap, act, args} when is_atom(cap) and is_atom(act) and is_map(args) -> true
{:synthesize, ctx} when is_map(ctx) -> true
{:observe, data} when is_map(data) -> true
{:output, type, payload} when is_atom(type) and is_map(payload) -> true
{:checkpoint, phase} when is_binary(phase) -> true
{:approval, req} when is_map(req) -> true
{:wait, ref} when is_map(ref) -> true
_ -> false
end
unless valid do
raise ArgumentError,
message: "next_step/2 returned invalid action shape: #{inspect(action)}"
end
action
end
@doc false
def validate_handle_result_shape!(response) do
case response do
{:ok, new_state} when is_map(new_state) ->
response
{:retry, new_state} when is_map(new_state) ->
response
{:abort, _reason} ->
response
other ->
raise ArgumentError, message: "handle_result/3 returned invalid shape: #{inspect(other)}"
end
end
@doc false
def validate_converge_shape!(result) do
case result do
{:ok, %Cyclium.ConvergeResult{}} ->
result
{:partial, %Cyclium.ConvergeResult{}, failures} when is_list(failures) ->
result
other ->
raise ArgumentError, message: "converge/2 returned invalid shape: #{inspect(other)}"
end
end
defp flunk(message) do
raise ArgumentError, message: message
end
end