Current section
Files
Jump to
Current section
Files
lib/gepa/engine.ex
defmodule GEPA.Engine do
# The engine loop intentionally coordinates proposal, evaluation, telemetry,
# and persistence in one place. Keep this exception scoped to this module.
# credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity
# credo:disable-for-this-file Credo.Check.Refactor.Nesting
@moduledoc """
Main optimization engine for GEPA.
Orchestrates the optimization loop: propose → evaluate → accept/reject → repeat.
"""
require Logger
alias GEPA.Adapter.Dispatch
alias GEPA.{
Callbacks,
CandidateProposal,
DataLoader,
EvaluationBatch,
EvaluationCache,
State,
Telemetry,
Tracking
}
alias GEPA.Proposer.{Merge, Reflective}
@doc """
Run optimization until stop condition met.
## Parameters
- `config`: Configuration map with all necessary settings
## Returns
`{:ok, final_state}` on success
"""
@spec run(map()) :: {:ok, State.t()}
def run(config) do
run_start_ms = System.monotonic_time(:millisecond)
Telemetry.emit_run_start(config)
Tracking.start(config[:tracker])
# Start progress display if enabled
progress = maybe_start_progress(config)
# Initialize or load state
state = initialize_state(config)
Callbacks.notify(config[:callbacks], :optimization_start, %{
seed_candidate: config.seed_candidate,
trainset_size: length(DataLoader.all_ids(config.trainset)),
valset_size: length(DataLoader.all_ids(config.valset)),
config: config
})
# Run optimization loop
final_state =
state
|> optimization_loop(config, progress)
|> sync_adapter_state_to_state(config.adapter)
# Save final state if run_dir configured
if config[:run_dir] do
save_state(final_state, config.run_dir)
notify_state_saved(config, final_state)
end
Telemetry.emit_run_stop(final_state, run_start_ms)
Tracking.log_summary(config[:tracker], %{
total_iterations: final_state.i,
total_metric_calls: final_state.total_num_evals,
best_score: best_score(final_state)
})
# Finish progress display
maybe_finish_progress(progress, final_state)
Callbacks.notify(config[:callbacks], :optimization_end, %{
best_candidate_idx: best_program_idx(final_state),
total_iterations: final_state.i,
total_metric_calls: final_state.total_num_evals,
final_state: final_state
})
Tracking.finish(config[:tracker])
{:ok, final_state}
end
@doc """
Run a single optimization iteration.
Returns `{:cont, new_state}` to continue or `{:stop, state}` to stop.
"""
@spec run_iteration(State.t(), map()) ::
{:cont, State.t(), map(), boolean(), term()} | {:stop, State.t()}
def run_iteration(state, config) do
# Check stop conditions
if should_stop?(state, config.stop_conditions) do
Logger.info("Stop condition met at iteration #{state.i}")
{:stop, state}
else
prev_best = best_score(state)
iter_start_ms = System.monotonic_time(:millisecond)
# Increment internal iteration. Public callbacks/logs are one-based to
# match upstream while state.i remains zero-based after the first loop.
next_i = state.i + 1
state = %{
state
| i: next_i,
full_program_trace: state.full_program_trace ++ [%{i: next_i}]
}
iteration = state.i + 1
Logger.debug("Starting iteration #{iteration}")
Callbacks.notify(config[:callbacks], :iteration_start, %{
iteration: iteration,
state: state,
trainset: config.trainset
})
Tracking.log_metrics(
config[:tracker],
%{iteration: iteration, total_metric_calls: state.total_num_evals},
step: iteration
)
# Try merge proposer first (if configured and conditions met)
{proposal, state, config, proposal_evals} =
case Map.fetch(config, :merge_proposer) do
{:ok, nil} ->
{reflective, new_state, new_config, metric_calls} =
try_reflective_proposal(state, config)
{reflective, new_state, new_config, metric_calls}
{:ok,
%Merge{
use_merge: true,
merges_due: merges_due,
last_iter_found_new_program: true
} = merge_proposer}
when merges_due > 0 ->
{merge_proposal, updated_proposer} =
Merge.propose(merge_proposer, state)
notify_merge_attempt(config, iteration, merge_proposal)
updated_proposer = %{updated_proposer | last_iter_found_new_program: false}
merge_config = %{config | merge_proposer: updated_proposer}
case merge_proposal do
%CandidateProposal{} ->
{merge_proposal, state, merge_config, nil}
nil ->
{reflective, new_state, new_config, metric_calls} =
try_reflective_proposal(state, merge_config)
{reflective, new_state, new_config, metric_calls}
end
{:ok, merge_proposer} ->
merge_config = %{config | merge_proposer: merge_proposer}
{reflective, new_state, new_config, metric_calls} =
try_reflective_proposal(state, merge_config)
{reflective, new_state, new_config, metric_calls}
:error ->
{reflective, new_state, new_config, metric_calls} =
try_reflective_proposal(state, config)
{reflective, new_state, new_config, metric_calls}
end
selected_candidate = selected_candidate_for(proposal)
Telemetry.emit_iteration_start(iteration, selected_candidate)
proposal_tag = proposal_tag(proposal)
subsample_before_sum = subsample_sum(proposal, :before)
subsample_after_sum = subsample_sum(proposal, :after)
subsample_ids = proposal_subsample_ids(proposal)
{result_tag, new_state, new_config, accepted?} =
case proposal do
%CandidateProposal{} ->
Logger.debug("Proposal generated for iteration #{iteration} (#{proposal.tag})")
Telemetry.emit_proposal_generated(proposal, iteration)
# Update eval counter
num_subsample_evals = nil_default(proposal_evals, proposal_metric_calls(proposal))
state = %{state | total_num_evals: state.total_num_evals + num_subsample_evals}
if CandidateProposal.should_accept?(
proposal,
Map.get(config, :acceptance_criterion, :strict_improvement),
state
) do
Logger.info("Accepting #{proposal.tag} proposal at iteration #{iteration}")
new_state = accept_proposal(state, proposal, config, iteration)
new_candidate_idx = length(new_state.program_candidates) - 1
Callbacks.notify(config[:callbacks], :candidate_accepted, %{
iteration: iteration,
new_candidate_idx: new_candidate_idx,
parent_ids: proposal.parent_program_ids,
candidate: proposal.candidate,
new_score: elem(State.get_program_score(new_state, new_candidate_idx), 0)
})
notify_merge_decision(config, iteration, proposal, new_candidate_idx, :accepted)
Telemetry.emit_proposal_decision(
proposal,
iteration,
true,
:accepted,
subsample_after_sum - subsample_before_sum,
proposal.parent_program_ids
)
Tracking.log_metrics(
config[:tracker],
%{
proposal_accepted: 1,
subsample_delta: subsample_after_sum - subsample_before_sum,
total_metric_calls: new_state.total_num_evals
},
step: iteration
)
new_config = update_merge_proposer_after_accept(config, proposal.tag)
{:cont, new_state, new_config, true}
else
Logger.debug("Rejecting proposal at iteration #{iteration}")
Callbacks.notify(config[:callbacks], :candidate_rejected, %{
iteration: iteration,
old_score: subsample_before_sum,
new_score: subsample_after_sum,
reason: :not_improved
})
notify_merge_decision(config, iteration, proposal, nil, :rejected)
Telemetry.emit_proposal_decision(
proposal,
iteration,
false,
:not_improved,
subsample_after_sum - subsample_before_sum,
proposal.parent_program_ids
)
Tracking.log_metrics(
config[:tracker],
%{
proposal_accepted: 0,
subsample_delta: subsample_after_sum - subsample_before_sum,
total_metric_calls: state.total_num_evals
},
step: iteration
)
{:cont, state, config, false}
end
nil ->
Logger.debug("No proposal generated at iteration #{iteration}")
state = add_metric_calls(state, nil_default(proposal_evals, 0))
Callbacks.notify(config[:callbacks], :candidate_rejected, %{
iteration: iteration,
old_score: subsample_before_sum,
new_score: subsample_after_sum,
reason: :schedule_skip
})
Telemetry.emit_proposal_decision(
nil,
iteration,
false,
:schedule_skip,
0.0,
nil
)
{:cont, state, config, false}
end
new_config = update_stop_conditions(new_config, new_state)
notify_budget_updated(config, iteration, state, new_state, new_config)
iter_duration_ms = System.monotonic_time(:millisecond) - iter_start_ms
Telemetry.emit_iteration_stop(
new_state,
iteration,
prev_best,
accepted?,
subsample_before_sum,
subsample_after_sum,
proposal_tag,
proposal_parent_ids(proposal),
subsample_ids,
iter_duration_ms
)
Callbacks.notify(config[:callbacks], :iteration_end, %{
iteration: iteration,
state: new_state,
proposal_accepted: accepted?
})
{result_tag, new_state, new_config, accepted?, proposal_tag}
end
end
defp selected_candidate_for(%CandidateProposal{parent_program_ids: parent_ids}),
do: List.first(parent_ids)
defp selected_candidate_for(nil), do: nil
defp proposal_tag(%CandidateProposal{tag: tag}), do: tag
defp proposal_tag(nil), do: nil
defp subsample_sum(%CandidateProposal{subsample_scores_before: scores}, :before),
do: Enum.sum(scores)
defp subsample_sum(%CandidateProposal{subsample_scores_after: scores}, :after),
do: Enum.sum(scores)
defp subsample_sum(nil, _side), do: 0.0
defp proposal_subsample_ids(%CandidateProposal{subsample_indices: ids}), do: ids
defp proposal_subsample_ids(nil), do: nil
defp proposal_parent_ids(%CandidateProposal{parent_program_ids: parent_ids}), do: parent_ids
defp proposal_parent_ids(nil), do: nil
defp try_reflective_proposal(state, config) do
# Use configured reflective proposer or create one
proposer = nil_default(config[:reflective_proposer], create_proposer(config))
try do
case Reflective.propose(proposer, state) do
{:ok, proposal, updated_proposer, updated_state} ->
new_config = put_reflective_proposer(config, updated_proposer)
{proposal, updated_state, new_config, nil}
{:none, updated_proposer, updated_state, metadata} ->
new_config = put_reflective_proposer(config, updated_proposer)
{nil, updated_state, new_config, Map.get(metadata, :num_metric_calls, 0)}
{:error, reason, updated_proposer, updated_state} ->
handle_reflective_error(reason, updated_proposer, updated_state, config)
end
rescue
exception ->
if Map.get(config, :raise_on_exception, true) do
notify_error(config, state.i + 1, exception, false)
reraise exception, __STACKTRACE__
else
Logger.warning("Reflective proposal raised: #{Exception.message(exception)}")
notify_error(config, state.i + 1, exception, true)
{nil, state, config, 0}
end
end
end
defp handle_reflective_error(reason, proposer, state, config) do
if Map.get(config, :raise_on_exception, true) do
notify_error(config, state.i + 1, reason, false)
raise "Reflective proposal failed: #{inspect(reason)}"
else
Logger.warning("Reflective proposal failed: #{inspect(reason)}")
notify_error(config, state.i + 1, reason, true)
new_config = put_reflective_proposer(config, proposer)
{nil, state, new_config, 0}
end
end
# Private functions
defp initialize_state(config) do
# Try to load existing state if run_dir provided
state =
if config[:run_dir] do
case load_state(config.run_dir) do
{:ok, state} ->
Logger.info("Loaded existing state from #{config.run_dir}")
state
|> validate_loaded_state!(config)
|> sync_loaded_cache_setting(config)
{:error, _} ->
create_initial_state(config)
end
else
create_initial_state(config)
end
restore_adapter_state(config.adapter, state.adapter_state)
state
end
defp create_initial_state(config) do
# Evaluate seed candidate on validation set
valset_ids = DataLoader.all_ids(config.valset)
adapter = config.adapter
validation_batch = DataLoader.fetch(config.valset, valset_ids)
Callbacks.notify(config[:callbacks], :evaluation_start, %{
iteration: 0,
candidate_idx: 0,
batch_size: length(valset_ids),
capture_traces: false,
parent_ids: [],
inputs: validation_batch,
is_seed_candidate: true
})
eval_start = System.monotonic_time(:millisecond)
{:ok, eval_batch, evaluation_cache} =
evaluate_validation(
adapter,
config.valset,
valset_ids,
config.seed_candidate,
false,
config[:evaluation_cache]
)
Callbacks.notify(config[:callbacks], :evaluation_end, %{
iteration: 0,
candidate_idx: 0,
scores: eval_batch.scores,
has_trajectories: has_trajectories?(eval_batch),
parent_ids: [],
outputs: eval_batch.outputs,
trajectories: eval_batch.trajectories,
objective_scores: eval_batch.objective_scores,
is_seed_candidate: true
})
duration_ms = System.monotonic_time(:millisecond) - eval_start
Telemetry.emit_evaluation_batch(
0,
:val,
length(valset_ids),
duration_ms,
eval_batch.scores,
0,
"seed"
)
Telemetry.emit_baseline(eval_batch, length(valset_ids))
state =
State.new(config.seed_candidate, eval_batch, valset_ids,
track_best_outputs: nil_default(config[:track_best_outputs], false),
frontier_type: nil_default(config[:frontier_type], :instance),
evaluation_cache: evaluation_cache,
adapter_state: adapter_state_from_adapter(adapter)
)
State.write_valset_outputs(config[:run_dir], valset_ids, eval_batch.outputs, 0, 0)
notify_valset_evaluated(
config,
0,
0,
config.seed_candidate,
valset_ids,
eval_batch,
[],
state
)
state
end
defp optimization_loop(state, config, progress) do
max_iters = Map.get(config, :max_iterations, 1000)
# Safety guard against infinite loops
if state.i + 1 >= max_iters do
Logger.warning("Reached max iterations (#{max_iters}), stopping")
state
else
case run_iteration(state, config) do
{:cont, new_state, new_config, accepted?, proposal_type} ->
# Update progress display
progress = maybe_update_progress(progress, new_state, accepted?, proposal_type)
maybe_save_periodic_state(config, new_state)
optimization_loop(new_state, new_config, progress)
{:stop, final_state} ->
Logger.info("Optimization stopped at iteration #{final_state.i}")
final_state
end
end
end
defp should_stop?(state, stop_conditions) do
Enum.any?(stop_conditions, &GEPA.StopCondition.should_stop?(&1, state))
end
defp update_stop_conditions(config, state) do
Map.update(config, :stop_conditions, [], fn stop_conditions ->
Enum.map(stop_conditions, &GEPA.StopCondition.update(&1, state))
end)
end
defp maybe_save_periodic_state(%{run_dir: nil}, _state), do: :ok
defp maybe_save_periodic_state(config, state) do
if rem(state.i, 5) == 0 do
state
|> sync_adapter_state_to_state(config.adapter)
|> save_state(config.run_dir)
notify_state_saved(config, state)
end
end
defp update_merge_proposer_after_accept(config, "merge") do
case Map.fetch(config, :merge_proposer) do
{:ok, %Merge{} = merge_proposer} ->
updated_merge = %{
merge_proposer
| merges_due: max(merge_proposer.merges_due - 1, 0),
total_merges_tested: merge_proposer.total_merges_tested + 1,
last_iter_found_new_program: false
}
%{config | merge_proposer: updated_merge}
_ ->
config
end
end
defp update_merge_proposer_after_accept(config, _proposal_tag) do
case Map.fetch(config, :merge_proposer) do
{:ok, %Merge{} = merge_proposer} ->
updated_merge = %{merge_proposer | last_iter_found_new_program: true}
updated_merge = Merge.schedule_if_needed(updated_merge)
%{config | merge_proposer: updated_merge}
_ ->
config
end
end
defp proposal_metric_calls(%CandidateProposal{metadata: metadata} = proposal) do
first_present(
[
Map.get(metadata, :num_metric_calls),
Map.get(metadata, "num_metric_calls")
],
length(proposal.subsample_scores_before) + length(proposal.subsample_scores_after)
)
end
defp add_metric_calls(state, 0), do: state
defp add_metric_calls(state, calls) when is_integer(calls) and calls > 0 do
%{state | total_num_evals: state.total_num_evals + calls}
end
defp nil_default(nil, default), do: default
defp nil_default(value, _default), do: value
defp first_present(values, default) do
case Enum.find(values, &(!is_nil(&1))) do
nil -> default
value -> value
end
end
defp validate_loaded_state!(state, config) do
requested_frontier_type = Map.get(config, :frontier_type, :instance)
loaded_frontier_type = Map.get(state, :frontier_type, :instance)
if loaded_frontier_type != requested_frontier_type do
raise ArgumentError,
"Frontier type mismatch: requested #{inspect(requested_frontier_type)} but loaded state has #{inspect(loaded_frontier_type)}"
end
state
end
defp sync_loaded_cache_setting(state, config) do
case Map.get(config, :evaluation_cache) do
nil ->
%{state | evaluation_cache: nil}
evaluation_cache ->
if state.evaluation_cache do
state
else
%{state | evaluation_cache: evaluation_cache}
end
end
end
defp accept_proposal(state, proposal, config, iteration) do
# Evaluate on the validation IDs selected by the configured policy.
valset_ids = validation_eval_ids(config, state, length(state.program_candidates))
adapter = config.adapter
validation_batch = DataLoader.fetch(config.valset, valset_ids)
Callbacks.notify(config[:callbacks], :evaluation_start, %{
iteration: iteration,
candidate_idx: nil,
batch_size: length(valset_ids),
capture_traces: false,
parent_ids: proposal.parent_program_ids,
inputs: validation_batch,
is_seed_candidate: false
})
eval_start = System.monotonic_time(:millisecond)
case evaluate_validation(
adapter,
config.valset,
valset_ids,
proposal.candidate,
false,
state.evaluation_cache
) do
{:ok, eval_batch, evaluation_cache} ->
duration_ms = System.monotonic_time(:millisecond) - eval_start
state = %{state | evaluation_cache: evaluation_cache}
Callbacks.notify(config[:callbacks], :evaluation_end, %{
iteration: iteration,
candidate_idx: nil,
scores: eval_batch.scores,
has_trajectories: has_trajectories?(eval_batch),
parent_ids: proposal.parent_program_ids,
outputs: eval_batch.outputs,
trajectories: eval_batch.trajectories,
objective_scores: eval_batch.objective_scores,
is_seed_candidate: false
})
# Create scores map
val_scores =
valset_ids
|> Enum.zip(eval_batch.scores)
|> Enum.into(%{})
outputs_by_val_id =
valset_ids
|> Enum.zip(eval_batch.outputs)
|> Enum.into(%{})
objective_scores_by_val_id =
if eval_batch.objective_scores do
valset_ids
|> Enum.zip(eval_batch.objective_scores)
|> Enum.into(%{})
end
# Add to state
{new_state, new_idx} =
State.add_program(
state,
proposal.candidate,
proposal.parent_program_ids,
val_scores,
outputs_by_val_id: outputs_by_val_id,
objective_scores_by_val_id: objective_scores_by_val_id,
metric_calls: nil_default(eval_batch.num_metric_calls, map_size(val_scores)),
run_dir: config[:run_dir],
iteration: iteration
)
notify_valset_evaluated(
config,
iteration,
new_idx,
proposal.candidate,
valset_ids,
eval_batch,
proposal.parent_program_ids,
new_state
)
notify_pareto_front_updated(config, iteration, state, new_state)
Telemetry.emit_evaluation_batch(
iteration,
:val,
length(valset_ids),
duration_ms,
eval_batch.scores,
new_idx,
proposal.tag
)
Telemetry.emit_valset_update(new_state, iteration, new_idx, val_scores)
Logger.info(
"Accepted new program #{new_idx} with avg score #{elem(State.get_program_score(new_state, new_idx), 0)}"
)
new_state
{:error, reason} ->
Logger.error("Failed to evaluate proposal: #{inspect(reason)}")
notify_error(config, iteration, reason, false)
state
end
end
defp evaluate_validation(adapter, loader, ids, candidate, capture_traces, nil) do
batch = DataLoader.fetch(loader, ids)
with {:ok, eval_batch} <-
Dispatch.evaluate(adapter, batch, candidate, capture_traces) do
{:ok, eval_batch, nil}
end
end
defp evaluate_validation(
adapter,
loader,
ids,
candidate,
capture_traces,
%EvaluationCache{} = cache
) do
{cached, uncached_ids} = EvaluationCache.get_batch(cache, candidate, ids)
if uncached_ids == [] do
{:ok, eval_batch_from_cached(ids, cached, 0), cache}
else
batch = DataLoader.fetch(loader, uncached_ids)
with {:ok, uncached_eval_batch} <-
Dispatch.evaluate(adapter, batch, candidate, capture_traces) do
cache =
EvaluationCache.put_batch(
cache,
candidate,
uncached_ids,
uncached_eval_batch.outputs,
uncached_eval_batch.scores,
uncached_eval_batch.objective_scores
)
{cached, _uncached_ids} = EvaluationCache.get_batch(cache, candidate, ids)
metric_calls = nil_default(uncached_eval_batch.num_metric_calls, length(uncached_ids))
{:ok, eval_batch_from_cached(ids, cached, metric_calls), cache}
end
end
end
defp validation_eval_ids(config, state, target_program_idx) do
policy = Map.get(config, :val_evaluation_policy, GEPA.Strategies.EvaluationPolicy.Full)
cond do
module_eval_policy?(policy) ->
policy.get_eval_batch(config.valset, state, target_program_idx)
struct_eval_policy_arity4?(policy) ->
policy.__struct__.get_eval_batch(policy, config.valset, state, target_program_idx)
struct_eval_policy_arity3?(policy) ->
policy.__struct__.get_eval_batch(config.valset, state, target_program_idx)
true ->
DataLoader.all_ids(config.valset)
end
end
defp module_eval_policy?(policy) when is_atom(policy) do
function_exported?(policy, :get_eval_batch, 3)
end
defp module_eval_policy?(_policy), do: false
defp struct_eval_policy_arity4?(%module{}) do
function_exported?(module, :get_eval_batch, 4)
end
defp struct_eval_policy_arity4?(_policy), do: false
defp struct_eval_policy_arity3?(%module{}) do
function_exported?(module, :get_eval_batch, 3)
end
defp struct_eval_policy_arity3?(_policy), do: false
defp eval_batch_from_cached(ids, cached, metric_calls) do
entries = Enum.map(ids, &Map.fetch!(cached, &1))
objective_scores = Enum.map(entries, & &1.objective_scores)
%EvaluationBatch{
outputs: Enum.map(entries, & &1.output),
scores: Enum.map(entries, & &1.score),
objective_scores: if(Enum.any?(objective_scores, & &1), do: objective_scores),
num_metric_calls: metric_calls
}
end
defp candidate_selector_from_config(config) do
Map.get(config, :candidate_selector, GEPA.Strategies.CandidateSelector.Pareto)
end
defp put_reflective_proposer(config, %Reflective{} = proposer) do
config
|> Map.put(:reflective_proposer, proposer)
|> Map.put(
:candidate_selector,
nil_default(proposer.candidate_selector, candidate_selector_from_config(config))
)
end
defp create_proposer(config) do
Reflective.new(
adapter: config.adapter,
trainset: config.trainset,
candidate_selector: candidate_selector_from_config(config),
perfect_score: nil_default(config[:perfect_score], 1.0),
skip_perfect_score: Keyword.get(config |> Map.to_list(), :skip_perfect_score, true),
minibatch_size: nil_default(config[:reflection_minibatch_size], 3),
instruction_proposal: config[:instruction_proposal],
batch_sampler: config[:batch_sampler],
module_selector: config[:module_selector],
custom_candidate_proposer: config[:custom_candidate_proposer],
callbacks: config[:callbacks],
seed: nil_default(config[:seed], 0)
)
end
defp adapter_state_from_adapter(adapter), do: Dispatch.get_adapter_state(adapter)
defp restore_adapter_state(adapter, state) when is_map(state) and map_size(state) > 0 do
Dispatch.set_adapter_state(adapter, state)
:ok
end
defp restore_adapter_state(_adapter, _state), do: :ok
defp sync_adapter_state_to_state(state, adapter) do
%{state | adapter_state: adapter_state_from_adapter(adapter)}
end
defp has_trajectories?(%EvaluationBatch{trajectories: trajectories}) do
case trajectories do
[] -> false
values when is_list(values) -> true
_other -> false
end
end
defp notify_merge_attempt(config, iteration, %CandidateProposal{} = proposal) do
Callbacks.notify(config[:callbacks], :merge_attempted, %{
iteration: iteration,
parent_ids: proposal.parent_program_ids,
merged_candidate: proposal.candidate
})
end
defp notify_merge_attempt(config, iteration, _proposal) do
Callbacks.notify(config[:callbacks], :merge_rejected, %{
iteration: iteration,
parent_ids: [],
reason: :no_merge_candidate
})
end
defp notify_merge_decision(
config,
iteration,
%CandidateProposal{tag: "merge"} = proposal,
idx,
:accepted
) do
Callbacks.notify(config[:callbacks], :merge_accepted, %{
iteration: iteration,
new_candidate_idx: idx,
parent_ids: proposal.parent_program_ids
})
end
defp notify_merge_decision(
config,
iteration,
%CandidateProposal{tag: "merge"} = proposal,
_idx,
:rejected
) do
Callbacks.notify(config[:callbacks], :merge_rejected, %{
iteration: iteration,
parent_ids: proposal.parent_program_ids,
reason: :not_improved
})
end
defp notify_merge_decision(_config, _iteration, _proposal, _idx, _decision), do: :ok
defp notify_pareto_front_updated(config, iteration, old_state, new_state) do
old_front = front_program_indices(old_state)
new_front = front_program_indices(new_state)
Callbacks.notify(config[:callbacks], :pareto_front_updated, %{
iteration: iteration,
new_front: new_front,
displaced_candidates: old_front -- new_front
})
end
defp front_program_indices(state) do
state
|> State.get_pareto_front_mapping()
|> Map.values()
|> Enum.flat_map(&MapSet.to_list/1)
|> Enum.uniq()
|> Enum.sort()
end
defp notify_state_saved(config, state) do
Callbacks.notify(config[:callbacks], :state_saved, %{
iteration: state.i + 1,
run_dir: config.run_dir
})
end
defp notify_budget_updated(config, iteration, old_state, new_state, new_config) do
Callbacks.notify(config[:callbacks], :budget_updated, %{
iteration: iteration,
metric_calls_used: new_state.total_num_evals,
metric_calls_delta: new_state.total_num_evals - old_state.total_num_evals,
metric_calls_remaining: metric_calls_remaining(new_config, new_state)
})
end
defp metric_calls_remaining(config, state) do
config
|> Map.get(:stop_conditions, [])
|> List.wrap()
|> Enum.find_value(fn
%GEPA.StopCondition.MaxCalls{max_calls: max_calls} ->
max(max_calls - state.total_num_evals, 0)
_condition ->
nil
end)
end
defp notify_valset_evaluated(
config,
iteration,
candidate_idx,
candidate,
valset_ids,
%EvaluationBatch{} = eval_batch,
parent_ids,
state
) do
scores_by_val_id = Enum.zip(valset_ids, eval_batch.scores) |> Map.new()
outputs_by_val_id = Enum.zip(valset_ids, eval_batch.outputs) |> Map.new()
average_score = average_score(eval_batch.scores)
Callbacks.notify(config[:callbacks], :valset_evaluated, %{
iteration: iteration,
candidate_idx: candidate_idx,
candidate: candidate,
scores_by_val_id: scores_by_val_id,
average_score: average_score,
num_examples_evaluated: length(valset_ids),
total_valset_size: length(DataLoader.all_ids(config.valset)),
parent_ids: parent_ids,
is_best_program: candidate_idx == best_program_idx(state),
outputs_by_val_id: outputs_by_val_id
})
end
defp notify_error(config, iteration, exception, will_continue) do
Callbacks.notify(config[:callbacks], :error, %{
iteration: iteration,
exception: exception,
will_continue: will_continue
})
end
defp average_score([]), do: 0.0
defp average_score(scores), do: Enum.sum(scores) / length(scores)
defp save_state(state, run_dir) do
State.save(state, run_dir)
end
defp load_state(run_dir) do
State.load(run_dir)
end
defp best_score(state) do
state.prog_candidate_val_subscores
|> Enum.map(fn scores ->
if map_size(scores) == 0 do
0.0
else
Enum.sum(Map.values(scores)) / map_size(scores)
end
end)
|> Enum.max(fn -> 0.0 end)
end
defp best_program_idx(state) do
state.prog_candidate_val_subscores
|> Enum.with_index()
|> Enum.max_by(fn {scores, _idx} ->
if map_size(scores) == 0 do
0.0
else
Enum.sum(Map.values(scores)) / map_size(scores)
end
end)
|> elem(1)
end
# Progress tracking helpers
defp maybe_start_progress(%{progress: nil}), do: nil
defp maybe_start_progress(%{progress: true} = config) do
max_calls = extract_max_calls(nil_default(config[:stop_conditions], []))
progress = GEPA.Progress.new(max_calls: max_calls)
GEPA.Progress.start(progress)
progress
end
defp maybe_start_progress(%{progress: opts} = config) when is_list(opts) do
max_calls = extract_max_calls(nil_default(config[:stop_conditions], []))
progress = GEPA.Progress.new([{:max_calls, max_calls} | opts])
GEPA.Progress.start(progress)
progress
end
defp maybe_start_progress(_config), do: nil
defp maybe_update_progress(nil, _state, _accepted?, _proposal_type), do: nil
defp maybe_update_progress(progress, state, accepted?, proposal_type) do
GEPA.Progress.update(progress, %{
iteration: state.i,
best_score: best_score(state),
pareto_size: map_size(state.program_at_pareto_front_valset),
total_evals: state.total_num_evals,
accepted: accepted?,
proposal_type: proposal_type
})
end
defp maybe_finish_progress(nil, _state), do: :ok
defp maybe_finish_progress(progress, state) do
result = GEPA.Result.from_state(state)
GEPA.Progress.finish(progress, result)
end
defp extract_max_calls(stop_conditions) do
Enum.find_value(stop_conditions, fn
%GEPA.StopCondition.MaxCalls{max_calls: max} -> max
%GEPA.StopCondition.Composite{conditions: nested} -> extract_max_calls(nested)
_ -> nil
end)
end
end