use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::time::{Duration, Instant};
use crate::core::data_loader::DataId;
use crate::core::state::GEPAState;
pub trait StopCondition<Id: DataId>: Send + Sync {
fn should_stop(&self, state: &GEPAState<Id>) -> bool;
fn description(&self) -> String;
}
#[derive(Debug, Clone, Copy)]
pub struct MaxMetricCallsStopper {
pub max_calls: usize,
}
impl MaxMetricCallsStopper {
pub fn new(max_calls: usize) -> Self {
Self { max_calls }
}
}
impl<Id: DataId> StopCondition<Id> for MaxMetricCallsStopper {
fn should_stop(&self, state: &GEPAState<Id>) -> bool {
state.total_num_evals >= self.max_calls
}
fn description(&self) -> String {
format!("MaxMetricCalls({})", self.max_calls)
}
}
#[derive(Debug, Clone, Copy)]
pub struct MaxIterationsStopper {
pub max_iterations: usize,
}
impl MaxIterationsStopper {
pub fn new(max_iterations: usize) -> Self {
Self { max_iterations }
}
}
impl<Id: DataId> StopCondition<Id> for MaxIterationsStopper {
fn should_stop(&self, state: &GEPAState<Id>) -> bool {
state.i != crate::core::state::BEFORE_FIRST_ITERATION && state.i >= self.max_iterations
}
fn description(&self) -> String {
format!("MaxIterations({})", self.max_iterations)
}
}
#[derive(Debug)]
pub struct TimeoutStopper {
start: Instant,
duration: Duration,
}
impl TimeoutStopper {
pub fn new(duration: Duration) -> Self {
Self {
start: Instant::now(),
duration,
}
}
pub fn remaining(&self) -> Option<Duration> {
self.duration.checked_sub(self.start.elapsed())
}
}
impl<Id: DataId> StopCondition<Id> for TimeoutStopper {
fn should_stop(&self, _state: &GEPAState<Id>) -> bool {
self.start.elapsed() >= self.duration
}
fn description(&self) -> String {
format!("Timeout({:.1}s)", self.duration.as_secs_f64())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompositeMode {
Any,
All,
}
pub struct CompositeStopper<Id: DataId> {
conditions: Vec<Box<dyn StopCondition<Id>>>,
mode: CompositeMode,
}
impl<Id: DataId> CompositeStopper<Id> {
pub fn new(mode: CompositeMode) -> Self {
Self {
conditions: Vec::new(),
mode,
}
}
pub fn any() -> Self {
Self::new(CompositeMode::Any)
}
pub fn all() -> Self {
Self::new(CompositeMode::All)
}
pub fn push_condition(mut self, condition: impl StopCondition<Id> + 'static) -> Self {
self.conditions.push(Box::new(condition));
self
}
}
impl<Id: DataId> StopCondition<Id> for CompositeStopper<Id> {
fn should_stop(&self, state: &GEPAState<Id>) -> bool {
match self.mode {
CompositeMode::Any => self.conditions.iter().any(|c| c.should_stop(state)),
CompositeMode::All => self.conditions.iter().all(|c| c.should_stop(state)),
}
}
fn description(&self) -> String {
let inner: Vec<String> = self.conditions.iter().map(|c| c.description()).collect();
match self.mode {
CompositeMode::Any => format!("Any({})", inner.join(", ")),
CompositeMode::All => format!("All({})", inner.join(", ")),
}
}
}
pub struct FileStopper {
pub path: std::path::PathBuf,
}
impl FileStopper {
pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
Self { path: path.into() }
}
}
impl<Id: DataId> StopCondition<Id> for FileStopper {
fn should_stop(&self, _state: &GEPAState<Id>) -> bool {
self.path.exists()
}
fn description(&self) -> String {
format!("FileStopper({})", self.path.display())
}
}
pub struct SignalStopper {
pub flag: Arc<AtomicBool>,
}
impl SignalStopper {
pub fn new(flag: Arc<AtomicBool>) -> Self {
Self { flag }
}
pub fn with_flag() -> (Self, Arc<AtomicBool>) {
let flag = Arc::new(AtomicBool::new(false));
let stopper = Self::new(Arc::clone(&flag));
(stopper, flag)
}
pub fn signal(&self) {
self.flag.store(true, AtomicOrdering::SeqCst);
}
pub fn is_set(&self) -> bool {
self.flag.load(AtomicOrdering::SeqCst)
}
}
impl<Id: DataId> StopCondition<Id> for SignalStopper {
fn should_stop(&self, _state: &GEPAState<Id>) -> bool {
self.flag.load(AtomicOrdering::SeqCst)
}
fn description(&self) -> String {
"SignalStopper".to_owned()
}
}
pub struct NoImprovementStopper {
pub patience: usize,
pub min_delta: f64,
best_score_bits: std::sync::atomic::AtomicU64,
no_improve_count: std::sync::atomic::AtomicUsize,
}
impl NoImprovementStopper {
pub fn new(patience: usize, min_delta: f64) -> Self {
Self {
patience,
min_delta,
best_score_bits: std::sync::atomic::AtomicU64::new(f64::NEG_INFINITY.to_bits()),
no_improve_count: std::sync::atomic::AtomicUsize::new(0),
}
}
pub fn current_best(&self) -> f64 {
f64::from_bits(
self.best_score_bits
.load(std::sync::atomic::Ordering::Relaxed),
)
}
pub fn stagnation_count(&self) -> usize {
self.no_improve_count
.load(std::sync::atomic::Ordering::Relaxed)
}
}
impl<Id: DataId> StopCondition<Id> for NoImprovementStopper {
fn should_stop(&self, state: &GEPAState<Id>) -> bool {
let current_best = state
.program_full_scores_val_set()
.into_iter()
.fold(f64::NEG_INFINITY, f64::max);
let prev_best = f64::from_bits(
self.best_score_bits
.load(std::sync::atomic::Ordering::Relaxed),
);
if current_best > prev_best + self.min_delta {
self.best_score_bits
.store(current_best.to_bits(), std::sync::atomic::Ordering::Relaxed);
self.no_improve_count
.store(0, std::sync::atomic::Ordering::Relaxed);
false
} else {
let count = self
.no_improve_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
count >= self.patience
}
}
fn description(&self) -> String {
format!(
"NoImprovementStopper(patience={}, min_delta={})",
self.patience, self.min_delta
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::adapter::Candidate;
use crate::core::state::{BEFORE_FIRST_ITERATION, FrontierType, GEPAState, ValsetEvaluation};
fn make_state_with_evals(num_evals: usize) -> GEPAState<usize> {
let mut seed = Candidate::new();
seed.insert("instructions".into(), "test".into());
let outputs = (0..2).map(|i| serde_json::json!(i)).collect();
let eval = ValsetEvaluation::from_vecs(vec![0usize, 1], outputs, vec![0.5, 0.8], None);
let mut state = GEPAState::new(seed, eval, FrontierType::Instance, None).unwrap();
state.total_num_evals = num_evals;
state
}
#[test]
fn max_metric_calls_fires_when_budget_exhausted() {
let stopper = MaxMetricCallsStopper::new(100);
let state_under = make_state_with_evals(99);
let state_at = make_state_with_evals(100);
let state_over = make_state_with_evals(101);
assert!(!stopper.should_stop(&state_under));
assert!(stopper.should_stop(&state_at));
assert!(stopper.should_stop(&state_over));
}
#[test]
fn max_iterations_stops_after_n_iterations() {
let stopper = MaxIterationsStopper::new(5);
let mut state = make_state_with_evals(0);
assert_eq!(state.i, BEFORE_FIRST_ITERATION);
assert!(!stopper.should_stop(&state));
state.i = 5; assert!(stopper.should_stop(&state));
state.i = 4; assert!(!stopper.should_stop(&state));
}
#[test]
fn timeout_stopper_does_not_fire_immediately() {
let stopper = TimeoutStopper::new(Duration::from_secs(60));
let state = make_state_with_evals(0);
assert!(!stopper.should_stop(&state));
assert!(stopper.remaining().is_some());
}
#[test]
fn timeout_stopper_fires_after_expiry() {
let stopper = TimeoutStopper::new(Duration::from_nanos(1));
std::thread::sleep(Duration::from_millis(1));
let state = make_state_with_evals(0);
assert!(stopper.should_stop(&state));
}
#[test]
fn composite_any_stops_when_one_fires() {
let stopper: CompositeStopper<usize> = CompositeStopper::any()
.push_condition(MaxMetricCallsStopper::new(1000))
.push_condition(MaxMetricCallsStopper::new(10));
let state_under = make_state_with_evals(9);
let state_at = make_state_with_evals(10);
assert!(!stopper.should_stop(&state_under));
assert!(stopper.should_stop(&state_at));
}
#[test]
fn composite_all_requires_all_to_fire() {
let stopper: CompositeStopper<usize> = CompositeStopper::all()
.push_condition(MaxMetricCallsStopper::new(10))
.push_condition(MaxMetricCallsStopper::new(20));
let state_10 = make_state_with_evals(10);
let state_20 = make_state_with_evals(20);
assert!(!stopper.should_stop(&state_10));
assert!(stopper.should_stop(&state_20));
}
#[test]
fn descriptions_are_human_readable() {
let s = MaxMetricCallsStopper::new(200);
assert!(StopCondition::<usize>::description(&s).contains("200"));
let t = TimeoutStopper::new(Duration::from_secs(30));
assert!(StopCondition::<usize>::description(&t).contains("30"));
let c: CompositeStopper<usize> =
CompositeStopper::any().push_condition(MaxMetricCallsStopper::new(100));
assert!(c.description().contains("Any"));
assert!(c.description().contains("100"));
}
#[test]
fn test_timeout_remaining_none_after_expiry() {
let stopper = TimeoutStopper::new(Duration::from_nanos(1));
std::thread::sleep(Duration::from_millis(1));
assert!(
stopper.remaining().is_none(),
"remaining() should return None after the timeout has expired"
);
}
#[test]
fn test_composite_empty_any_returns_false() {
let stopper: CompositeStopper<usize> = CompositeStopper::any();
let state = make_state_with_evals(9999);
assert!(
!stopper.should_stop(&state),
"empty any() composite must return false (no condition can fire)"
);
}
#[test]
fn test_composite_empty_all_returns_true() {
let stopper: CompositeStopper<usize> = CompositeStopper::all();
let state = make_state_with_evals(0);
assert!(
stopper.should_stop(&state),
"empty all() composite must return true (vacuous conjunction)"
);
}
#[test]
fn file_stopper_does_not_fire_without_file() {
let stopper = FileStopper::new("/tmp/gepa_test_sentinel_NONEXISTENT_XYZ");
let state = make_state_with_evals(0);
assert!(
!stopper.should_stop(&state),
"FileStopper should not fire when the file does not exist"
);
}
#[test]
fn file_stopper_fires_when_file_exists() {
let tmp = std::env::temp_dir().join("gepa_test_file_stopper_sentinel");
std::fs::write(&tmp, "stop").expect("write sentinel");
let stopper = FileStopper::new(&tmp);
let state = make_state_with_evals(0);
let fires = stopper.should_stop(&state);
std::fs::remove_file(&tmp).ok();
assert!(
fires,
"FileStopper should fire when the sentinel file exists"
);
}
#[test]
fn file_stopper_description_contains_path() {
let stopper = FileStopper::new("/tmp/sentinel");
let desc = StopCondition::<usize>::description(&stopper);
assert!(
desc.contains("sentinel"),
"description should contain the path"
);
}
#[test]
fn signal_stopper_does_not_fire_before_signal() {
let (stopper, _flag) = SignalStopper::with_flag();
let state = make_state_with_evals(0);
assert!(
!stopper.should_stop(&state),
"SignalStopper should not fire before the flag is set"
);
}
#[test]
fn signal_stopper_fires_after_signal() {
let (stopper, flag) = SignalStopper::with_flag();
let state = make_state_with_evals(0);
flag.store(true, AtomicOrdering::SeqCst);
assert!(
stopper.should_stop(&state),
"SignalStopper should fire after the flag is set"
);
}
#[test]
fn signal_stopper_signal_method_triggers_stop() {
let (stopper, _flag) = SignalStopper::with_flag();
assert!(!stopper.is_set());
stopper.signal();
assert!(stopper.is_set());
let state = make_state_with_evals(0);
assert!(stopper.should_stop(&state));
}
#[test]
fn no_improvement_stopper_does_not_fire_on_first_call() {
let stopper = NoImprovementStopper::new(3, 0.0);
let state = make_state_with_evals(0); assert!(
!stopper.should_stop(&state),
"NoImprovementStopper must not fire on the first call"
);
assert_eq!(stopper.stagnation_count(), 0);
}
#[test]
fn no_improvement_stopper_fires_after_patience_exceeded() {
let stopper = NoImprovementStopper::new(3, 0.0);
let state = make_state_with_evals(0);
assert!(!stopper.should_stop(&state));
assert!(!stopper.should_stop(&state)); assert!(!stopper.should_stop(&state)); assert!(stopper.should_stop(&state)); }
#[test]
fn no_improvement_stopper_resets_on_improvement() {
let stopper = NoImprovementStopper::new(2, 0.0);
let state_low = make_state_with_evals(0);
assert!(!stopper.should_stop(&state_low)); assert!(!stopper.should_stop(&state_low));
let mut state_high = make_state_with_evals(0);
let better_eval = crate::core::state::ValsetEvaluation::from_vecs(
vec![0usize, 1],
vec![serde_json::json!("a"), serde_json::json!("b")],
vec![0.9, 1.0],
None,
);
let mut better_cand = Candidate::new();
better_cand.insert("instructions".into(), "better".into());
state_high
.update_state_with_new_program(vec![0], better_cand, better_eval, 2)
.expect("update");
assert!(!stopper.should_stop(&state_high));
assert_eq!(stopper.stagnation_count(), 0);
}
}