A Rust constraint solver library that bridges to Timefold JVM via WebAssembly and HTTP.
cargo add solverforgeSolverForge enables constraint satisfaction and optimization problems to be defined in Rust and solved using the Timefold solver engine. Instead of requiring JNI or native bindings, SolverForge:
- Generates WASM modules containing domain object accessors and constraint predicates
- Communicates via HTTP with an embedded Java service running Timefold
- Serializes solutions as JSON for language-agnostic integration
- Rust-first: Core library and API in Rust
- No JNI complexity: Pure HTTP/JSON interface to Timefold
- WASM-based constraints: Constraint predicates compiled to WebAssembly for execution in the JVM
- Timefold compatibility: Full access to Timefold's constraint streams, moves, and solving algorithms
- Near-native performance: ~80-100k moves/second
use solverforge::prelude::*;
// Problem fact: Employee with skills
#[derive(Clone)]
struct Employee {
name: String,
skills: Vec<String>,
}
// Planning entity: Shift with employee assignment
#[derive(PlanningEntity, Clone)]
struct Shift {
#[planning_id]
id: String,
#[planning_variable(value_range_provider = "employees")]
employee: Option<Employee>,
required_skill: String,
}
// Planning solution: Schedule
#[derive(PlanningSolution, Clone)]
struct Schedule {
#[problem_fact_collection]
#[value_range_provider(id = "employees")]
employees: Vec<Employee>,
#[planning_entity_collection]
shifts: Vec<Shift>,
#[planning_score]
score: Option<HardSoftScore>,
}use solverforge::{Constraint, ConstraintFactory, HardSoftScore};
fn define_constraints(factory: ConstraintFactory) -> Vec<Constraint> {
vec![
// Hard: Employee must have the required skill
factory.for_each::<Shift>()
.filter(|shift| {
shift.employee.as_ref().map_or(false, |emp| {
!emp.skills.contains(&shift.required_skill)
})
})
.penalize(HardSoftScore::ONE_HARD)
.as_constraint("Required skill"),
// Soft: Prefer balanced workload
factory.for_each::<Shift>()
.group_by(|shift| shift.employee.clone(), count())
.penalize(HardSoftScore::ONE_SOFT, |_emp, count| count * count)
.as_constraint("Balanced workload"),
]
}use solverforge::{SolverFactory, SolverConfig, TerminationConfig};
let config = SolverConfig::new()
.with_solution_class::<Schedule>()
.with_entity_classes::<Shift>()
.with_termination(
TerminationConfig::new().with_seconds_spent_limit(30)
);
let solver = SolverFactory::create(config, define_constraints).build();
let solution = solver.solve(problem)?;
println!("Score: {:?}", solution.score);┌─────────────────────────────────────────────────────────────────────────────┐
│ solverforge (Rust) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Domain │ │ Constraints │ │ WASM │ │ HTTP │ │
│ │ Model │ │ Streams │ │ Builder │ │ Client │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
HTTP/JSON
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ solverforge-wasm-service (Java) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Chicory │ │ Dynamic │ │ Timefold │ │ Host │ │
│ │ WASM Runtime │ │ Class Gen │ │ Solver │ │ Functions │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
The embedded solver service starts automatically when needed.
solverforge/
├── solverforge/ # Main crate with prelude
├── solverforge-core/ # Core library
├── solverforge-derive/ # Derive macros
├── solverforge-service/ # JVM lifecycle management
├── solverforge-python/ # Python bindings (PyO3)
└── solverforge-wasm-service/ # Java Quarkus service
#[derive(PlanningEntity)] - Marks a struct as a planning entity
Field attributes:
#[planning_id]- Unique identifier (required)#[planning_variable(value_range_provider = "...")]- Solver-assigned field#[planning_variable(..., allows_unassigned = true)]- Can remain unassigned#[planning_list_variable(value_range_provider = "...")]- List variable
#[derive(PlanningSolution)] - Marks a struct as the solution container
Struct attributes:
#[constraint_provider = "function_name"]- Constraint function
Field attributes:
#[problem_fact_collection]- Immutable input data#[planning_entity_collection]- Entities to be solved#[value_range_provider(id = "...")]- Provides values for variables#[planning_score]- Solution score field
factory.for_each::<Entity>() // Start stream
.filter(|e| predicate) // Filter entities
.join::<Other>() // Join with another type
.if_exists::<Other>() // Filter if matching exists
.if_not_exists::<Other>() // Filter if no match
.group_by(key_fn, collector) // Group and aggregate
.penalize(score) // Apply penalty
.penalize(score, weigher) // Weighted penalty
.reward(score) // Apply reward
.as_constraint("name") // Name the constraintJoiners::equal(|a| a.field, |b| b.field)
Joiners::less_than(|a| a.value, |b| b.value)
Joiners::greater_than(|a| a.value, |b| b.value)
Joiners::overlapping(|a| a.start, |a| a.end, |b| b.start, |b| b.end)count()
count_distinct(|e| e.field)
sum(|e| e.value)
average(|e| e.value)
min(|e| e.value)
max(|e| e.value)
to_list()
to_set()
load_balance()
compose(collector1, collector2)
conditionally(filter, collector)SimpleScore- Single score levelHardSoftScore- Hard constraints (must satisfy) + soft (optimize)HardMediumSoftScore- Three-level scoringBendableScore- Configurable number of levels
Each has a Decimal variant for precise arithmetic.
For computed fields that update automatically:
#[derive(PlanningEntity)]
struct Visit {
#[planning_id]
id: i64,
#[planning_variable(value_range_provider = "vehicles")]
vehicle: Option<Vehicle>,
#[inverse_relation_shadow_variable(source = "vehicle")]
previous_visit: Option<Visit>,
#[shadow_variable(source = "previous_visit", listener = "ArrivalTimeListener")]
arrival_time: Option<DateTime>,
}Available shadow types:
#[shadow_variable]- Custom computed#[inverse_relation_shadow_variable]- Inverse reference#[index_shadow_variable]- Position in list#[previous_element_shadow_variable]- Previous in list#[next_element_shadow_variable]- Next in list#[anchor_shadow_variable]- Chain anchor#[piggyback_shadow_variable]- Follows another shadow#[cascading_update_shadow_variable]- Cascade updates
Python bindings with Timefold-compatible API:
pip install solverforgefrom solverforge import (
planning_entity, planning_solution, constraint_provider,
PlanningId, PlanningVariable, HardSoftScore,
SolverFactory, SolverConfig,
)
@planning_entity
class Shift:
id: Annotated[str, PlanningId]
employee: Annotated[Optional[Employee], PlanningVariable]
@constraint_provider
def constraints(factory):
return [
factory.for_each(Shift)
.filter(lambda s: ...)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("Constraint name"),
]
solver = SolverFactory.create(config, constraints).build_solver()
solution = solver.solve(problem)| Metric | SolverForge | Native Timefold |
|---|---|---|
| Moves/second | ~80,000-100,000 | ~100,000 |
| Constraint evaluation | WASM (Chicory) | Native JVM |
| Score calculation | Incremental | Incremental |
cargo build --workspace
cargo test --workspace # Requires Java 24
make test-python # Python binding tests
make test-integration # Integration testsTest Counts: 535 core + 197 python
- Rust: 1.75+ (edition 2021)
- Java: 24+ (for embedded service)
- Maven: 3.9+ (for building Java service)
Apache-2.0