Documentation

Construction

Construction heuristic policy, nullable obligations, candidate limits, and grouped scalar construction.

Construction creates the first workable solution before local search improves it. A production config usually starts with one construction phase and then hands the result to local search.

[[phases]]
type = "construction_heuristic"
construction_heuristic_type = "first_fit"
construction_obligation = "preserve_unassigned"
value_candidate_limit = 32

Heuristic Types

Type Use
first_fit assign the first doable value that improves or preserves the current construction policy
cheapest_insertion evaluate bounded candidates and pick the cheapest insertion
grouped scalar with group_name cover required nullable scalar slots or apply atomic multi-scalar candidates from a named ScalarGroup
list-specific constructors route and sequence initialization where list work is present

Generic FirstFit and CheapestInsertion are compiled before the phase starts. Scalar-only targets use the graph’s descriptor-placement schedule; mixed or list-bearing targets use its declaration-order global scan. Specialized scalar and list heuristics become compiled nodes that call the same public kernels, rather than entering a parallel phase-builder lifecycle.

List-specific construction such as Clarke-Wright consumes savings hooks from the list variable. Stock CVRP lists can declare domain = "cvrp" to get the standard solverforge::cvrp::savings_hooks and savings_metric_class without repeating hook paths. Custom route domains can omit the profile and wire savings_hooks explicitly; the module must export depot, distance, and feasible, while optional savings_metric_class_fn lets owners with the same construction depot and distance behavior share savings rows. Route-local behavior stays separate in route_hooks, which exports get, set, depot, distance, and feasible for route assignment and k-opt improvement. Stock CVRP distance hooks turn unreachable or malformed matrix entries into large finite construction costs, and strict route hooks reject unreachable travel-time legs before route-local improvements commit them.

Stable list-source identity

Generated usize list models supply a stable element-source key automatically. Lower-level ListConstructionPhaseBuilder, ListCheapestInsertionPhase, ListRegretInsertionPhase, and ListClarkeWrightPhase callers must provide an element_source_key that maps declared elements, current assignments, and precedence successors to the same unique usize identity.

SolverForge freezes that declaration binding before a reached construction node does candidate work. Duplicate declarations, unknown assigned keys, duplicate assigned keys, or inconsistent successor keys are binding errors; the runtime does not recover identity through payload equality or hashing. Later phases reuse the frozen declaration but refresh current assignments, so they cannot reinsert work accepted by an earlier phase or reread the declaration callback.

Nullable Construction Obligation

Nullable scalar variables default to preserve_unassigned: construction may leave None in place when that is legal and scores best.

Use assign_when_candidate_exists when construction should assign a doable value whenever one exists:

[[phases]]
type = "construction_heuristic"
construction_heuristic_type = "first_fit"
construction_obligation = "assign_when_candidate_exists"

This separates Rust nullability from construction policy. A field can be nullable because later moves may unassign it, while construction can still be told to fill it whenever a legal value exists.

Candidate Limits

Scalar construction can use bounded candidates from model hooks or from config:

[[phases]]
type = "construction_heuristic"
construction_heuristic_type = "cheapest_insertion"
value_candidate_limit = 32

cheapest_insertion for scalar construction requires one bounded candidate source: either candidate_values on the model or a config candidate limit.

Construction Order Keys

Scalar construction heuristics that sort entities or values declare those capabilities on #[planning_variable]:

  • construction_entity_order_key = "fn_name"
  • construction_value_order_key = "fn_name"

SolverForge re-evaluates construction order hooks on the current working solution at every construction step. Queue-style and weakest/strongest-fit heuristics therefore track live model state instead of a phase-start snapshot.

These hooks are construction-only. Local-search scalar change, pillar-change, and ruin/recreate selectors keep canonical bounded candidate order.

Assignment-Backed ScalarGroup Construction

Use assignment-backed grouped scalar construction when the model has nullable scalar assignments where some slots are required, some values share capacity, and construction should cover every required slot that has a doable candidate.

The solution points the macro at its scalar-group provider, and the model declares a named assignment-backed ScalarGroup:

#[planning_solution(
    constraints = "define_constraints",
    scalar_groups = "scalar_groups"
)]
pub struct Schedule {
    #[problem_fact_collection]
    pub employees: Vec<Employee>,

    #[planning_entity_collection]
    pub shifts: Vec<Shift>,

    #[planning_score]
    pub score: Option<HardSoftScore>,
}

pub(super) fn scalar_groups() -> Vec<ScalarGroup<Schedule>> {
    vec![
        ScalarGroup::assignment(
            "required_shift_assignment",
            Schedule::shifts().scalar("employee_idx"),
        )
        .with_required_entity(required_shift)
        .with_capacity_key(employee_day_capacity)
        .with_entity_order(shift_order)
        .with_value_order(employee_preference),
    ]
}

fn required_shift(_schedule: &Schedule, _shift_idx: usize) -> bool {
    true
}

fn employee_day_capacity(
    schedule: &Schedule,
    shift_idx: usize,
    employee_idx: usize,
) -> Option<usize> {
    let shift = &schedule.shifts[shift_idx];
    shift
        .date
        .checked_mul(schedule.employees.len())
        .and_then(|base| base.checked_add(employee_idx))
}

fn shift_order(schedule: &Schedule, shift_idx: usize) -> i64 {
    i64::try_from(schedule.shifts[shift_idx].date).unwrap_or(i64::MAX)
}

fn employee_preference(
    schedule: &Schedule,
    shift_idx: usize,
    employee_idx: usize,
) -> i64 {
    let preferred = schedule.shifts[shift_idx].date % schedule.employees.len();
    let distance = (employee_idx + schedule.employees.len() - preferred)
        % schedule.employees.len();
    i64::try_from(distance).unwrap_or(i64::MAX)
}

The solver policy selects that group by name. The grouped scalar construction path owns required-slot assignment; there is no separate coverage-specific phase type:

[[phases]]
type = "construction_heuristic"
construction_heuristic_type = "first_fit"
construction_obligation = "assign_when_candidate_exists"
group_name = "required_shift_assignment"
value_candidate_limit = 8
group_candidate_limit = 64

Required entities are handled before optional entities. One live placer cursor owns the required pass: dense coverage can produce one hard-first allocation candidate, while single-slot required work remains a bounded stream so cheapest_insertion and weakest/strongest ordering keep their normal semantics. Configured solver and phase limits remain binding during this required pass. If a limit fires before every required slot is assigned, the solve ends as Failed without publishing an incomplete best solution or snapshot. Pause, cancel, and parent-yield control remain cooperative, and accepted work commits immediately. Required assignments may displace optional occupants or move required blockers through bounded augmenting paths. Optional assignments remain score-improving only unless the model marks them required and configuration uses assign_when_candidate_exists.

Dynamic Construction Primitives

Normal app models should keep construction on ScalarGroup::assignment(...), group_name, and solver config. Advanced integrations that assemble construction streams directly can import the public lower-level construction surface from solverforge-solver, including GroupedScalarCursor, GroupedScalarSelector, ScalarAssignmentMoveCursor, ScalarAssignmentMoveOptions, and ScalarAssignmentRequiredStreamingCursor.

That surface is the public form of the grouped-scalar construction machinery. It lets required assignment construction stream state while the stock runtime still owns the ordinary hard-first required-slot fill policy.

Grouped Scalar Construction

Use candidate-backed grouped scalar construction when the legal assignment is a custom bundle of scalar edits instead of one stock nullable-scalar assignment.

[[phases]]
type = "construction_heuristic"
construction_heuristic_type = "first_fit"
group_name = "task_operator_assignment"
value_candidate_limit = 32
group_candidate_limit = 128

group_name selects a named model-provided ScalarGroup. group_candidate_limit caps normalized grouped candidates after SolverForge removes illegal, duplicate, no-op, and non-frontier edits. Config limits override model-owned ScalarGroup::with_limits(...) values.

See Also