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
With phases omitted, construction is state-aware: matching specialized list
construction runs alongside named grouped FirstFit for required assignment
rows, grouped CheapestInsertion for optional rows, and single-slot construction
for remaining non-assignment-owned scalar variables. Default local search is
eligible afterward only with an effective parsed top-level termination limit.
See Phases for the distinction from explicit
phases.
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.
Generic and specialized constructors emit one engine-owned phase lifecycle. With verbose logging, the first observed construction work is published promptly and long-running work continues at roughly one-second intervals. Candidate, score-calculation, generation-time, and evaluation-time counters are the runtime’s authoritative values; Rust apps and bindings do not reconstruct construction progress from callbacks or terminal output.
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.
Clarke-Wright interruption and buffered merges
Interruption during the merge loop does not discard a complete, placeable buffered route set merely because the merges have not yet been committed. If at least one merge was admitted and the surviving routes can be assigned to distinct available owner slots from their retained feasibility evidence, the phase commits that complete assignment and its pending move telemetry atomically.
This final assignment reuses the feasibility already established for every owner in a route’s savings metric class (or every owner for an unscored route). It does not run another owner-feasibility scan or consume another candidate budget. If that evidence cannot place the buffered routes, the already committed complete assignment is kept instead. Interruption while generating savings also keeps the committed complete assignment; it is not permission to publish an incomplete route set. Cancellation and configured termination retain their normal lifecycle meanings, and the mandatory-construction publication gate still applies across the whole model.
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. Ordinary local-search change and swap
ranking uses dedicated search_entity_order_key / search_value_order_key
hooks instead; construction keys do not change local-search order.
Candidate, order, legality, and ownership callbacks may depend on all of the solution. Construction scans and placements read the committed live state on each pull, so a commit elsewhere can change the next placement. Completion markers describe exact construction slots, not a cached phase-start solution.
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. The dense hard-first batch commits independent direct
assignments immediately; it does not explore augmenting rematches that cannot
be retained as part of that batch. Bounded rematches remain available to the
following required cursor, where their multi-entity edits can be retained.
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, deadline, and parent-yield control are polled during preprocessing and
expensive candidate pulls as well as at phase boundaries, 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.
Keeping an optional assignment unassigned completes that anchor decision, not the optional pass. Completed optional roots are skipped before reading their candidate domains or generating moves, so an earlier kept-unassigned row cannot consume the candidate budget intended for later rows. Completion recognizes the exact assignment group slot, including kept-unassigned decisions; it does not mark assigned occupants unavailable to later required augmenting rematches. Candidate-backed grouped decisions instead complete their exact group slot and the scalar slots covered by that decision.
Assignment Limits
ScalarGroupLimits separates value_candidate_limit, group_candidate_limit,
max_moves_per_step, max_augmenting_depth, and max_rematch_size. Construction
config overrides its corresponding candidate limits and otherwise uses the
model’s ScalarGroup::with_limits(...) values. Local search exposes
value_candidate_limit and max_moves_per_step through the configured
grouped_scalar_move_selector.
Assignment augmenting depth and sequence/position rematch size are model-owned
limits set through with_limits, not extra TOML selector fields. Stock defaults
are depth 3 and rematch size 4; rematch size is clamped to at least 2.
let group = ScalarGroup::assignment(
"required_shift_assignment",
Schedule::shifts().scalar("employee_idx"),
)
.with_limits(ScalarGroupLimits {
max_augmenting_depth: Some(3),
max_rematch_size: Some(4),
..ScalarGroupLimits::new()
});
Dynamic Construction Primitives
Normal app models keep construction on ScalarGroup::assignment(...),
group_name, and solver config. The grouped-scalar construction cursors and
assignment-stream types are runtime-internal; they are assembled through the
pub(crate) solver builder and are not importable from the public crate
surface. Required assignment construction streams state internally 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
- Local Search - improving the constructed solution
- Scalar Move Selectors - grouped scalar local-search counterpart