solverforge-solver 0.9.0

Solver engine for SolverForge
Documentation
use solverforge_core::domain::PlanningSolution;
use solverforge_core::score::SoftScore;
use std::any::Any;

use super::*;
use crate::heuristic::r#move::{metadata::MoveTabuScope, MoveTabuSignature};

#[derive(Clone, Debug)]
struct DummySolution {
    score: Option<SoftScore>,
}

impl PlanningSolution for DummySolution {
    type Score = SoftScore;

    fn score(&self) -> Option<Self::Score> {
        self.score
    }

    fn set_score(&mut self, score: Option<Self::Score>) {
        self.score = score;
    }
}

fn signature(
    descriptor_index: usize,
    variable_name: &'static str,
    entity_ids: &[u64],
    destination_value_ids: &[u64],
    move_id: &[u64],
    undo_move_id: &[u64],
) -> MoveTabuSignature {
    let scope = MoveTabuScope::new(descriptor_index, variable_name);
    MoveTabuSignature::new(
        scope,
        move_id.iter().copied().collect(),
        undo_move_id.iter().copied().collect(),
    )
    .with_entity_tokens(
        entity_ids
            .iter()
            .copied()
            .map(|entity_id| scope.entity_token(entity_id)),
    )
    .with_destination_value_tokens(
        destination_value_ids
            .iter()
            .copied()
            .map(|value_id| scope.value_token(value_id)),
    )
}

fn policy(
    entity_tabu_size: Option<usize>,
    value_tabu_size: Option<usize>,
    move_tabu_size: Option<usize>,
    undo_move_tabu_size: Option<usize>,
    aspiration_enabled: bool,
) -> TabuSearchPolicy {
    TabuSearchPolicy {
        entity_tabu_size,
        value_tabu_size,
        move_tabu_size,
        undo_move_tabu_size,
        aspiration_enabled,
    }
}

fn panic_message(payload: Box<dyn Any + Send>) -> String {
    match payload.downcast::<String>() {
        Ok(message) => *message,
        Err(payload) => match payload.downcast::<&'static str>() {
            Ok(message) => (*message).to_string(),
            Err(_) => "<non-string panic>".to_string(),
        },
    }
}

#[test]
fn hill_climbing_accepts_only_improving_moves() {
    let mut acceptor = HillClimbingAcceptor::new();

    assert!(
        <HillClimbingAcceptor as Acceptor<DummySolution>>::is_accepted(
            &mut acceptor,
            &SoftScore::of(-10),
            &SoftScore::of(-5),
            None,
        )
    );
    assert!(
        !<HillClimbingAcceptor as Acceptor<DummySolution>>::is_accepted(
            &mut acceptor,
            &SoftScore::of(-5),
            &SoftScore::of(-10),
            None,
        )
    );
    assert!(
        !<HillClimbingAcceptor as Acceptor<DummySolution>>::is_accepted(
            &mut acceptor,
            &SoftScore::of(-5),
            &SoftScore::of(-5),
            None,
        )
    );
}

#[test]
fn late_acceptance_uses_late_score_threshold() {
    let mut acceptor = LateAcceptanceAcceptor::<DummySolution>::new(5);
    acceptor.phase_started(&SoftScore::of(-10));

    assert!(acceptor.is_accepted(&SoftScore::of(-10), &SoftScore::of(-5), None));
    assert!(acceptor.is_accepted(&SoftScore::of(-10), &SoftScore::of(-10), None));
    assert!(!acceptor.is_accepted(&SoftScore::of(-10), &SoftScore::of(-15), None));
}

#[test]
fn tabu_search_blocks_recent_entities_and_allows_aspiration() {
    let mut acceptor =
        TabuSearchAcceptor::<DummySolution>::new(policy(Some(3), None, None, None, true));
    let first = signature(0, "worker", &[7], &[], &[10], &[11]);
    let second = signature(0, "worker", &[7], &[], &[12], &[13]);

    acceptor.phase_started(&SoftScore::of(-10));
    acceptor.step_ended(&SoftScore::of(-9), Some(&first));

    assert!(!acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-9), Some(&second),));

    assert!(acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-5), Some(&second),));
}

#[test]
fn tabu_search_blocks_recent_values() {
    let mut acceptor =
        TabuSearchAcceptor::<DummySolution>::new(policy(None, Some(2), None, None, false));
    let first = signature(0, "worker", &[], &[42], &[10], &[11]);
    let second = signature(0, "worker", &[], &[42], &[12], &[13]);

    acceptor.phase_started(&SoftScore::of(-10));
    acceptor.step_ended(&SoftScore::of(-9), Some(&first));

    assert!(!acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-8), Some(&second),));
}

#[test]
fn tabu_search_blocks_exact_move_and_undo_move() {
    let mut acceptor =
        TabuSearchAcceptor::<DummySolution>::new(policy(None, None, Some(2), Some(2), false));
    let committed = signature(0, "worker", &[], &[], &[10, 20], &[30, 40]);
    let exact_repeat = signature(0, "worker", &[], &[], &[10, 20], &[99]);
    let undo_repeat = signature(0, "worker", &[], &[], &[30, 40], &[10, 20]);

    acceptor.phase_started(&SoftScore::of(-10));
    acceptor.step_ended(&SoftScore::of(-9), Some(&committed));

    assert!(!acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-8), Some(&exact_repeat),));
    assert!(!acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-8), Some(&undo_repeat),));
}

#[test]
fn tabu_search_undo_memory_matches_candidate_move_identity() {
    let mut acceptor =
        TabuSearchAcceptor::<DummySolution>::new(policy(None, None, None, Some(2), false));
    let committed = signature(0, "worker", &[], &[], &[10], &[20]);
    let reverse = signature(0, "worker", &[], &[], &[20], &[10]);
    let unrelated_with_same_undo_id = signature(0, "worker", &[], &[], &[30], &[20]);

    acceptor.phase_started(&SoftScore::of(-10));
    acceptor.step_ended(&SoftScore::of(-9), Some(&committed));

    assert!(!acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-8), Some(&reverse)));
    assert!(acceptor.is_accepted(
        &SoftScore::of(-9),
        &SoftScore::of(-8),
        Some(&unrelated_with_same_undo_id)
    ));
}

#[test]
fn tabu_search_requires_signatures() {
    let mut acceptor =
        TabuSearchAcceptor::<DummySolution>::new(policy(Some(1), None, None, None, true));
    acceptor.phase_started(&SoftScore::of(-10));

    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        acceptor.is_accepted(&SoftScore::of(-10), &SoftScore::of(-9), None)
    }));

    assert!(result.is_err());
    assert!(acceptor.requires_move_signatures());
}

#[test]
fn tabu_search_clears_memories_on_phase_end() {
    let mut acceptor =
        TabuSearchAcceptor::<DummySolution>::new(policy(Some(1), Some(1), Some(1), Some(1), false));
    let sig = signature(0, "worker", &[1], &[2], &[3], &[4]);

    acceptor.phase_started(&SoftScore::of(-10));
    acceptor.step_ended(&SoftScore::of(-9), Some(&sig));
    assert!(!acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-8), Some(&sig)));

    acceptor.phase_ended();
    acceptor.phase_started(&SoftScore::of(-10));

    assert!(acceptor.is_accepted(&SoftScore::of(-10), &SoftScore::of(-9), Some(&sig)));
}

#[test]
fn tabu_search_move_only_policy_blocks_recent_exact_move() {
    let mut acceptor = TabuSearchAcceptor::<DummySolution>::new(TabuSearchPolicy::move_only(10));
    let committed = signature(0, "worker", &[], &[], &[10, 20], &[30, 40]);
    let repeated = signature(0, "worker", &[], &[], &[10, 20], &[99]);

    acceptor.phase_started(&SoftScore::of(-10));
    acceptor.step_ended(&SoftScore::of(-9), Some(&committed));

    assert!(!acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-9), Some(&repeated)));
}

#[test]
fn tabu_search_rejects_zero_tenures_at_acceptor_boundary() {
    for (field_name, policy) in [
        ("entity_tabu_size", policy(Some(0), None, None, None, true)),
        ("value_tabu_size", policy(None, Some(0), None, None, true)),
        ("move_tabu_size", policy(None, None, Some(0), None, true)),
        (
            "undo_move_tabu_size",
            policy(None, None, None, Some(0), true),
        ),
    ] {
        let result = std::panic::catch_unwind(|| {
            let _ = TabuSearchAcceptor::<DummySolution>::new(policy);
        });
        let message = panic_message(result.expect_err("zero tabu size must panic"));
        assert_eq!(
            message,
            format!("tabu_search field `{field_name}` must be greater than 0")
        );
    }
}

#[test]
fn tabu_search_scoped_tokens_do_not_collide_across_variables() {
    let mut acceptor =
        TabuSearchAcceptor::<DummySolution>::new(policy(Some(2), Some(2), None, None, false));
    let first = signature(0, "worker_a", &[7], &[42], &[10], &[11]);
    let second = signature(0, "worker_b", &[7], &[42], &[12], &[13]);

    acceptor.phase_started(&SoftScore::of(-10));
    acceptor.step_ended(&SoftScore::of(-9), Some(&first));

    assert!(acceptor.is_accepted(&SoftScore::of(-9), &SoftScore::of(-8), Some(&second)));
}