Skip to content

SolverForge/solverforge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

645 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SolverForge

CI Release Crates.io Documentation License Rust Version Downloads

Used in Production

"Working like a charm, A+" — Dr. Fawaz Halwani, Pathologist, The Ottawa Hospital

A zero-erasure constraint solver in Rust.

SolverForge optimizes planning and scheduling problems using metaheuristic algorithms. It combines a declarative constraint API with efficient incremental scoring to solve complex real-world problems like employee scheduling, vehicle routing, and resource allocation.

Zero-Erasure Architecture

SolverForge preserves concrete types through the entire solver pipeline:

  • No trait objects (Box<dyn Trait>, Arc<dyn Trait>)
  • No runtime dispatch - all generics resolved at compile time
  • No hidden allocations - moves, scores, and constraints are stack-allocated
  • Predictable performance - no GC pauses, no vtable lookups

This enables aggressive compiler optimizations and cache-friendly data layouts.

Features

  • Score Types: SimpleScore, HardSoftScore, HardMediumSoftScore, BendableScore, HardSoftDecimalScore
  • ConstraintStream API: Declarative constraint definition with fluent builders
  • SERIO Engine: Scoring Engine for Real-time Incremental Optimization
  • Solver Phases:
    • Construction Heuristic (FirstFit, BestFit, FirstFeasible, WeakestFit, StrongestFit, CheapestInsertion, RegretInsertion)
    • List Construction (CheapestInsertion, RegretInsertion, ClarkeWright savings, K-Opt polishing)
    • Local Search (Hill Climbing, Simulated Annealing, Tabu Search, Late Acceptance, Great Deluge, Step Counting Hill Climbing, Diversified Late Acceptance)
    • Exhaustive Search (Branch and Bound with DFS/BFS/Score-First)
    • Partitioned Search (multi-threaded via rayon)
    • VND (Variable Neighborhood Descent)
  • Move System: Zero-allocation typed moves with arena-based ownership
    • Basic: ChangeMove, SwapMove, PillarChangeMove, PillarSwapMove, RuinMove
    • List: ListChangeMove, ListSwapMove, SubListChangeMove, SubListSwapMove, KOptMove, ListRuinMove
    • Nearby selection for list moves
  • SolverManager API: Channel-based async solving with score analysis and telemetry
  • Derive Macros: #[planning_solution], #[planning_entity], #[problem_fact]
  • Configuration: TOML support with builder API
  • Console Output: Colorful tracing-based progress display with solve telemetry

Installation

Add to your Cargo.toml:

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

Feature Flags

Feature Description
console Colorful console output with progress tracking
verbose-logging DEBUG-level progress updates (1/sec during local search)
decimal Decimal score support via rust_decimal
serde Serialization support for domain types

Quick Start

1. Define Your Domain Model

use solverforge::prelude::*;

#[problem_fact]
pub struct Employee {
    pub id: i64,
    pub name: String,
    pub skills: Vec<String>,
}

#[planning_entity]
pub struct Shift {
    #[planning_id]
    pub id: i64,
    pub required_skill: String,
    #[planning_variable]
    pub employee: Option<i64>,
}

#[planning_solution]
pub struct Schedule {
    #[problem_fact_collection]
    pub employees: Vec<Employee>,
    #[planning_entity_collection]
    pub shifts: Vec<Shift>,
    #[planning_score]
    pub score: Option<HardSoftScore>,
}

2. Define Constraints

The #[planning_solution] macro generates a ScheduleConstraintStreams trait with typed accessors for each collection field, so factory.shifts() replaces manual for_each extractors:

use ScheduleConstraintStreams; // generated by #[planning_solution]
use solverforge::stream::{ConstraintFactory, joiner};

fn define_constraints() -> impl ConstraintSet<Schedule, HardSoftScore> {
    let required_skill = ConstraintFactory::<Schedule, HardSoftScore>::new()
        .shifts()
        .join((
            |s: &Schedule| s.employees.as_slice(),
            joiner::equal_bi(
                |shift: &Shift| shift.employee_id,
                |emp: &Employee| Some(emp.id),
            ),
        ))
        .filter(|shift: &Shift, emp: &Employee| {
            !emp.skills.contains(&shift.required_skill)
        })
        .penalize_hard()
        .named("Required skill");

    let no_overlap = ConstraintFactory::<Schedule, HardSoftScore>::new()
        .for_each(|s: &Schedule| s.shifts.as_slice())
        .join(joiner::equal(|shift: &Shift| shift.employee_id))
        .filter(|a: &Shift, b: &Shift| {
            a.employee_id.is_some() && a.start < b.end && b.start < a.end
        })
        .penalize_hard()
        .named("No overlap");

    (required_skill, no_overlap)
}

3. Solve

use solverforge::{SolverManager, Solvable};

fn main() {
    let schedule = Schedule::new(employees, shifts);

    // Channel-based solving with progress updates
    let (job_id, receiver) = SolverManager::global().solve(schedule);

    // Receive best solutions as they're found
    while let Ok((solution, score)) = receiver.recv() {
        println!("New best: {}", score);
    }
}

Console Output

With features = ["console"], SolverForge displays colorful progress:

 ____        _                 _____
/ ___|  ___ | |_   _____ _ __ |  ___|__  _ __ __ _  ___
\___ \ / _ \| \ \ / / _ \ '__|| |_ / _ \| '__/ _` |/ _ \
 ___) | (_) | |\ V /  __/ |   |  _| (_) | | | (_| |  __/
|____/ \___/|_| \_/ \___|_|   |_|  \___/|_|  \__, |\___|
                                             |___/
                   v0.5.17 - Zero-Erasure Constraint Solver

  0.000s ▶ Solving │ 14 entities │ 5 values │ scale 9.799 x 10^0
  0.001s ▶ Construction Heuristic started
  0.002s ◀ Construction Heuristic ended │ 1ms │ 14 steps │ 14,000/s │ 0hard/-50soft
  0.002s ▶ Late Acceptance started
  1.002s ⚡    12,456 steps │      445,000/s │ -2hard/8soft
  2.003s ⚡    24,891 steps │      448,000/s │ 0hard/12soft
 30.001s ◀ Late Acceptance ended │ 30.00s │ 104,864 steps │ 456,000/s │ 0hard/15soft
 30.001s ■ Solving complete │ 0hard/15soft │ FEASIBLE

╔══════════════════════════════════════════════════════════╗
║                 FEASIBLE SOLUTION FOUND                  ║
╠══════════════════════════════════════════════════════════╣
║  Final Score:                            0hard/15soft    ║
╚══════════════════════════════════════════════════════════╝

Log Levels

Level Content When
INFO Lifecycle events (solve/phase start/end) Default
DEBUG Progress updates (1/sec with speed and score) verbose-logging feature
TRACE Individual move evaluations RUST_LOG=solverforge_solver=trace

Architecture

SERIO incremental scoring

┌─────────────────────────────────────────────────────────────────┐
│                         solverforge                             │
│                    (facade + re-exports)                        │
└─────────────────────────────────────────────────────────────────┘
        │              │              │              │
        ▼              ▼              ▼              ▼
┌──────────────┬──────────────┬──────────────┬──────────────┐
│solverforge-  │solverforge-  │solverforge-  │solverforge-  │
│   solver     │   scoring    │   config     │   console    │
│              │              │              │              │
│ • Phases     │ • Constraint │ • TOML       │ • Banner     │
│ • Moves      │   Streams    │ • Builders   │ • Tracing    │
│ • Selectors  │ • Score      │              │ • Progress   │
│ • Acceptors  │   Directors  │              │              │
│ • Termination│ • SERIO      │              │              │
│ • Manager    │   Engine     │              │              │
│ • Telemetry  │              │              │              │
└──────────────┴──────────────┴──────────────┴──────────────┘
        │              │
        └──────┬───────┘
               ▼
        ┌──────────────────────────────┐
        │       solverforge-core       │
        │                              │
        │ • Score types                │
        │ • Domain traits              │
        │ • Descriptors                │
        │ • Variable system            │
        └──────────────────────────────┘
                       │
                       ▼
        ┌──────────────────────────────┐
        │      solverforge-macros      │
        │                              │
        │ • #[planning_solution]       │
        │ • #[planning_entity]         │
        │ • #[problem_fact]            │
        └──────────────────────────────┘

Crate Overview

Crate Purpose
solverforge Main facade with prelude and re-exports
solverforge-core Core types: scores, domain traits, descriptors
solverforge-solver Solver engine: phases, moves, termination, SolverManager, telemetry
solverforge-scoring ConstraintStream API, SERIO incremental scoring
solverforge-config Configuration via TOML and builder API
solverforge-console Tracing-based console output with banner and progress display
solverforge-macros Procedural macros for domain model
solverforge-cvrp CVRP domain helpers: VrpSolution, ProblemData, distance meters, feasibility functions

Score Types

use solverforge::prelude::*;

// Single-level score
let score = SimpleScore::of(-5);

// Two-level score (hard + soft)
let score = HardSoftScore::of(-2, 100);
assert!(!score.is_feasible());  // Hard score < 0

// Three-level score
let score = HardMediumSoftScore::of(0, -50, 200);

// Decimal precision
let score = HardSoftDecimalScore::of(dec!(0), dec!(-123.45));

// N-level configurable
let score = BendableScore::new(vec![0, -1], vec![-50, -100]);

Termination

Configure via solver.toml:

[termination]
seconds_spent_limit = 30
unimproved_seconds_spent_limit = 5
step_count_limit = 10000

Or programmatically:

let config = SolverConfig::load("solver.toml").unwrap_or_default();

SolverManager API

The SolverManager provides async solving with channel-based solution streaming:

use solverforge::{SolverManager, SolverStatus};

// Get global solver manager instance
let manager = SolverManager::global();

// Start solving (returns immediately)
let (job_id, receiver) = manager.solve(problem);

// Check status
match manager.get_status(job_id) {
    SolverStatus::Solving => println!("Still working..."),
    SolverStatus::Terminated => println!("Done!"),
    SolverStatus::NotStarted => println!("Queued"),
}

// Terminate early if needed
manager.terminate_early(job_id);

// Receive solutions as they improve
while let Ok((solution, score)) = receiver.recv() {
    // Process each improving solution
}

Score Analysis

Analyze solutions without solving:

use solverforge::analyze;

let analysis = analyze(&solution);

println!("Score: {}", analysis.score);
for constraint in &analysis.constraints {
    println!("  {}: {}", constraint.name, constraint.score);
}

Examples

See the examples/ directory:

  • N-Queens: Classic constraint satisfaction problem
cargo run -p nqueens

For comprehensive examples including employee scheduling and vehicle routing, see SolverForge Quickstarts.

Performance

SolverForge leverages Rust's zero-cost abstractions:

  • Typed Moves: Values stored inline, no boxing, arena-based ownership (never cloned)
  • RPITIT Selectors: Return-position impl Trait eliminates Box<dyn Iterator> from all selectors
  • Incremental Scoring: SERIO propagates only changed constraints
  • No GC: Predictable latency without garbage collection
  • Cache-friendly: Contiguous memory layouts for hot paths
  • No vtable dispatch: Monomorphized score directors, deciders, and bounders

Typical throughput: 300k-1M moves/second depending on constraint complexity for scheduling; 2.5M+ moves/second on VRP

Status

Current Version: 0.5.17

What's New in 0.5.17

  • Generated domain accessors: #[planning_solution] generates a {Name}ConstraintStreams trait with typed .field_name() methods on ConstraintFactory — e.g., factory.shifts() instead of factory.for_each(|s| s.shifts.as_slice())
  • Generated .unassigned() filter: entities with Option planning variables get a {Entity}UnassignedFilter trait — e.g., factory.shifts().unassigned() filters to unassigned entities
  • Convenience scoring: penalize_hard(), penalize_soft(), reward_hard(), reward_soft() on all stream types
  • Unified .join(target): single join method dispatching on argument type — equal(|a| key) for self-join, (extractor_b, equal_bi(ka, kb)) for keyed cross-join, (other_stream, |a, b| pred) for predicate join
  • .named("name"): sole finalization method on all builders (replaces as_constraint)
  • Score trait: one_hard(), one_soft(), one_medium() default methods
  • Joiners: equal, equal_bi, less_than, less_than_or_equal, greater_than, greater_than_or_equal, overlapping, filtering, with .and() composition
  • Conditional existence: if_exists_filtered(), if_not_exists_filtered() with joiner-based matching

What's New in 0.5.15

  • solverforge-cvrp wired into the facade: solverforge::cvrp::VrpSolution, ProblemData, MatrixDistanceMeter, MatrixIntraDistanceMeter, and all CVRP free functions now accessible from the main crate
  • Fixed circular dependency: solverforge-cvrp now depends on solverforge-solver directly instead of the facade

What's New in 0.5.14

  • Added ListKOptPhase, solverforge-cvrp library, and fixed doctest signatures

What's New in 0.5.7

  • API cleanup: ~1500-1900 LOC removed across scoring and solver crates
  • Consolidated tri/quad/penta n-ary constraints and arity stream macros into unified macro files
  • Deleted ShadowAwareScoreDirector, ScoreDirectorFactory (dead wrappers)
  • Trimmed ScoreDirector trait: removed variable_name param, before/after_entity_changed, trigger_variable_listeners, get_entity; deleted dead pinning infrastructure
  • Eliminated Box<dyn Acceptor<S>> via AnyAcceptor<S> enum in AcceptorBuilder
  • Removed run_solver_with_channel; collapsed basic.rs solve overloads
  • Deleted dead termination_fn field/methods from SolverScope
  • Added WIREFRAME.md canonical API references for all crates

What's New in 0.5.6

  • Fixed GroupedUniConstraint new-group old_score computation (was using -weight(empty) instead of Sc::zero(), causing phantom positive deltas)
  • Fixed UniConstraintStream::group_by() silently dropping accumulated filters (.filter().group_by() now works correctly)
  • Added #[allow(too_many_arguments)] on GroupedUniConstraint::new to suppress lint

What's New in 0.5.5

  • Fixed incremental scoring corruption when multiple entity classes are present — on_insert/on_retract notifications now filtered by descriptor_index in all constraint types (IncrementalUniConstraint, GroupedUniConstraint, all nary variants)
  • UniConstraintStream::for_descriptor(idx) exposed in stream builder API

What's New in 0.5.4

  • Deleted dynamic/cranelift and stub dotfile artifacts (internal cleanup)

What's New in 0.5.3

  • Move streaming for never-ending selectors: local search no longer stalls when selectors produce moves lazily without exhausting

What's New in 0.5.2

New Features:

  • Ruin-and-Recreate (LNS): ListRuinMove for Large Neighborhood Search on list variables
  • Nearby Selection: Proximity-based list change/swap selectors for improved VRP solving
  • EitherMove: Monomorphized union of ChangeMove + SwapMove with UnionMoveSelector for mixed move neighborhoods
  • Simulated Annealing: Rewritten with true Boltzmann distribution
  • Telemetry: SolveResult with solve statistics (moves/sec, calc/sec, acceptance rate)
  • Best Solution Callback: with_best_solution_callback() on Solver for real-time progress streaming
  • DiminishedReturns Termination: Terminate when score improvement rate falls below threshold

Zero-Erasure Deepening:

  • Eliminated all Box<dyn Iterator> from selectors via RPITIT (return-position impl Trait in trait)
  • Monomorphized RecordingScoreDirector and exhaustive search decider/bounder (no more vtable dispatch)
  • Replaced Arc<RwLock> in MimicRecorder with Cell + manual refcount
  • Removed Rc from SwapMoveSelector (eager triangular pairing)
  • PhantomData<fn() -> T> applied across all types to prevent inherited trait bounds

Performance:

  • Eliminated Vec clones in KOptMove, SubListChangeMove, and SubListSwapMove hot paths
  • Fixed 6 hot-path regressions in local search and SA acceptor
  • Score macros (impl_score_ops!, impl_score_scale!, impl_score_parse!) reduce codegen

Fixes:

  • Construction heuristic and local search producing 0 steps (entity_count wiring)
  • Overflow panics in IntegerRange, ValueRangeDef, and date evaluation
  • Correct Acceptor::is_accepted signature (&mut self)

What's New in 0.5.1

  • Removed filter_with_solution() in favor of shadow variables on entities

What's New in 0.5.0

  • Zero-erasure architecture across entire solver pipeline
  • ConstraintStream API with incremental SERIO scoring
  • Channel-based SolverManager API with analyze() for score analysis
  • Console output with tracing-based progress display
  • Solution-aware filter traits
  • Macro-based codegen for N-ary incremental constraints

Component Status

Component Status
Score types Complete
Domain model macros Complete
ConstraintStream API Complete
SERIO incremental scoring Complete
Construction heuristics (7 types) Complete
Local search (7 acceptors, 5 foragers) Complete
Exhaustive search Complete
Partitioned search Complete
VND Complete
Move system (12 move types) Complete
Nearby selection Complete
Ruin-and-recreate (LNS) Complete
Selector decorators (8 types) Complete
Termination Complete
SolverManager Complete
Score Analysis (analyze()) Complete
Solve telemetry Complete
Console output Complete

Minimum Rust Version

Rust 1.80 or later.

License

Apache License 2.0. See LICENSE.

Contributing

Contributions welcome. Please open an issue or pull request.

Acknowledgments

Inspired by Timefold (formerly OptaPlanner).

About

SolverForge is a constraint programming framework and solver written in Rust. It allows you to solve any planning problem, blazing fast!

Resources

License

Stars

Watchers

Forks

Sponsor this project

 

Packages

 
 
 

Contributors

Languages