solverforge-config 0.11.1

Configuration system for SolverForge constraint solver
Documentation
use super::*;

#[test]
fn test_conflict_repair_selector_roundtrip() {
    let toml = r#"
        [[phases]]
        type = "local_search"

        [phases.move_selector]
        type = "conflict_repair_move_selector"
        constraints = ["minimumRest", "furnaceOverlap"]
        max_matches_per_step = 4
        max_repairs_per_match = 8
        max_moves_per_step = 32
        include_soft_matches = true
    "#;

    let config = SolverConfig::from_toml_str(toml).unwrap();
    let encoded = toml::to_string(&config).unwrap();
    let reparsed = SolverConfig::from_toml_str(&encoded).unwrap();
    let PhaseConfig::LocalSearch(local_search) = &reparsed.phases[0] else {
        panic!("phase should be local_search");
    };
    let Some(MoveSelectorConfig::ConflictRepairMoveSelector(selector)) =
        &local_search.move_selector
    else {
        panic!("local search should have conflict repair selector");
    };

    assert_eq!(selector.constraints, ["minimumRest", "furnaceOverlap"]);
    assert_eq!(selector.max_matches_per_step, 4);
    assert_eq!(selector.max_repairs_per_match, 8);
    assert_eq!(selector.max_moves_per_step, 32);
    assert!(!selector.require_hard_improvement);
    assert!(selector.include_soft_matches);
}

#[test]
fn test_compound_conflict_repair_selector_roundtrip() {
    let toml = r#"
        [[phases]]
        type = "local_search"

        [phases.move_selector]
        type = "compound_conflict_repair_move_selector"
        constraints = ["minimumRest"]
        max_matches_per_step = 2
        max_repairs_per_match = 3
        max_moves_per_step = 4
        include_soft_matches = true
    "#;

    let config = SolverConfig::from_toml_str(toml).unwrap();
    let encoded = toml::to_string(&config).unwrap();
    let reparsed = SolverConfig::from_toml_str(&encoded).unwrap();
    let PhaseConfig::LocalSearch(local_search) = &reparsed.phases[0] else {
        panic!("phase should be local_search");
    };
    let Some(MoveSelectorConfig::CompoundConflictRepairMoveSelector(selector)) =
        &local_search.move_selector
    else {
        panic!("local search should have compound conflict repair selector");
    };

    assert_eq!(selector.constraints, ["minimumRest"]);
    assert_eq!(selector.max_matches_per_step, 2);
    assert_eq!(selector.max_repairs_per_match, 3);
    assert_eq!(selector.max_moves_per_step, 4);
    assert!(selector.require_hard_improvement);
    assert!(selector.include_soft_matches);
}

#[test]
fn test_new_scalar_selector_variants_and_cartesian_roundtrip() {
    let toml = r#"
        [[phases]]
        type = "local_search"

        [phases.move_selector]
        type = "union_move_selector"

        [[phases.move_selector.selectors]]
        type = "nearby_change_move_selector"
        max_nearby = 7
        value_candidate_limit = 3
        entity_class = "Shift"
        variable_name = "employee_id"

        [[phases.move_selector.selectors]]
        type = "nearby_swap_move_selector"
        max_nearby = 5
        entity_class = "Shift"
        variable_name = "employee_id"

        [[phases.move_selector.selectors]]
        type = "pillar_change_move_selector"
        minimum_sub_pillar_size = 2
        maximum_sub_pillar_size = 4
        entity_class = "Shift"
        variable_name = "employee_id"

        [[phases.move_selector.selectors]]
        type = "pillar_swap_move_selector"
        minimum_sub_pillar_size = 2
        maximum_sub_pillar_size = 3
        entity_class = "Shift"
        variable_name = "employee_id"

        [[phases.move_selector.selectors]]
        type = "ruin_recreate_move_selector"
        min_ruin_count = 2
        max_ruin_count = 6
        moves_per_step = 9
        recreate_heuristic_type = "first_fit"
        entity_class = "Shift"
        variable_name = "employee_id"

        [[phases.move_selector.selectors]]
        type = "cartesian_product_move_selector"
        require_hard_improvement = true

        [[phases.move_selector.selectors.selectors]]
        type = "change_move_selector"
        entity_class = "Shift"
        variable_name = "employee_id"

        [[phases.move_selector.selectors.selectors]]
        type = "list_change_move_selector"
        entity_class = "Route"
        variable_name = "visits"
    "#;

    let config = SolverConfig::from_toml_str(toml).unwrap();
    let PhaseConfig::LocalSearch(local_search) = &config.phases[0] else {
        panic!("phase should be local_search");
    };
    let Some(MoveSelectorConfig::UnionMoveSelector(union)) = &local_search.move_selector else {
        panic!("local search should have a union move selector");
    };
    assert_eq!(union.selectors.len(), 6);

    let MoveSelectorConfig::NearbyChangeMoveSelector(nearby_change) = &union.selectors[0] else {
        panic!("first selector should be nearby_change");
    };
    assert_eq!(nearby_change.max_nearby, 7);
    assert_eq!(nearby_change.value_candidate_limit, Some(3));

    let MoveSelectorConfig::NearbySwapMoveSelector(nearby_swap) = &union.selectors[1] else {
        panic!("second selector should be nearby_swap");
    };
    assert_eq!(nearby_swap.max_nearby, 5);

    let MoveSelectorConfig::PillarChangeMoveSelector(pillar_change) = &union.selectors[2] else {
        panic!("third selector should be pillar_change");
    };
    assert_eq!(pillar_change.minimum_sub_pillar_size, 2);
    assert_eq!(pillar_change.maximum_sub_pillar_size, 4);

    let MoveSelectorConfig::PillarSwapMoveSelector(pillar_swap) = &union.selectors[3] else {
        panic!("fourth selector should be pillar_swap");
    };
    assert_eq!(pillar_swap.minimum_sub_pillar_size, 2);
    assert_eq!(pillar_swap.maximum_sub_pillar_size, 3);

    let MoveSelectorConfig::RuinRecreateMoveSelector(ruin_recreate) = &union.selectors[4] else {
        panic!("fifth selector should be ruin_recreate");
    };
    assert_eq!(ruin_recreate.min_ruin_count, 2);
    assert_eq!(ruin_recreate.max_ruin_count, 6);
    assert_eq!(ruin_recreate.moves_per_step, Some(9));
    assert_eq!(
        ruin_recreate.recreate_heuristic_type,
        RecreateHeuristicType::FirstFit
    );

    let MoveSelectorConfig::CartesianProductMoveSelector(cartesian) = &union.selectors[5] else {
        panic!("sixth selector should be cartesian_product");
    };
    assert!(cartesian.require_hard_improvement);
    assert_eq!(cartesian.selectors.len(), 2);
}

#[test]
fn test_tagged_forager_variants_roundtrip() {
    let toml = r#"
        [[phases]]
        type = "local_search"

        [phases.forager]
        type = "accepted_count"
        limit = 9
    "#;

    let config = SolverConfig::from_toml_str(toml).unwrap();
    let PhaseConfig::LocalSearch(local_search) = &config.phases[0] else {
        panic!("phase should be local_search");
    };
    let Some(ForagerConfig::AcceptedCount(accepted_count)) = &local_search.forager else {
        panic!("forager should be accepted_count");
    };
    assert_eq!(accepted_count.limit, Some(9));

    let improving_toml = r#"
        [[phases]]
        type = "local_search"

        [phases.forager]
        type = "first_best_score_improving"
    "#;

    let config = SolverConfig::from_toml_str(improving_toml).unwrap();
    let PhaseConfig::LocalSearch(local_search) = &config.phases[0] else {
        panic!("phase should be local_search");
    };
    assert!(matches!(
        local_search.forager,
        Some(ForagerConfig::FirstBestScoreImproving)
    ));
}