releases

SolverForge 0.10.x: Coupled Scalar Search and Zero-Erasure Scoring

SolverForge 0.10.x publishes the grouped-scalar runtime line: atomic coupled scalar construction, conflict-directed repair, level-aware simulated annealing, and coordinate-stable projected scoring rows.

SolverForge 0.10.x is the grouped-scalar runtime line. It starts with 0.10.0, available on crates.io with API docs on docs.rs, and tagged in the core repository.

Patch releases are folded into this line note instead of published as separate release-note pages.

This release moves SolverForge past single-slot scalar repair. The 0.9 line made scalar/list model metadata explicit through planning_model!; 0.10.0 uses that model-owned metadata to solve coupled scalar decisions as first-class runtime work.

That matters for real planning models where “assign this one field” is not the actual decision. A furnace load, surgical case, field-service visit, or operator-task match may need several nullable scalar variables to become legal together. Solving those fields one at a time can strand the search behind hard constraints. 0.10.0 gives those coupled assignments a stock grouped-scalar path instead of forcing app-local workaround logic.

What Changed

Grouped scalar construction is now explicit

Named ScalarGroupContext providers can emit grouped scalar candidates that the construction phase evaluates as one compound decision. When a construction phase sets group_name, SolverForge resolves the named model group, normalizes the candidate edits, applies group limits after normalization, and marks every covered scalar slot complete when the grouped decision is accepted.

The result is a construction route for nullable scalar variables that must be assigned together. Ordinary scalar construction remains single-slot unless the model declares a group and the phase selects it.

The config shape is deliberately explicit:

[[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 scalar group. value_candidate_limit is passed through to the provider for per-entity or per-slot work, while group_candidate_limit caps normalized candidates after SolverForge has removed illegal, duplicate, no-op, and non-frontier edits.

Nullable construction obligations are configurable

0.10.0 also adds mandatory nullable construction assignment. Optional scalar slots still default to preserve_unassigned, meaning None can remain the baseline when it is legal and scores best. When construction must assign a value whenever a candidate exists, use:

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

That separates two real modeling policies: optional means “may stay unassigned” by default, while mandatory nullable construction means “the field is nullable in Rust, but construction should fill it when a legal candidate is available.”

Local search can repair scalar conflicts as compound moves

0.10.0 adds grouped scalar and conflict-directed compound repair selectors. These selectors build CompoundScalarMove candidates, carry exact undo and tabu identity, and can focus on configured hard constraints through package-aware constraint metadata.

This is the runtime answer for models where the legal move is “change the bundle”, not “change one variable and hope the next move fixes the rest.”

Grouped scalar local search uses the same model group as grouped construction:

[[phases]]
type = "local_search"

[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-directed compound repair is for a different source of candidates: the configured scoring constraints identify broken matches, and domain providers emit one or more scalar edits that repair that match as a compound move.

[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

Constraint keys now resolve against exact scoring metadata. Package-qualified constraints use ConstraintRef::full_name() strings such as package/name; package-less constraints use the short name.

Hard-improvement gates are shared across compound paths

Grouped scalar moves, conflict repair, cartesian composites, local search, and VND now enforce the same hard-score improvement rule when that gate is configured. For multi-level scores, the check compares all hard levels rather than only the first hard component.

That gives compound repair a consistent contract: a configured hard-repair path must make the hard score better, regardless of whether the candidate came from a grouped scalar selector, conflict repair selector, cartesian composition, or VND step.

Projected scoring rows keep stable coordinates

Projected scoring rows let generated accessors produce bounded derived rows without materializing extra facts or entities. In 0.10.0, projected joins reuse sparse row storage while preserving self-join coordinate order by source slot, entity index, and emission index.

That fixes the important edge case: order-sensitive filters over projected self-joins no longer depend on sparse storage slot reuse.

The public projection contract remains:

use solverforge::{Projection, ProjectionSink};

struct AssignmentLoadEntries;

impl Projection<Assignment> for AssignmentLoadEntries {
    type Out = AssignmentLoadEntry;
    const MAX_EMITS: usize = 2;

    fn project<Sink>(&self, assignment: &Assignment, sink: &mut Sink)
    where
        Sink: ProjectionSink<Self::Out>,
    {
        sink.emit(AssignmentLoadEntry::primary(assignment));
        sink.emit(AssignmentLoadEntry::secondary(assignment));
    }
}

Projected rows can now participate in self-joins without the incremental engine letting sparse storage reuse define pair orientation. That closes a correctness gap for projections that emit multiple rows per source entity and then apply order-sensitive filters.

Simulated annealing is score-level aware

Simulated annealing now understands multi-level scores directly. It supports per-level temperatures, hard-regression policy, calibration, and deterministic hill-climbing behavior once cooled.

Custom Score implementations now expose level_number() as the allocation-free per-level accessor. to_level_numbers() remains the owned vector view for code that needs level data as a collection.

The config is per score level, ordered from highest-priority level to lowest:

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

[phases.acceptor.calibration]
sample_size = 64
target_acceptance_probability = 0.75
fallback_temperature = 2.0

If explicit temperatures are omitted, calibration can sample candidate deltas and derive temperatures per level. Once the configured temperature cools to the hill-climbing threshold, worsening moves are rejected deterministically.

Selector telemetry reports real move metadata

Selector telemetry now uses move metadata instead of phantom fallback labels. Round-robin union selectors can interleave child neighborhoods, and source-aware stream helpers are re-exported through the facade.

The combined effect is better observability for mixed selector trees: telemetry names the family that actually generated the candidate, and union selectors can avoid starving later child neighborhoods behind an earlier large stream.

For broad mixed neighborhoods, union_move_selector can now opt into round-robin child traversal:

[phases.move_selector]
type = "union_move_selector"
selection_order = "round_robin"

[[phases.move_selector.selectors]]
type = "grouped_scalar_move_selector"
group_name = "task_operator_assignment"

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

The selector telemetry fix makes those families visible under the real move metadata, rather than collapsing reporting into fallback labels.

Upgrade

For direct runtime users:

[dependencies]
solverforge = { version = "0.10.0", features = ["serde", "console"] }

The published crate declares Rust 1.95.

For CLI-generated projects, check the scaffold target separately:

solverforge --version

solverforge-cli 2.0.2 scaffolds solverforge 0.10.0, so newly generated apps start on this runtime line.

If you maintain custom score types, implement Score::level_number() and keep from_level_numbers(...) coherent with the same level order. If your model has coupled nullable scalar decisions, declare a named scalar group in the model and select that group from construction or local-search config instead of encoding the coupling as app-specific solver glue.

Existing scalar-only models do not need grouped scalar configuration unless they have coupled nullable decisions. Existing optional scalar construction keeps the 0.9 behavior unless you opt into assign_when_candidate_exists.

SolverForge 0.10.0 is the release where coupled scalar decisions become stock SolverForge runtime behavior: explicit in the model, selected in config, scored incrementally, and repaired through the same hard-score contract as the rest of the search engine.