Current section
Files
Jump to
Current section
Files
lib/puck/eval/graders.ex
defmodule Puck.Eval.Graders do
@moduledoc """
Built-in graders for common evaluation patterns.
All graders return functions that can be used with `Puck.Eval.Grader.run/3`
or `Puck.Eval.Result.from_graders/3`.
## Output Graders
Check the agent's final output:
* `contains/1` - Output contains a substring
* `matches/1` - Output matches a regex
* `equals/1` - Output equals expected value
* `satisfies/1` - Output passes a predicate function
## Trajectory Graders
Check the agent's execution trajectory:
* `max_steps/1` - Trajectory has at most N steps
* `max_tokens/1` - Trajectory used at most N tokens
* `max_duration_ms/1` - Trajectory took at most N milliseconds
## Step Output Graders
Check what was produced during execution:
* `output_produced/1,2` - A specific struct type was produced
* `output_matches/1,2` - A step output matches a predicate (for value checks)
* `output_not_produced/1` - A specific struct type was not produced
* `output_sequence/1` - Struct types were produced in a specific order
## Example
alias Puck.Eval.{Collector, Graders, Result}
{output, trajectory} = Collector.collect(fn -> MyAgent.run(input) end)
result = Result.from_graders(output, trajectory, [
Graders.contains("john@example.com"),
Graders.max_steps(5),
Graders.output_produced(LookupContact)
])
result.passed? # => true or false
"""
@doc """
Checks if the output contains the given substring.
## Example
grader = Graders.contains("hello")
grader.("hello world", trajectory)
# => :pass
grader.("goodbye", trajectory)
# => {:fail, "Output does not contain \\"hello\\""}
"""
def contains(substring) when is_binary(substring) do
fn output, _trajectory ->
output_str = to_string_safe(output)
if String.contains?(output_str, substring) do
:pass
else
{:fail, "Output does not contain #{inspect(substring)}"}
end
end
end
@doc """
Checks if the output matches the given regex.
## Example
grader = Graders.matches(~r/\\d{3}-\\d{4}/)
grader.("Call 555-1234", trajectory)
# => :pass
"""
def matches(%Regex{} = regex) do
fn output, _trajectory ->
output_str = to_string_safe(output)
if Regex.match?(regex, output_str) do
:pass
else
{:fail, "Output does not match #{inspect(regex)}"}
end
end
end
@doc """
Checks if the output equals the expected value.
## Example
grader = Graders.equals("success")
grader.("success", trajectory)
# => :pass
"""
def equals(expected) do
fn output, _trajectory ->
if output == expected do
:pass
else
{:fail, "Output #{inspect(output)} does not equal #{inspect(expected)}"}
end
end
end
@doc """
Checks if the output satisfies a predicate function.
The predicate receives the output and should return a boolean.
## Example
grader = Graders.satisfies(fn output -> String.length(output) > 10 end)
grader.("hello world!", trajectory)
# => :pass
"""
def satisfies(predicate) when is_function(predicate, 1) do
fn output, _trajectory ->
if predicate.(output) do
:pass
else
{:fail, "Output does not satisfy predicate"}
end
end
end
@doc """
Checks that the trajectory has at most N steps.
## Example
grader = Graders.max_steps(3)
grader.(output, %Trajectory{total_steps: 2})
# => :pass
grader.(output, %Trajectory{total_steps: 5})
# => {:fail, "5 steps exceeds max of 3"}
"""
def max_steps(n) when is_integer(n) and n >= 0 do
fn _output, trajectory ->
if trajectory.total_steps <= n do
:pass
else
{:fail, "#{trajectory.total_steps} steps exceeds max of #{n}"}
end
end
end
@doc """
Checks that the trajectory used at most N tokens.
## Example
grader = Graders.max_tokens(1000)
grader.(output, %Trajectory{total_tokens: 500})
# => :pass
"""
def max_tokens(n) when is_integer(n) and n >= 0 do
fn _output, trajectory ->
if trajectory.total_tokens <= n do
:pass
else
{:fail, "#{trajectory.total_tokens} tokens exceeds max of #{n}"}
end
end
end
@doc """
Checks that the trajectory took at most N milliseconds.
## Example
grader = Graders.max_duration_ms(5000)
grader.(output, %Trajectory{total_duration_ms: 3000})
# => :pass
"""
def max_duration_ms(n) when is_integer(n) and n >= 0 do
fn _output, trajectory ->
if trajectory.total_duration_ms <= n do
:pass
else
{:fail, "#{trajectory.total_duration_ms}ms exceeds max of #{n}ms"}
end
end
end
@doc """
Checks that a specific struct type was produced during execution.
Matches directly on struct module types - no extractor needed.
## Options
* `:times` - Exact number of times this struct should appear (default: at least once)
## Example
# Check if any step produced a LookupContact struct
grader = Graders.output_produced(LookupContact)
grader.(output, trajectory)
# => :pass if any step.output was %LookupContact{}
# Check exact count
grader = Graders.output_produced(LookupContact, times: 2)
"""
def output_produced(struct_module, opts \\ [])
def output_produced(struct_module, opts) when is_atom(struct_module) do
times = Keyword.get(opts, :times)
fn _output, trajectory ->
count = count_struct_type(trajectory, struct_module)
cond do
times && count == times ->
:pass
times && count != times ->
{:fail, "#{inspect(struct_module)} produced #{count} times, expected #{times}"}
count > 0 ->
:pass
true ->
{:fail, "#{inspect(struct_module)} was not produced"}
end
end
end
@doc """
Checks that any step output matches a predicate function.
Use this to assert on specific struct values, not just types.
## Options
* `:times` - Exact number of matches required (default: at least once)
## Example
# Check if any step produced a LookupContact for "John"
grader = Graders.output_matches(fn
%LookupContact{name: "John"} -> true
_ -> false
end)
# Check exact count of matching outputs
grader = Graders.output_matches(
fn %LookupContact{} -> true; _ -> false end,
times: 2
)
"""
def output_matches(predicate, opts \\ [])
def output_matches(predicate, opts) when is_function(predicate, 1) do
times = Keyword.get(opts, :times)
fn _output, trajectory ->
count = Enum.count(trajectory.steps, fn step -> predicate.(step.output) end)
cond do
times && count == times ->
:pass
times && count != times ->
{:fail, "Output matched #{count} times, expected #{times}"}
count > 0 ->
:pass
true ->
{:fail, "No step output matched the predicate"}
end
end
end
@doc """
Checks that a specific struct type was NOT produced during execution.
## Example
grader = Graders.output_not_produced(DeleteContact)
grader.(output, trajectory)
# => :pass if no step.output was %DeleteContact{}
"""
def output_not_produced(struct_module) when is_atom(struct_module) do
fn _output, trajectory ->
count = count_struct_type(trajectory, struct_module)
if count > 0 do
{:fail, "#{inspect(struct_module)} was produced but should not have been"}
else
:pass
end
end
end
@doc """
Checks that struct types were produced in a specific order.
The sequence must appear somewhere in the trajectory, but other struct
types can appear between the expected ones.
## Example
# Check investigation pattern: snapshot -> execute -> alert
grader = Graders.output_sequence([TakeSnapshot, Execute, FireAlert])
grader.(output, trajectory)
# => :pass if structs appeared in that order
"""
def output_sequence(struct_modules) when is_list(struct_modules) do
fn _output, trajectory ->
struct_types = extract_struct_types(trajectory)
if subsequence?(struct_modules, struct_types) do
:pass
else
{:fail,
"Expected sequence #{inspect(struct_modules)} not found in #{inspect(struct_types)}"}
end
end
end
defp to_string_safe(value) when is_binary(value), do: value
defp to_string_safe(value), do: inspect(value)
defp count_struct_type(trajectory, struct_module) do
Enum.count(trajectory.steps, fn step ->
is_struct(step.output, struct_module)
end)
end
defp extract_struct_types(trajectory) do
trajectory.steps
|> Enum.map(fn step ->
if is_struct(step.output), do: step.output.__struct__, else: nil
end)
|> Enum.reject(&is_nil/1)
end
defp subsequence?([], _haystack), do: true
defp subsequence?(_needle, []), do: false
defp subsequence?([n | rest_needle], [n | rest_haystack]) do
subsequence?(rest_needle, rest_haystack)
end
defp subsequence?(needle, [_h | rest_haystack]) do
subsequence?(needle, rest_haystack)
end
end