solverforge-solver 0.19.2

Solver engine for SolverForge
Documentation
//! Structural completion checks for one compiled runtime execution.

use std::fmt::Debug;

use solverforge_core::domain::PlanningSolution;
use solverforge_core::score::{ParseableScore, Score};
use solverforge_scoring::Director;

use crate::heuristic::selector::nearby_list_change::CrossEntityDistanceMeter;
use crate::manager::SolverTerminalReason;
use crate::runtime_build_error::{RuntimeBuildError, RuntimeBuildResult};
use crate::scope::{ProgressCallback, SolverScope};

use super::{PreparedRuntimeExecution, RuntimeInstantiationError};
use crate::runtime::compiler::DefaultRuntimeBindings;

pub(super) fn publish_if_mandatory_complete<S, V, DM, IDM, Extension, D, ProgressCb>(
    execution: &mut PreparedRuntimeExecution<S, V, DM, IDM, Extension>,
    bindings: &DefaultRuntimeBindings<S, V, DM, IDM>,
    completion_published: &mut bool,
    phase_index: usize,
    solver_scope: &mut SolverScope<'_, S, D, ProgressCb>,
) -> RuntimeBuildResult<bool>
where
    S: PlanningSolution + Clone + Send + Sync + 'static,
    S::Score: Score + Copy + Ord + ParseableScore,
    V: Clone + PartialEq + Into<usize> + Send + Sync + Debug + 'static,
    DM: Clone + Send + Sync + Debug + CrossEntityDistanceMeter<S> + 'static,
    IDM: Clone + Send + Sync + Debug + CrossEntityDistanceMeter<S> + 'static,
    D: Director<S>,
    ProgressCb: ProgressCallback<S>,
{
    if matches!(
        solver_scope.terminal_reason(),
        SolverTerminalReason::Cancelled | SolverTerminalReason::Failed
    ) {
        return Ok(false);
    }
    let unresolved = unresolved_mandatory_work(
        execution,
        bindings,
        phase_index,
        solver_scope.working_solution(),
    )
    .map_err(|error| RuntimeBuildError::Execution {
        phase_index: error.phase_index,
        message: error.to_string(),
    })?;
    if unresolved.is_some() {
        solver_scope.defer_best_solution_publication();
        *completion_published = false;
        return Ok(false);
    }
    if !*completion_published {
        solver_scope.publish_current_solution_as_best();
        *completion_published = true;
    }
    Ok(true)
}

pub(super) fn require_mandatory_completion<S, V, DM, IDM, Extension, D, ProgressCb>(
    execution: &mut PreparedRuntimeExecution<S, V, DM, IDM, Extension>,
    bindings: &DefaultRuntimeBindings<S, V, DM, IDM>,
    completion_published: &mut bool,
    phase_index: usize,
    solver_scope: &mut SolverScope<'_, S, D, ProgressCb>,
) -> RuntimeBuildResult<bool>
where
    S: PlanningSolution + Clone + Send + Sync + 'static,
    S::Score: Score + Copy + Ord + ParseableScore,
    V: Clone + PartialEq + Into<usize> + Send + Sync + Debug + 'static,
    DM: Clone + Send + Sync + Debug + CrossEntityDistanceMeter<S> + 'static,
    IDM: Clone + Send + Sync + Debug + CrossEntityDistanceMeter<S> + 'static,
    D: Director<S>,
    ProgressCb: ProgressCallback<S>,
{
    if matches!(
        solver_scope.terminal_reason(),
        SolverTerminalReason::Cancelled | SolverTerminalReason::Failed
    ) {
        return Ok(false);
    }
    let unresolved = unresolved_mandatory_work(
        execution,
        bindings,
        phase_index,
        solver_scope.working_solution(),
    )
    .map_err(|error| RuntimeBuildError::Execution {
        phase_index: error.phase_index,
        message: error.to_string(),
    })?;
    let Some(unresolved) = unresolved else {
        if !*completion_published {
            solver_scope.publish_current_solution_as_best();
            *completion_published = true;
        }
        return Ok(true);
    };
    solver_scope.defer_best_solution_publication();
    *completion_published = false;
    Err(RuntimeBuildError::Execution {
        phase_index,
        message: format!(
            "configured solve stopped with mandatory planning work incomplete: {unresolved}"
        ),
    })
}

pub(super) fn unresolved_mandatory_work<S, V, DM, IDM, Extension>(
    execution: &mut PreparedRuntimeExecution<S, V, DM, IDM, Extension>,
    bindings: &DefaultRuntimeBindings<S, V, DM, IDM>,
    phase_index: usize,
    solution: &S,
) -> Result<Option<String>, RuntimeInstantiationError>
where
    S: PlanningSolution + Clone + Send + Sync + 'static,
    V: Clone + PartialEq + Send + Sync + Debug + 'static,
    DM: Clone + Send + Sync + Debug + CrossEntityDistanceMeter<S>,
    IDM: Clone + Send + Sync + Debug + CrossEntityDistanceMeter<S>,
{
    for slot in &bindings.list_slots {
        let unassigned = execution.current_list_unassigned_count(phase_index, slot, solution)?;
        if unassigned > 0 {
            return Ok(Some(format!(
                "list variable {} has {unassigned} unassigned element(s)",
                slot.identity()
            )));
        }
    }

    for binding in &bindings.assignment_groups {
        let Some(assignment) = binding.group.assignment() else {
            continue;
        };
        let remaining = assignment.remaining_required_count(solution);
        if remaining > 0 {
            return Ok(Some(format!(
                "assignment group {} has {remaining} unassigned required entity row(s)",
                binding.group.group_name
            )));
        }
    }

    for binding in &bindings.scalar_slots {
        if binding.assignment_owned || binding.slot.allows_unassigned() {
            continue;
        }
        let unassigned = (0..binding.slot.entity_count(solution))
            .filter(|entity_index| {
                binding
                    .slot
                    .current_value(solution, *entity_index)
                    .is_none()
            })
            .count();
        if unassigned > 0 {
            return Ok(Some(format!(
                "scalar variable {} has {unassigned} unassigned entity row(s)",
                binding.slot.id()
            )));
        }
    }

    Ok(None)
}

#[cfg(test)]
mod tests {
    use std::any::TypeId;

    use solverforge_config::SolverConfig;
    use solverforge_core::domain::{
        EntityClassId, EntityCollectionExtractor, EntityDescriptor, PlanningSolution,
        SolutionDescriptor, ValueRangeType, VariableDescriptor, VariableId,
    };
    use solverforge_core::score::SoftScore;

    use super::unresolved_mandatory_work;
    use crate::builder::{
        bind_scalar_groups, NoDynamicExtensions, RuntimeModel, ScalarVariableSlot, SearchContext,
        ValueSource, VariableSlot,
    };
    use crate::heuristic::selector::nearby_list_change::DefaultCrossEntityDistanceMeter;
    use crate::planning::{ScalarGroup, ScalarTarget};
    use crate::runtime::compiler::{
        compile_runtime_graph, CompiledRuntimeExecutor, RuntimeGraphInput,
    };

    type Meter = DefaultCrossEntityDistanceMeter;
    type Model = RuntimeModel<ScalarPlan, usize, Meter, Meter>;

    #[derive(Clone, Debug)]
    struct ScalarPlan {
        score: Option<SoftScore>,
        workers: Vec<Option<usize>>,
        required: Vec<bool>,
        candidates: Vec<usize>,
    }

    impl PlanningSolution for ScalarPlan {
        type Score = SoftScore;

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

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

    fn descriptor(allows_unassigned: bool) -> SolutionDescriptor {
        SolutionDescriptor::new("ScalarPlan", TypeId::of::<ScalarPlan>()).with_entity(
            EntityDescriptor::new("Task", TypeId::of::<Option<usize>>(), "tasks")
                .with_logical_id(EntityClassId(0))
                .with_extractor(Box::new(EntityCollectionExtractor::new(
                    "Task",
                    "tasks",
                    |plan: &ScalarPlan| &plan.workers,
                    |plan: &mut ScalarPlan| &mut plan.workers,
                )))
                .with_variable(
                    VariableDescriptor::genuine("worker")
                        .with_logical_id(VariableId(0))
                        .with_allows_unassigned(allows_unassigned)
                        .with_value_range_type(ValueRangeType::EntityDependent)
                        .with_usize_accessors(
                            |entity| {
                                *entity
                                    .downcast_ref::<Option<usize>>()
                                    .expect("Task must be an optional worker")
                            },
                            |entity, value| {
                                *entity
                                    .downcast_mut::<Option<usize>>()
                                    .expect("Task must be an optional worker") = value;
                            },
                        ),
                ),
        )
    }

    fn scalar_slot(allows_unassigned: bool) -> ScalarVariableSlot<ScalarPlan> {
        ScalarVariableSlot::new(
            0,
            0,
            "Task",
            |plan| plan.workers.len(),
            "worker",
            |plan, entity, _| plan.workers[entity],
            |plan, entity, _, value| plan.workers[entity] = value,
            ValueSource::EntitySlice {
                values_for_entity: |plan, _, _| &plan.candidates,
            },
            allows_unassigned,
        )
    }

    fn unresolved(
        model: Model,
        descriptor: SolutionDescriptor,
        plan: &ScalarPlan,
    ) -> Option<String> {
        let context = SearchContext::new(descriptor, model, None);
        let graph = compile_runtime_graph(
            &SolverConfig::default(),
            RuntimeGraphInput::new(context, NoDynamicExtensions),
        )
        .expect("scalar completion graph compiles");
        let executor = CompiledRuntimeExecutor::new(graph);
        let bindings = executor.graph().default_bindings().clone();
        let mut execution = executor.instantiate().expect("scalar graph instantiates");
        unresolved_mandatory_work(&mut execution, &bindings, 0, plan)
            .expect("scalar completion check succeeds")
    }

    #[test]
    fn required_scalar_slots_must_be_assigned() {
        let plan = ScalarPlan {
            score: None,
            workers: vec![None, Some(0)],
            required: vec![false, false],
            candidates: vec![0],
        };
        let unresolved = unresolved(
            RuntimeModel::new(vec![VariableSlot::Scalar(scalar_slot(false))]),
            descriptor(false),
            &plan,
        )
        .expect("one required scalar row is unassigned");

        assert!(unresolved.contains("1 unassigned entity row(s)"));
    }

    #[test]
    fn optional_scalar_slots_may_remain_unassigned() {
        let plan = ScalarPlan {
            score: None,
            workers: vec![None, Some(0)],
            required: vec![false, false],
            candidates: vec![0],
        };

        assert!(unresolved(
            RuntimeModel::new(vec![VariableSlot::Scalar(scalar_slot(true))]),
            descriptor(true),
            &plan,
        )
        .is_none());
    }

    #[test]
    fn assignment_groups_require_only_rows_declared_required() {
        let slot = scalar_slot(true);
        let groups = bind_scalar_groups(
            vec![ScalarGroup::assignment(
                "worker_assignment",
                ScalarTarget::from_descriptor_index(0, "worker"),
            )
            .with_required_entity(|plan: &ScalarPlan, entity| plan.required[entity])],
            &[slot],
        );
        let model = RuntimeModel::new(vec![VariableSlot::Scalar(slot)]).with_scalar_groups(groups);
        let incomplete = ScalarPlan {
            score: None,
            workers: vec![None, None],
            required: vec![true, false],
            candidates: vec![0],
        };
        let complete = ScalarPlan {
            score: None,
            workers: vec![Some(0), None],
            required: vec![true, false],
            candidates: vec![0],
        };

        let message = unresolved(model.clone(), descriptor(true), &incomplete)
            .expect("required assignment row is incomplete");
        assert!(message.contains("1 unassigned required entity row(s)"));
        assert!(unresolved(model, descriptor(true), &complete).is_none());
    }
}