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 And Custom Search
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
- Construction — Construction policy and grouped scalar construction
- Local Search — Acceptors, foragers, and move selectors
- Moves — Selector families and move behavior
- Termination — Termination conditions
- SolverManager — Running the solver