solverforge 0.8.6

SolverForge - A constraint solver in Rust
Documentation
use std::fs;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Mutex, OnceLock};

use solverforge::prelude::*;
use solverforge::{SolverConfig, SolverEvent, SolverManager, SolverTerminalReason};

static LAST_CONFIG_SECONDS: AtomicU64 = AtomicU64::new(0);
static LAST_BASE_RANDOM_SEED: AtomicU64 = AtomicU64::new(0);
static LAST_BASE_PHASE_COUNT: AtomicUsize = AtomicUsize::new(0);
static LAST_FINAL_RANDOM_SEED: AtomicU64 = AtomicU64::new(0);
static LAST_FINAL_PHASE_COUNT: AtomicUsize = AtomicUsize::new(0);
static LAST_FINAL_TERMINATION_SECONDS: AtomicU64 = AtomicU64::new(0);
static LAST_EXPLICIT_BASE_RANDOM_SEED: AtomicU64 = AtomicU64::new(0);
static LAST_EXPLICIT_BASE_PHASE_COUNT: AtomicUsize = AtomicUsize::new(0);
static LAST_EXPLICIT_FINAL_RANDOM_SEED: AtomicU64 = AtomicU64::new(0);
static LAST_EXPLICIT_FINAL_PHASE_COUNT: AtomicUsize = AtomicUsize::new(0);
static LAST_EXPLICIT_FINAL_TERMINATION_SECONDS: AtomicU64 = AtomicU64::new(0);

#[planning_entity]
struct DummyEntity {
    #[planning_id]
    id: usize,
}

#[problem_fact]
struct DummyVisit {
    #[planning_id]
    id: usize,
}

#[planning_entity]
struct DummyRoute {
    #[planning_id]
    id: usize,

    #[planning_list_variable(element_collection = "visits")]
    visits: Vec<usize>,
}

#[planning_solution(
    constraints = "define_constraints",
    config = "solver_config_for_solution"
)]
struct ConfigurableSolution {
    #[planning_entity_collection]
    entities: Vec<DummyEntity>,

    #[planning_score]
    score: Option<HardSoftScore>,

    time_limit_secs: u64,
}

#[planning_solution(
    constraints = "define_explicit_constraints",
    config = "solver_config_for_explicit_solution",
    solver_toml = "fixtures/configurable_solvable_solver.toml"
)]
struct ExplicitConfigurableSolution {
    #[planning_entity_collection]
    entities: Vec<DummyEntity>,

    #[planning_score]
    score: Option<HardSoftScore>,

    time_limit_secs: u64,
}

#[planning_solution(
    constraints = "define_explicit_list_constraints",
    config = "solver_config_for_explicit_list_solution",
    solver_toml = "fixtures/configurable_solvable_solver.toml"
)]
struct ExplicitListConfigurableSolution {
    #[problem_fact_collection]
    visits: Vec<DummyVisit>,

    #[planning_entity_collection]
    routes: Vec<DummyRoute>,

    #[planning_score]
    score: Option<HardSoftScore>,

    time_limit_secs: u64,
}

fn define_constraints() -> impl ConstraintSet<ConfigurableSolution, HardSoftScore> {
    (
        ConstraintFactory::<ConfigurableSolution, HardSoftScore>::new()
            .entities()
            .penalize_with(|_| HardSoftScore::of(0, 0))
            .named("noop"),
    )
}

fn define_explicit_constraints() -> impl ConstraintSet<ExplicitConfigurableSolution, HardSoftScore>
{
    (
        ConstraintFactory::<ExplicitConfigurableSolution, HardSoftScore>::new()
            .entities()
            .penalize_with(|_| HardSoftScore::of(0, 0))
            .named("noop"),
    )
}

fn define_explicit_list_constraints(
) -> impl ConstraintSet<ExplicitListConfigurableSolution, HardSoftScore> {
}

fn solver_config_for_solution(
    solution: &ConfigurableSolution,
    config: SolverConfig,
) -> SolverConfig {
    LAST_CONFIG_SECONDS.store(solution.time_limit_secs, Ordering::SeqCst);
    LAST_BASE_RANDOM_SEED.store(config.random_seed.unwrap_or_default(), Ordering::SeqCst);
    LAST_BASE_PHASE_COUNT.store(config.phases.len(), Ordering::SeqCst);

    let config = config.with_termination_seconds(solution.time_limit_secs);

    LAST_FINAL_RANDOM_SEED.store(config.random_seed.unwrap_or_default(), Ordering::SeqCst);
    LAST_FINAL_PHASE_COUNT.store(config.phases.len(), Ordering::SeqCst);
    LAST_FINAL_TERMINATION_SECONDS.store(
        config
            .time_limit()
            .map(|duration| duration.as_secs())
            .unwrap_or(0),
        Ordering::SeqCst,
    );

    config
}

fn solver_config_for_explicit_solution(
    solution: &ExplicitConfigurableSolution,
    config: SolverConfig,
) -> SolverConfig {
    LAST_EXPLICIT_BASE_RANDOM_SEED.store(config.random_seed.unwrap_or_default(), Ordering::SeqCst);
    LAST_EXPLICIT_BASE_PHASE_COUNT.store(config.phases.len(), Ordering::SeqCst);

    let config = config.with_termination_seconds(solution.time_limit_secs);

    LAST_EXPLICIT_FINAL_RANDOM_SEED.store(config.random_seed.unwrap_or_default(), Ordering::SeqCst);
    LAST_EXPLICIT_FINAL_PHASE_COUNT.store(config.phases.len(), Ordering::SeqCst);
    LAST_EXPLICIT_FINAL_TERMINATION_SECONDS.store(
        config
            .time_limit()
            .map(|duration| duration.as_secs())
            .unwrap_or(0),
        Ordering::SeqCst,
    );

    config
}

fn solver_config_for_explicit_list_solution(
    solution: &ExplicitListConfigurableSolution,
    config: SolverConfig,
) -> SolverConfig {
    config.with_termination_seconds(solution.time_limit_secs)
}

fn cwd_test_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

struct TempSolverConfigDir {
    original_dir: std::path::PathBuf,
    temp_dir: std::path::PathBuf,
}

impl TempSolverConfigDir {
    fn new(contents: Option<&str>) -> Self {
        static COUNTER: AtomicU64 = AtomicU64::new(0);

        let original_dir = std::env::current_dir().expect("current directory should be readable");
        let suffix = COUNTER.fetch_add(1, Ordering::SeqCst);
        let temp_dir = std::env::temp_dir().join(format!(
            "solverforge-configurable-solution-{}-{suffix}",
            std::process::id()
        ));

        let _ = fs::remove_dir_all(&temp_dir);
        fs::create_dir_all(&temp_dir).expect("temp solver directory should be created");
        if let Some(contents) = contents {
            fs::write(temp_dir.join("solver.toml"), contents)
                .expect("solver.toml should be written");
        }
        std::env::set_current_dir(&temp_dir).expect("current directory should switch to temp");

        Self {
            original_dir,
            temp_dir,
        }
    }
}

impl Drop for TempSolverConfigDir {
    fn drop(&mut self) {
        std::env::set_current_dir(&self.original_dir)
            .expect("current directory should restore after test");
        let _ = fs::remove_dir_all(&self.temp_dir);
    }
}

#[test]
fn planning_solution_config_provider_decorates_solver_toml_for_retained_runtime_solves() {
    static MANAGER: SolverManager<ConfigurableSolution> = SolverManager::new();

    let _cwd_lock = cwd_test_lock().lock().expect("cwd lock should be acquired");
    let _temp_solver_dir = TempSolverConfigDir::new(Some(
        r#"
random_seed = 19

[[phases]]
type = "construction_heuristic"
construction_heuristic_type = "first_fit"
"#,
    ));

    let (job_id, mut receiver) = MANAGER
        .solve(ConfigurableSolution {
            entities: Vec::new(),
            score: None,
            time_limit_secs: 7,
        })
        .expect("job should start");

    let mut completed = false;

    while let Some(event) = receiver.blocking_recv() {
        match event {
            SolverEvent::BestSolution { .. } => {}
            SolverEvent::Completed { metadata, solution } => {
                assert_eq!(
                    metadata.terminal_reason,
                    Some(SolverTerminalReason::Completed)
                );
                assert_eq!(solution.score, Some(HardSoftScore::of(0, 0)));
                completed = true;
                break;
            }
            other => panic!("unexpected event: {other:?}"),
        }
    }

    assert!(completed, "expected a completed event");
    assert_eq!(LAST_CONFIG_SECONDS.load(Ordering::SeqCst), 7);
    assert_eq!(LAST_BASE_RANDOM_SEED.load(Ordering::SeqCst), 19);
    assert_eq!(LAST_BASE_PHASE_COUNT.load(Ordering::SeqCst), 1);
    assert_eq!(LAST_FINAL_RANDOM_SEED.load(Ordering::SeqCst), 19);
    assert_eq!(LAST_FINAL_PHASE_COUNT.load(Ordering::SeqCst), 1);
    assert_eq!(LAST_FINAL_TERMINATION_SECONDS.load(Ordering::SeqCst), 7);

    MANAGER.delete(job_id).expect("delete completed job");
}

#[test]
fn planning_solution_solver_toml_path_is_independent_of_cwd() {
    static MANAGER: SolverManager<ExplicitConfigurableSolution> = SolverManager::new();

    let _cwd_lock = cwd_test_lock().lock().expect("cwd lock should be acquired");
    let _temp_solver_dir = TempSolverConfigDir::new(None);

    let (job_id, mut receiver) = MANAGER
        .solve(ExplicitConfigurableSolution {
            entities: Vec::new(),
            score: None,
            time_limit_secs: 11,
        })
        .expect("job should start");

    let mut completed = false;

    while let Some(event) = receiver.blocking_recv() {
        match event {
            SolverEvent::BestSolution { .. } => {}
            SolverEvent::Completed { metadata, solution } => {
                assert_eq!(
                    metadata.terminal_reason,
                    Some(SolverTerminalReason::Completed)
                );
                assert_eq!(solution.score, Some(HardSoftScore::of(0, 0)));
                completed = true;
                break;
            }
            other => panic!("unexpected event: {other:?}"),
        }
    }

    assert!(completed, "expected a completed event");
    assert_eq!(LAST_EXPLICIT_BASE_RANDOM_SEED.load(Ordering::SeqCst), 23);
    assert_eq!(LAST_EXPLICIT_BASE_PHASE_COUNT.load(Ordering::SeqCst), 1);
    assert_eq!(LAST_EXPLICIT_FINAL_RANDOM_SEED.load(Ordering::SeqCst), 23);
    assert_eq!(LAST_EXPLICIT_FINAL_PHASE_COUNT.load(Ordering::SeqCst), 1);
    assert_eq!(
        LAST_EXPLICIT_FINAL_TERMINATION_SECONDS.load(Ordering::SeqCst),
        11
    );

    MANAGER.delete(job_id).expect("delete completed job");
}

#[test]
fn planning_solution_solver_toml_path_compiles_and_runs_for_list_only_solutions() {
    static MANAGER: SolverManager<ExplicitListConfigurableSolution> = SolverManager::new();

    let _cwd_lock = cwd_test_lock().lock().expect("cwd lock should be acquired");
    let _temp_solver_dir = TempSolverConfigDir::new(None);

    let (job_id, mut receiver) = MANAGER
        .solve(ExplicitListConfigurableSolution {
            visits: Vec::new(),
            routes: Vec::new(),
            score: None,
            time_limit_secs: 3,
        })
        .expect("job should start");

    let mut completed = false;

    while let Some(event) = receiver.blocking_recv() {
        match event {
            SolverEvent::BestSolution { .. } => {}
            SolverEvent::Completed { metadata, solution } => {
                assert_eq!(
                    metadata.terminal_reason,
                    Some(SolverTerminalReason::Completed)
                );
                assert_eq!(solution.score, Some(HardSoftScore::of(0, 0)));
                completed = true;
                break;
            }
            other => panic!("unexpected event: {other:?}"),
        }
    }

    assert!(completed, "expected a completed event");

    MANAGER.delete(job_id).expect("delete completed job");
}