Current section
Files
Jump to
Current section
Files
src/adk_eval.erl
%% @doc adk_eval - Lightweight evaluation helpers for Erlang ADK.
%%
%% Allows defining datasets and running them against an agent to calculate metrics.
-module(adk_eval).
-export([run/3, run/4]).
-type dataset() :: [#{input => term(), expected => term(), metadata => map()}].
-type metric_fn() :: fun((Expected :: term(), Actual :: term()) -> float()).
%% @doc Run an evaluation.
-spec run(AgentRef :: term(), Dataset :: dataset(), MetricFn :: metric_fn()) -> {ok, map()} | {error, term()}.
run(AgentRef, Dataset, MetricFn) ->
run(AgentRef, Dataset, MetricFn, #{}).
%% @doc Run an evaluation with options (e.g. concurrency).
%%
%% `concurrency' controls the number of evaluator worker processes. Calls made
%% to one stateful agent pid are still serialized by that agent's gen_server;
%% use separate agent processes when model calls themselves must overlap.
-spec run(AgentRef :: term(), Dataset :: dataset(), MetricFn :: metric_fn(), Opts :: map()) -> {ok, map()} | {error, term()}.
run(AgentRef, Dataset, MetricFn, Opts)
when is_list(Dataset), is_function(MetricFn, 2), is_map(Opts) ->
Concurrency = maps:get(concurrency, Opts, 1),
Timeout = maps:get(timeout, Opts, 60000),
case validate_options(Concurrency, Timeout) of
ok ->
EvalFun = fun(Row) -> evaluate_row(AgentRef, Row, MetricFn) end,
%% Use monitored workers even at concurrency=1 so the timeout has
%% identical meaning in sequential and parallel evaluations.
Results = map_in_batches(
EvalFun, Dataset, Concurrency, Timeout, []),
TotalScore = lists:sum([maps:get(score, R) || R <- Results]),
AvgScore = case Results of
[] -> 0.0;
_ -> TotalScore / length(Results)
end,
TotalDuration = lists:sum(
[maps:get(duration, R) || R <- Results]),
{ok, #{average_score => AvgScore,
total_duration_ms => TotalDuration,
results => Results}};
{error, _} = Error ->
Error
end;
run(_AgentRef, _Dataset, _MetricFn, _Opts) ->
{error, invalid_evaluation_arguments}.
evaluate_row(AgentRef, Row, MetricFn) ->
Input = maps:get(input, Row),
Expected = maps:get(expected, Row),
Start = erlang:monotonic_time(millisecond),
PromptResult = try erlang_adk:prompt(AgentRef, Input) of
PromptValue -> PromptValue
catch
Class:PromptFailure -> {error, {Class, PromptFailure}}
end,
Duration = erlang:monotonic_time(millisecond) - Start,
case PromptResult of
{ok, Response} ->
Score = try MetricFn(Expected, Response) of
MetricValue when is_number(MetricValue) -> MetricValue
catch
_:_ -> 0.0
end,
#{input => Input, expected => Expected, actual => Response,
score => Score, duration => Duration,
metadata => maps:get(metadata, Row, #{})};
{error, PromptError} ->
#{input => Input, expected => Expected, actual => undefined,
score => 0.0, duration => Duration,
metadata => maps:get(metadata, Row, #{}), error => PromptError}
end.
map_in_batches(_Fun, [], _Concurrency, _Timeout, Acc) ->
lists:append(lists:reverse(Acc));
map_in_batches(Fun, Items, Concurrency, Timeout, Acc) ->
BatchSize = erlang:min(Concurrency, length(Items)),
{Batch, Rest} = lists:split(BatchSize, Items),
Results = parallel_batch(Fun, Batch, Timeout),
map_in_batches(Fun, Rest, Concurrency, Timeout, [Results | Acc]).
parallel_batch(Fun, Items, Timeout) ->
Parent = self(),
Jobs = lists:map(fun({Index, Item}) ->
Ref = make_ref(),
StartedAt = erlang:monotonic_time(millisecond),
{Pid, Monitor} = spawn_monitor(fun() ->
Result = try Fun(Item) of
Value -> Value
catch
Class:Reason ->
failed_result(Item, {Class, Reason}, elapsed_ms(StartedAt))
end,
Parent ! {Ref, Result}
end),
{Index, Item, StartedAt, Ref, Pid, Monitor}
end, lists:enumerate(Items)),
Deadline = eval_deadline(Timeout),
[collect_eval_job(Job, Deadline) || Job <- Jobs].
collect_eval_job({_Index, Item, StartedAt, Ref, Pid, Monitor}, Deadline) ->
Remaining = eval_remaining(Deadline),
receive
{Ref, Result} ->
erlang:demonitor(Monitor, [flush]),
Result;
{'DOWN', Monitor, process, Pid, Reason} ->
failed_result(Item, {worker_down, Reason}, elapsed_ms(StartedAt))
after Remaining ->
exit(Pid, kill),
erlang:demonitor(Monitor, [flush]),
failed_result(Item, timeout, elapsed_ms(StartedAt))
end.
failed_result(Row, Error, Duration) when is_map(Row) ->
#{input => maps:get(input, Row, undefined),
expected => maps:get(expected, Row, undefined),
actual => undefined,
score => 0.0,
duration => Duration,
metadata => maps:get(metadata, Row, #{}),
error => Error};
failed_result(_Row, Error, Duration) ->
#{input => undefined,
expected => undefined,
actual => undefined,
score => 0.0,
duration => Duration,
metadata => #{},
error => Error}.
elapsed_ms(StartedAt) ->
erlang:max(0, erlang:monotonic_time(millisecond) - StartedAt).
validate_options(Concurrency, Timeout)
when is_integer(Concurrency), Concurrency > 0,
(Timeout =:= infinity orelse
(is_integer(Timeout) andalso Timeout >= 0)) ->
ok;
validate_options(Concurrency, _Timeout)
when not is_integer(Concurrency); Concurrency =< 0 ->
{error, {invalid_concurrency, Concurrency}};
validate_options(_Concurrency, Timeout) ->
{error, {invalid_timeout, Timeout}}.
eval_deadline(infinity) -> infinity;
eval_deadline(Timeout) -> erlang:monotonic_time(millisecond) + Timeout.
eval_remaining(infinity) -> infinity;
eval_remaining(Deadline) ->
erlang:max(0, Deadline - erlang:monotonic_time(millisecond)).