solverforge-solver 0.20.2

Solver engine for SolverForge
Documentation
use super::*;
use solverforge_scoring::Director;

struct OptionalDirector {
    plan: AssignmentPlan,
    descriptor: SolutionDescriptor,
}

impl Director<AssignmentPlan> for OptionalDirector {
    fn working_solution(&self) -> &AssignmentPlan {
        &self.plan
    }
    fn working_solution_mut(&mut self) -> &mut AssignmentPlan {
        &mut self.plan
    }
    fn calculate_score(&mut self) -> SoftScore {
        let score = SoftScore::of(
            (if self.plan.assignments[0].is_some() {
                -10
            } else {
                0
            }) + (if self.plan.assignments[1].is_some() {
                1
            } else {
                0
            }),
        );
        self.plan.set_score(Some(score));
        score
    }
    fn solution_descriptor(&self) -> &SolutionDescriptor {
        &self.descriptor
    }
    fn clone_working_solution(&self) -> AssignmentPlan {
        self.plan.clone()
    }
    fn before_variable_changed(&mut self, _: usize, _: usize) {}
    fn after_variable_changed(&mut self, _: usize, _: usize) {}
    fn entity_count(&self, index: usize) -> Option<usize> {
        (index == 0).then_some(self.plan.assignments.len())
    }
    fn total_entity_count(&self) -> Option<usize> {
        Some(self.plan.assignments.len())
    }
    fn constraint_metadata(&self) -> Vec<solverforge_scoring::ConstraintMetadata<'_>> {
        Vec::new()
    }
}

fn optional_group(
    descriptor: &SolutionDescriptor,
    limits: ScalarGroupLimits,
    candidate_values: TestCandidateValues,
) -> ScalarGroupBinding<AssignmentPlan> {
    let slot = ScalarVariableSlot::new(
        0,
        0,
        "Task",
        entity_count,
        "worker",
        current_value,
        set_value,
        ValueSource::EntitySlice {
            values_for_entity: candidate_values,
        },
        true,
    )
    .with_candidate_values(candidate_values);
    let groups = bind_scalar_groups(
        vec![ScalarGroup::assignment(
            "worker_assignment",
            ScalarTarget::from_descriptor_index(0, "worker"),
        )
        .with_capacity_key(capacity_key)
        .with_limits(limits)],
        &[slot],
    );
    RuntimeModel::<
        AssignmentPlan,
        usize,
        DefaultCrossEntityDistanceMeter,
        DefaultCrossEntityDistanceMeter,
    >::new(vec![VariableSlot::Scalar(slot)])
    .with_scalar_groups(groups)
    .resolve_dynamic_descriptor_indexes(descriptor)
    .expect("optional model must resolve")
    .scalar_groups()[0]
        .clone()
}

#[test]
fn completed_optional_row_does_not_starve_later_row_at_candidate_limit() {
    assert_later_optional_row_reached(Some(2));
}

#[test]
fn completed_optional_row_does_not_end_uncapped_construction() {
    assert_later_optional_row_reached(None);
}

fn assert_later_optional_row_reached(group_candidate_limit: Option<usize>) {
    let descriptor = descriptor();
    let group = optional_group(&descriptor, ScalarGroupLimits::new(), candidates);
    let bindings = collect_bindings(&descriptor)
        .into_iter()
        .map(ResolvedVariableBinding::new)
        .collect();
    let input = AssignmentPlan {
        score: None,
        assignments: vec![None, None],
        candidates: vec![vec![0, 1], vec![2, 3]],
    };
    let director = OptionalDirector {
        plan: input,
        descriptor,
    };
    let mut scope = SolverScope::new(director);
    let config = ConstructionHeuristicConfig {
        construction_heuristic_type: ConstructionHeuristicType::CheapestInsertion,
        construction_obligation: ConstructionObligation::PreserveUnassigned,
        group_candidate_limit,
        ..ConstructionHeuristicConfig::default()
    };
    let mut phase = build_scalar_group_construction(Some(&config), 0, group, bindings, false);

    phase.solve(&mut scope);

    assert_eq!(
        scope.working_solution().assignments,
        vec![None, Some(2)],
        "keeping an earlier optional row must not exhaust the next placement's candidate budget"
    );
}

static COMPLETED_ROW_READS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);

fn tracked_optional_candidates(plan: &AssignmentPlan, entity: usize, _: usize) -> &[usize] {
    if entity < plan.assignments.len() - 1 {
        COMPLETED_ROW_READS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
    }
    &plan.candidates[entity]
}

#[test]
fn completed_optional_prefix_is_not_regenerated() {
    use crate::heuristic::selector::move_selector::MoveCursor;
    use crate::phase::construction::{EntityPlacer, EntityPlacerCursor};

    COMPLETED_ROW_READS.store(0, std::sync::atomic::Ordering::SeqCst);
    let descriptor = descriptor();
    let limits = ScalarGroupLimits::new();
    let group = optional_group(&descriptor, limits, tracked_optional_candidates);
    let plan = AssignmentPlan {
        score: None,
        assignments: vec![None; 64],
        candidates: (0..64)
            .map(|row| (row * 32..(row + 1) * 32).collect())
            .collect(),
    };
    let director = ScoreDirector::simple(plan, descriptor, |plan, _| plan.assignments.len());
    let placer = super::super::placer::ScalarGroupPlacer::new(
        0,
        group,
        Vec::new(),
        limits,
        ConstructionHeuristicType::CheapestInsertion,
        ConstructionObligation::PreserveUnassigned,
        false,
    );
    let mut cursor = placer.open_cursor(&director);
    let mut placement = cursor
        .next_placement(
            &director,
            |placement| {
                placement
                    .construction_target()
                    .scalar_slots()
                    .iter()
                    .any(|slot| slot.entity_index() < 63)
            },
            || false,
        )
        .expect("unfinished final row must be reached");
    let id = placement
        .candidates_mut()
        .next_candidate()
        .expect("final row must have a candidate");
    let candidate = placement
        .candidates()
        .candidate(id)
        .expect("candidate must remain borrowable");
    let crate::heuristic::selector::move_selector::MoveCandidateRef::Borrowed(mov) = candidate
    else {
        panic!("grouped assignment must retain a borrowed candidate");
    };
    assert_eq!(mov.edits()[0].entity_index, 63);
    assert_eq!(
        COMPLETED_ROW_READS.load(std::sync::atomic::Ordering::SeqCst),
        0,
        "completed roots must be excluded before reading domains or generating moves"
    );
}