Documentation

Configuration

Runtime configuration with SolverConfig, solver.toml, and parsing helpers.

A model reads a solver.toml only when it names one. Add solver_toml to #[planning_solution] with a path relative to the source file that declares the solution. With the solution in src/domain/schedule.rs and the file next to Cargo.toml (the scaffold layout), that is solver_toml = "../../solver.toml"; the full declaration is under Per-Solution Overlays.

The file is embedded at compile time, so the binary does not depend on its working directory. Without solver_toml, a solve starts from SolverConfig::default(); no file is read from the working directory. An embedded file that does not parse fails the job with one Failed event whose error starts with solver configuration invalid: TOML parse error.

The solverforge facade exports the configuration API, so app code that inspects or builds configs needs no other dependency:

use solverforge::{
    AcceptorConfig, CandidateTraceConfig, ForagerConfig, MoveSelectorConfig, PhaseConfig,
    SolverConfig, SolverConfigOverride,
};

Configuration has three levels:

Level Scope Typical owner
Global config environment mode, random seed, thread count, top-level termination, candidate tracing app/operator
Phase config construction, local search, score ties, VND via local_search_type, partitioned search, custom phase names, phase-specific termination app/operator
Model capabilities candidate providers and metrics, nearby hooks, list operations/metadata, construction order keys, scalar groups Rust model or binding

The important rule is that config selects declared capabilities. It does not invent model hooks. If a selector asks for nearby scalar candidates, grouped scalar candidates, assignment-backed scalar groups, or conflict repair providers, the model must expose those capabilities through the generated model support layer.

Every table rejects keys it does not know, so a misspelt key fails to parse instead of silently running on a default:

[termination]
seconds_spent_limt = 30

SolverForge compiles the complete resolved policy before any phase runs. A bad target, missing operation bundle, unknown provider or metric, invalid selector composition, out-of-range value, or inconsistent construction source fails the job with one Failed event that names its config path; the runtime does not defer schema discovery until a hot candidate loop. On a model whose variable is employee_idx, this config:

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

[[phases]]
type = "local_search"

[phases.move_selector]
type = "change_move_selector"
variable_name = "employee_id"

fails with runtime compilation failed at phases[1].local_search.move_selector: ... no scalar planning slot matches entity_class=None variable_name=Some("employee_id").

Loading Configuration

Embedded in the model

solver_toml = "..." on #[planning_solution] embeds the file at compile time and every SolverManager solve of that solution starts from it. This is the normal setup; see the top of this page.

From a TOML file at run time

SolverConfig::load reads a path relative to the process’s working directory:

let config = SolverConfig::load("solver.toml").expect("solver.toml should parse");

To solve with a file chosen at run time, load it and return it from the solution’s config callback, or pass it to solve_with_config.

From a TOML string

let config = SolverConfig::from_toml_str(r#"
    environment_mode = "reproducible"
    move_thread_count = { count = 4 }

    [termination]
    seconds_spent_limit = 120

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

    [[phases]]
    type = "local_search"
    [phases.acceptor]
    type = "late_acceptance"
    late_acceptance_size = 400
"#).expect("config should parse");

TOML is the only configuration format.

Example TOML File

This is a complete solver.toml for a model with a scalar employee_idx variable:

environment_mode = "non_reproducible"
move_thread_count = "auto"

[termination]
seconds_spent_limit = 300
unimproved_seconds_spent_limit = 60

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

[[phases]]
type = "local_search"

[phases.acceptor]
type = "simulated_annealing"
level_temperatures = [5.0, 500.0]
hard_regression_policy = "never_accept_hard_regression"

[phases.move_selector]
type = "change_move_selector"
variable_name = "employee_idx"
value_candidate_limit = 32

[phases.termination]
step_count_limit = 100000

Configuration Options

Environment Mode

Controls reproducibility and assertion levels.

Mode Description
reproducible Repeatable at fixed input, seed, evaluator count, and deterministic work limits; wall-clock budgets can change the result.
non_reproducible Non-deterministic — fastest mode for production use
fast_assert Enables light assertions for debugging
full_assert Enables all assertions — slowest, for development only
environment_mode = "non_reproducible"

Move Thread Count

Controls candidate evaluation in the compiled runtime’s acceptor-forager local search, independently of partitioned search’s thread_count.

move_thread_count = { count = 4 }    # Request up to four evaluators
# move_thread_count = "auto"         # Sequential (default)
# move_thread_count = "none"         # Sequential

The commented lines are alternative values for the same key; a file may set it once. auto, none, and explicit counts 0 or 1 use the sequential loop. A count of at least 2 resolves to min(count, rayon::current_num_threads(), 32) evaluators, including the main score director; the others are persistent replicas. The automatic policy stays sequential. A solve running in a one-thread Rayon pool therefore stays sequential even with a larger explicit count.

Only compiled acceptor-forager local search uses this setting. Lower-level LocalSearchPhase, VND, construction, exhaustive search, and partitioned child solves evaluate sequentially. Candidate generation and acceptance stay ordered; parallel trials can pull candidates beyond the eventual decision point. See Parallel Candidate Evaluation for reproducibility, speculative work, and budget semantics.

Random Seed

Set a fixed seed when you want reproducible runs:

random_seed = 42

Candidate Trace

Candidate tracing is an opt-in diagnostic surface. It records a bounded ordered prefix at the engine candidate-pull boundary:

[candidate_trace]
max_entries = 4096

Each retained pull can carry its phase and step, selector/source index, logical operation identity, construction target, and ordered disposition transitions such as evaluated, rejected, selected, and applied. The header owns canonical config, execution policy, resolved phase plan, and optional externally attested input/qualification provenance. max_entries must be nonzero; truncation is reported explicitly.

Ordinary events, status, and snapshots stay compact. Read the trace together with the exact aggregate publication using SolverManager::get_telemetry_detail(job_id).

Phases

Phases run in sequence. A typical configuration uses a construction heuristic followed by local search:

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

[[phases]]
type = "local_search"
score_tie_break = "random"
[phases.acceptor]
type = "tabu_search"
entity_tabu_size = 7

When phases are omitted, the runtime runs state-aware construction: matching specialized list construction, named grouped FirstFit for required assignment rows followed by grouped CheapestInsertion for optional rows, and single-slot construction for remaining non-assignment-owned scalars. One streaming default local-search phase is eligible only when top-level termination contains an effective parsed limit. Missing termination or an empty termination object leaves omitted phases construction-only. A best_score_limit that does not parse as the model’s score fails the solve. This is separate from omitting move_selector inside an explicit local-search phase. VND is never prepended implicitly.

See Phases for all phase types and their options.

Phase Anatomy

Most production configs have this shape:

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

[[phases]]
type = "local_search"

[phases.acceptor]
type = "late_acceptance"
late_acceptance_size = 400

[phases.forager]
type = "accepted_count"
limit = 4

[phases.move_selector]
type = "union_move_selector"
selection_order = "stratified_random"
weighting = "equal"

[[phases.move_selector.selectors]]
type = "change_move_selector"
value_candidate_limit = 32
selection_order = "random"

[[phases.move_selector.selectors]]
type = "swap_move_selector"

[phases.termination]
step_count_limit = 100000

Construction creates the first workable solution. Local search improves it. The acceptor decides whether a scored candidate can be accepted. The forager decides which accepted candidate is committed. The move selector decides which candidate moves are pulled. score_tie_break = "random" is the seeded default for equally scored best candidates; use first to retain the first equal best candidate instead.

Move Selectors

For config-driven local search, move selection lives under [phases.move_selector].

[phases.move_selector]
type = "nearby_list_change_move_selector"
max_nearby = 12
variable_name = "visits"

Nearby selection is configured by choosing a nearby selector variant, not by top-level nearby_selection = true flags.

Every non-composite leaf selector accepts an optional selection_order:

Value Behavior
original preserve the canonical cursor order
random seeded random pull order; used by omitted local-search defaults
shuffled seeded full shuffle
sorted sort a complete stream by a registered selection_metric
probabilistic sample a complete stream by non-negative weights from a registered selection_metric
[phases.move_selector]
type = "change_move_selector"
variable_name = "employee_idx"
selection_order = "sorted"
selection_metric = "assignment_priority"

sorted and probabilistic require a named runtime candidate metric; selection_metric is rejected for the other orders. Advanced native or binding runtimes register immutable metrics on RuntimeModel::with_candidate_metrics(...). Unknown names, non-finite results, and negative probabilistic weights fail explicitly.

Union selectors have their own scheduling policy. selection_order can be sequential, round_robin, rotating_round_robin, random, or the default stratified_random. weighting is equal by default, fixed with a weights vector, or candidate_count to derive child weights from declared candidate counts:

[phases.move_selector]
type = "union_move_selector"
selection_order = "stratified_random"
weighting = "fixed"
weights = [3, 1]

[[phases.move_selector.selectors]]
type = "change_move_selector"

[[phases.move_selector.selectors]]
type = "swap_move_selector"

Nearby scalar selectors require model-declared candidate hooks on the matching #[planning_variable]: nearby_value_candidates for nearby_change_move_selector and nearby_entity_candidates for nearby_swap_move_selector. Distance meters only rank or filter those bounded candidates.

List selectors likewise select declared list capabilities. list_permute_move_selector permutes a bounded window in one list. list_precedence_move_selector targets list variables that expose precedence_duration_fn and precedence_successors_fn hooks through #[planning_list_variable]; it streams critical-path support moves for generic precedence makespan models.

[phases.move_selector]
type = "list_permute_move_selector"
variable_name = "operations"
min_window_size = 2
max_window_size = 5
[phases.move_selector]
type = "list_precedence_move_selector"
entity_class = "Route"
variable_name = "operations"

When you need to cap one neighborhood deliberately, use the limited_neighborhood selector variant and wrap the concrete selector inside it:

[phases.move_selector]
type = "limited_neighborhood"
selected_count_limit = 24

[phases.move_selector.selector]
type = "change_move_selector"
variable_name = "employee_idx"

That cap applies to the wrapped neighborhood itself. It is separate from the accepted-count forager limit, which stops a selector step after that many accepted candidates and then picks the best candidate inside that step horizon.

Scalar change_move_selector, nearby_change_move_selector, pillar_change_move_selector, and ruin_recreate_move_selector accept value_candidate_limit. Scalar cheapest_insertion requires a bounded candidate source: either candidate_values on the model or this config limit.

Numeric selector and construction values are range-checked when the config is compiled, before any phase runs; a value outside its range fails the job at its key path. Every candidate, group, move, match, and repair limit is at least 1 when set, as are max_nearby on nearby selectors and selected_count_limit. Ranges are 1 <= min_sublist_size <= max_sublist_size, 2 <= min_window_size <= max_window_size <= 8, 1 <= min_ruin_count <= max_ruin_count, k-opt k from 2 to 5 with min_segment_len >= 1, and pillar sizes either both 0 (whole pillars) or 1 <= minimum_sub_pillar_size <= maximum_sub_pillar_size. Acceptor and forager values are checked the same way: tabu and late-acceptance sizes and the accepted-count limit must be positive, and simulated annealing needs one temperature per score level.

Grouped scalar construction and grouped scalar local search use group_name when the model provides a scalar group through the generated model support surface. Grouped local search also supports require_hard_improvement when a compound candidate must improve the hard score before it can be accepted.

Assignment-backed scalar construction and repair use the same group_name surface. The model declares ScalarGroup::assignment(...); construction generates stock nullable scalar assignment candidates and local search repairs uncovered required entities, capacity conflicts, bounded reassignments, and sequence/position rematches:

[[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

[[phases]]
type = "local_search"

[phases.move_selector]
type = "grouped_scalar_move_selector"
group_name = "required_shift_assignment"
max_moves_per_step = 64
require_hard_improvement = true

Assignment augmenting depth and sequence/position rematch size are declared with ScalarGroup::with_limits(ScalarGroupLimits { ... }), not as additional TOML selector fields. max_augmenting_depth defaults to 3; max_rematch_size defaults to 4 and is clamped to at least 2. Construction config candidate limits override corresponding model limits; grouped local search exposes value_candidate_limit and max_moves_per_step. See Assignment Limits.

Keeping an optional assignment unassigned completes that group-slot decision without ending the optional pass. Completed roots are excluded before candidate domains are read, preserving later rows’ candidate budget. Assigned occupants remain available for required augmenting rematches.

Grouped construction example:

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

Grouped local-search example:

[phases.move_selector]
type = "grouped_scalar_move_selector"
group_name = "task_operator_assignment"
value_candidate_limit = 32
max_moves_per_step = 256
require_hard_improvement = true

Conflict-repair selectors configure constraints by exact scoring metadata identity. Use ConstraintRef::full_name() for package-qualified constraints and the short name for package-less constraints. With include_soft_matches = false, soft constraints are rejected before providers run; setting it to true explicitly allows soft repair providers. Conflict repair operates on non-assignment-owned scalar variables; assignment-backed scalar slots stay on their owning grouped scalar selector path.

Compound repair example:

[phases.move_selector]
type = "compound_conflict_repair_move_selector"
constraints = ["schedule/no_overlapping_operator_assignment"]
max_matches_per_step = 16
max_repairs_per_match = 32
max_moves_per_step = 256
require_hard_improvement = true

Variable Neighborhood Descent

VND is a local-search type, not a standalone type = "vnd" phase:

[[phases]]
type = "local_search"
local_search_type = "variable_neighborhood_descent"

[[phases.neighborhoods]]
type = "change_move_selector"
variable_name = "employee_idx"

[[phases.neighborhoods]]
type = "swap_move_selector"
variable_name = "employee_idx"

The variable_neighborhood_descent mode uses ordered neighborhoods and rejects acceptor, forager, and move_selector. The default acceptor_forager local-search mode uses acceptor, forager, and move_selector.

partitioned_search requires a named partitioner compiled into the solution’s typed search surface. SolverForge does not infer partitions from a count:

[[phases]]
type = "partitioned_search"
partitioner = "by_vehicle"
thread_count = "auto"
log_progress = true

Custom phases are also compiled into the solution with #[planning_solution(search = "...")] and selected by name:

[[phases]]
type = "custom"
name = "weekend_repair"

There is no arbitrary custom_phase_class loader or erased plugin registry.

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"

Termination

Controls when the solver stops. See Termination for all options.

[termination]
seconds_spent_limit = 300

Configured solver and phase limits remain binding during required scalar and list construction. SolverForge publishes a best solution only after every mandatory list element, required assignment row, and non-optional scalar slot is assigned. If a limit fires first, the retained job ends as Failed without publishing an incomplete solution or snapshot.

Programmatic Builders

SolverConfig also exposes simple builder helpers:

let config = SolverConfig::new()
    .with_random_seed(42)
    .with_termination_seconds(30);

Per-Solution Overlays

Retained solves can layer per-solution policy on top of the embedded solver.toml with config = "..." on #[planning_solution]:

#[planning_solution(
    constraints = "define_constraints",
    solver_toml = "../../solver.toml",
    config = "solver_config_for_solution"
)]
pub struct Schedule {
    #[problem_fact_collection]
    pub employees: Vec<Employee>,
    #[problem_fact_collection]
    pub rooms: Vec<Room>,
    #[planning_entity_collection]
    pub shifts: Vec<Shift>,
    #[planning_score]
    pub score: Option<HardSoftScore>,
    pub time_limit_secs: u64,
}

fn solver_config_for_solution(solution: &Schedule, config: SolverConfig) -> SolverConfig {
    config.with_termination_seconds(solution.time_limit_secs)
}

The callback receives the embedded solver.toml config, or SolverConfig::default() when the solution names no file, and returns the config the job runs. Decorate the base config rather than replace it, unless the callback loads a file of its own. with_termination_seconds sets seconds_spent_limit and keeps the other termination keys, so with the example file above a solution with time_limit_secs = 2 stops after two seconds.

See Also