A Rust constraint solver library that bridges to Timefold JVM via WebAssembly and HTTP.
What works:
- Language bindings (Rust, Python) are functional
- Infrastructure is complete: HTTP communication, WASM generation, embedded solver service
- Constraint stream API mirrors Timefold's API
- All derive macros and annotations
Known issues:
- Score corruption under certain conditions due to memory layout misalignment between Rust and dynamically-generated Java classes
- Pointer handling issues in the WASM/Java boundary layer
These issues stem from the fundamental challenge of cross-language constraint solving: the hot path evaluates millions of moves, and any boundary crossing in this tight loop compounds dramatically. For more details, see Why Java Interop is Difficult in SolverForge Core.
Recommendation: Use FULL_ASSERT mode during development to catch score corruption early. Production use should be validated thoroughly.
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 (requires Python 3.13+):
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