SolverForge
Rust-based constraint solver library using WASM modules and HTTP communication
SolverForge is a Rust library for solving constraint satisfaction and optimization problems. It bridges language bindings to the Timefold solver engine via WebAssembly and HTTP, eliminating JNI complexity.
π§ Language Bindings In Progress
User-facing APIs (Python, JavaScript, etc.) are under active development. The documentation below covers the core Rust library, which is intended for developers creating new language bindings β not for end users.
If you want to experiment with SolverForge today, you can use the core library directly from Rust. This is not recommended for production use and the API may change without notice.
Key Features
- Language-agnostic core: Pure Rust library with planned bindings for Python, JavaScript, etc.
- WASM-based constraints: Constraint predicates compile to WebAssembly for portable execution
- HTTP interface: Clean JSON/HTTP communication with the Timefold solver service
- Full constraint streams: forEach, filter, join, groupBy, penalize, reward, and more
Getting Started
Documentation
1 - SolverForge Core
Complete reference documentation for the solverforge-core Rust library
SolverForge
SolverForge is a Rust library for solving constraint satisfaction and optimization problems. It bridges language bindings to the Timefold solver engine via WebAssembly and HTTP, eliminating JNI complexity.
Key Features
- Language-agnostic core: Pure Rust library with planned bindings for Python, JavaScript, etc.
- WASM-based constraints: Constraint predicates compile to WebAssembly for portable execution
- HTTP interface: Clean JSON/HTTP communication with the Timefold solver service
- Full constraint streams: forEach, filter, join, groupBy, penalize, reward, and more
Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Language Bindings β
β (Python, JavaScript, etc.) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β solverforge-core (Rust) β
β ββββββββββββββ ββββββββββββββ ββββββββββββββ βββββββββββββββ
β β Domain β β Constraint β β WASM β β HTTP ββ
β β Model β β Streams β β Builder β β Client ββ
β ββββββββββββββ ββββββββββββββ ββββββββββββββ βββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
HTTP/JSON
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β timefold-wasm-service (Java) β
β ββββββββββββββ ββββββββββββββ ββββββββββββββ βββββββββββββββ
β β Chicory β β Dynamic β β Timefold β β Host ββ
β βWASM Runtimeβ β Class Gen β β Solver β β Functions ββ
β ββββββββββββββ ββββββββββββββ ββββββββββββββ βββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Getting Started
For user-facing quickstarts, see the Getting Started guides, including the Rust Quickstart which demonstrates using the core library directly.
Documentation Sections
Requirements
- Rust: 1.70+
- Java: 24+ (for the solver service)
- Maven: 3.9+ (for building the Java service)
Current Status
SolverForge is in active development. The MVP supports:
- Domain model definition with planning annotations
- Constraint streams: forEach, filter, join, groupBy, complement, flattenLast, penalize, reward
- Score types: Simple, HardSoft, HardMediumSoft, Bendable (with Decimal variants)
- WASM module generation with proper memory alignment
- End-to-end solving via HTTP
1.1 - Concepts
Understand the core concepts behind SolverForge
This section covers the foundational concepts you need to understand when working with SolverForge.
In This Section
Overview
SolverForge solves constraint satisfaction and optimization problems (CSPs). Given:
- A set of planning entities with planning variables to assign
- A set of constraints that define valid and preferred solutions
- An objective function (score) to optimize
The solver searches for an assignment of values to planning variables that:
- Satisfies all hard constraints (feasibility)
- Optimizes soft constraints (quality)
Example: Employee Scheduling
| Concept | Example |
|---|
| Planning Entity | Shift |
| Planning Variable | Shift.employee |
| Problem Fact | Employee, Skill |
| Hard Constraint | Employee must have required skill |
| Soft Constraint | Balance assignments fairly |
| Score | HardSoftScore (e.g., 0hard/-5soft) |
The solver tries different employee assignments for each shift, evaluating constraints until it finds a feasible, high-quality solution.
1.1.1 - Architecture
How SolverForge uses WASM and HTTP to solve constraints
SolverForge uses a layered architecture that separates constraint definition (Rust) from solving execution (Java/Timefold).
Overview
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your Application (Rust) β
β β’ Define domain model β
β β’ Build constraints β
β β’ Generate WASM predicates β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
HTTP/JSON
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β timefold-wasm-service (Java) β
β β’ Execute WASM predicates via Chicory runtime β
β β’ Run Timefold solver algorithms β
β β’ Return optimized solution β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Why This Architecture?
WASM for Portability
Constraint predicates are compiled to WebAssembly, which:
- Runs safely in a sandboxed environment
- Executes at near-native speed
- Works across different language runtimes
HTTP for Simplicity
Using HTTP/JSON instead of JNI provides:
- Clean separation between Rust and Java
- Easy debugging (inspect JSON requests/responses)
- Language-agnostic interface for future bindings
Components
solverforge-core (Rust)
The core library provides:
| Module | Purpose |
|---|
domain | Define planning entities and fields |
constraints | Build constraint streams |
wasm | Generate WASM modules |
solver | Configure solver and send requests |
score | Score types (HardSoft, Bendable, etc.) |
timefold-wasm-service (Java)
The solver service handles:
| Component | Purpose |
|---|
| Chicory WASM Runtime | Execute constraint predicates |
| Dynamic Class Generation | Create Java classes from domain DTOs |
| Timefold Solver | Run optimization algorithms |
| Host Functions | Bridge WASM calls to Java operations |
Request Flow
1. Build Domain Model β DomainModel with annotations
2. Create Constraints β Constraint streams (forEach, filter, penalize)
3. Generate WASM β Predicates compiled to WebAssembly
4. Build SolveRequest β JSON payload with domain + constraints + WASM + problem
5. Send HTTP POST β /solve endpoint
6. Solver Executes β Timefold evaluates constraints via WASM
7. Return Solution β JSON response with score and assignments
WASM Memory Layout
Domain objects are stored in WASM linear memory with proper alignment:
| Type | Alignment | Size |
|---|
| int, float, pointer | 4 bytes | 4 bytes |
| long, double, DateTime | 8 bytes | 8 bytes |
Example Shift layout:
Field Offset Size Type
βββββββββββββββββββββββββββββββββββ
id 0 4 String (pointer)
employee 4 4 Employee (pointer)
location 8 4 String (pointer)
[padding] 12 4 (align for DateTime)
start 16 8 LocalDateTime
end 24 8 LocalDateTime
requiredSkill 32 4 String (pointer)
βββββββββββββββββββββββββββββββββββ
Total: 40 bytes
Both Rust (WASM generation) and Java (runtime) use identical alignment rules.
Host Functions
WASM predicates can call host functions for operations that require Java:
| Function | Purpose |
|---|
string_equals | Compare two strings |
list_contains | Check if list contains element |
ranges_overlap | Check if time ranges overlap |
hround | Round float to integer |
These are injected into the WASM module via HostFunctionRegistry.
1.1.2 - Constraint Satisfaction
Core concepts of constraint satisfaction and optimization problems
SolverForge solves constraint satisfaction and optimization problems (CSPs). This page explains the core concepts.
Problem Structure
Every planning problem has:
Planning Entities
Objects that the solver modifies. Each entity has one or more planning variables that the solver assigns.
// Shift is a planning entity
DomainClass::new("Shift")
.with_annotation(PlanningAnnotation::PlanningEntity)
.with_field(
// employee is a planning variable - solver decides the value
FieldDescriptor::new("employee", FieldType::object("Employee"))
.with_planning_annotation(PlanningAnnotation::planning_variable(vec!["employees"]))
)
Problem Facts
Objects that provide data but are not modified by the solver.
// Employee is a problem fact - solver reads but doesn't change it
DomainClass::new("Employee")
.with_field(FieldDescriptor::new("name", FieldType::Primitive(PrimitiveType::String)))
.with_field(FieldDescriptor::new("skills", FieldType::list(FieldType::Primitive(PrimitiveType::String))))
Planning Solution
A container that holds all entities and problem facts, plus the score.
DomainClass::new("Schedule")
.with_annotation(PlanningAnnotation::PlanningSolution)
.with_field(/* employees - problem facts */)
.with_field(/* shifts - planning entities */)
.with_field(/* score - optimization result */)
Constraints
Constraints define what makes a solution valid and good.
Hard Constraints
Must be satisfied for a solution to be feasible. Violations result in negative hard score.
// Every shift must have an employee with the required skill
StreamComponent::for_each("Shift"),
StreamComponent::filter(WasmFunction::new("skillMismatch")),
StreamComponent::penalize("1hard/0soft"),
Soft Constraints
Should be satisfied for a better solution. Violations result in negative soft score.
// Prefer balanced shift distribution
StreamComponent::for_each("Shift"),
StreamComponent::group_by(/* ... */),
StreamComponent::penalize("0hard/1soft"),
Score Types
The score measures solution quality. SolverForge supports multiple score types:
| Score Type | Levels | Example |
|---|
SimpleScore | 1 | -5 |
HardSoftScore | 2 | 0hard/-10soft |
HardMediumSoftScore | 3 | 0hard/0medium/-5soft |
BendableScore | N | Configurable levels |
Score Interpretation
- Hard score = 0: All hard constraints satisfied (feasible)
- Hard score < 0: Hard constraints violated (infeasible)
- Soft score: Higher is better (less negative = fewer soft violations)
Example: 0hard/-5soft is feasible but has 5 soft constraint points violated.
Solving Process
- Initial Solution: Start with planning variables unassigned or randomly assigned
- Move Selection: Choose a move (e.g., assign employee A to shift 1)
- Score Calculation: Evaluate all constraints after the move
- Move Acceptance: Accept or reject based on score improvement
- Termination: Stop when time limit, score limit, or other condition is met
Constraint Streams
Constraints are expressed as pipelines that:
- Select entities:
for_each("Shift") - Filter matches:
filter(predicate) - Join with other entities:
join("Employee") - Group for aggregation:
group_by(key, collector) - Penalize/Reward:
penalize("1hard/0soft")
Example constraint pipeline:
forEach(Shift)
β filter(unassignedEmployee)
β penalize(1hard)
This penalizes every shift that has no employee assigned.
Next Steps
1.2 - Domain Modeling
Define your planning domain with DomainModel, classes, and fields
This section covers how to define your planning domain using SolverForge’s domain modeling API.
In This Section
- Domain Model - Build domain models with
DomainModel, DomainClass, and FieldDescriptor - Planning Annotations - Configure planning behavior with
PlanningAnnotation types - Field Types - Supported field types including primitives, objects, collections, and scores
Overview
A domain model describes the structure of your planning problem:
use solverforge_core::domain::{
DomainClass, DomainModel, FieldDescriptor, FieldType,
PlanningAnnotation, PrimitiveType, ScoreType,
};
let model = DomainModel::builder()
.add_class(
DomainClass::new("Shift")
.with_annotation(PlanningAnnotation::PlanningEntity)
.with_field(/* ... */)
)
.add_class(
DomainClass::new("Schedule")
.with_annotation(PlanningAnnotation::PlanningSolution)
.with_field(/* ... */)
)
.build();
Key Concepts
| Concept | Purpose | Annotation |
|---|
| Planning Entity | Object modified by solver | @PlanningEntity |
| Planning Variable | Field assigned by solver | @PlanningVariable |
| Planning Solution | Container for all data | @PlanningSolution |
| Problem Fact | Read-only data | (no annotation needed) |
| Value Range Provider | Source of variable values | @ValueRangeProvider |
Model Validation
Use build_validated() to catch configuration errors early:
let model = DomainModel::builder()
.add_class(/* ... */)
.build_validated()?; // Returns SolverForgeError on invalid model
Validation checks:
- Solution class exists with
@PlanningSolution - At least one entity class with
@PlanningEntity - Each entity has at least one
@PlanningVariable - Solution class has a
@PlanningScore field
1.2.1 - Domain Model
Build domain models with DomainModel, DomainClass, and FieldDescriptor
The DomainModel is the central data structure that describes your planning domain.
Building a Model
Use the builder pattern to construct a domain model:
use solverforge_core::domain::{
DomainClass, DomainModel, FieldDescriptor, FieldType,
PlanningAnnotation, PrimitiveType, ScoreType,
};
let model = DomainModel::builder()
.add_class(DomainClass::new("Employee")/* ... */)
.add_class(DomainClass::new("Shift")/* ... */)
.add_class(DomainClass::new("Schedule")/* ... */)
.build();
DomainClass
Each class in your domain is defined with DomainClass:
let shift = DomainClass::new("Shift")
.with_annotation(PlanningAnnotation::PlanningEntity)
.with_field(
FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
.with_planning_annotation(PlanningAnnotation::PlanningId),
)
.with_field(
FieldDescriptor::new("employee", FieldType::object("Employee"))
.with_planning_annotation(
PlanningAnnotation::planning_variable(vec!["employees".to_string()])
),
)
.with_field(FieldDescriptor::new(
"start",
FieldType::Primitive(PrimitiveType::DateTime),
))
.with_field(FieldDescriptor::new(
"end",
FieldType::Primitive(PrimitiveType::DateTime),
));
Class Methods
| Method | Purpose |
|---|
DomainClass::new(name) | Create a new class |
.with_annotation(ann) | Add a class-level annotation |
.with_field(field) | Add a field |
.is_planning_entity() | Check if @PlanningEntity |
.is_planning_solution() | Check if @PlanningSolution |
.get_planning_variables() | Iterator over planning variable fields |
FieldDescriptor
Each field is defined with FieldDescriptor:
let employee_field = FieldDescriptor::new("employee", FieldType::object("Employee"))
.with_planning_annotation(PlanningAnnotation::planning_variable(vec!["employees"]))
.with_accessor(DomainAccessor::new("getEmployee", "setEmployee"));
Field Methods
| Method | Purpose |
|---|
FieldDescriptor::new(name, type) | Create a new field |
.with_planning_annotation(ann) | Add a planning annotation |
.with_shadow_annotation(ann) | Add a shadow variable annotation |
.with_accessor(acc) | Override default accessor names |
.is_planning_variable() | Check if @PlanningVariable |
Complete Example
let model = DomainModel::builder()
// Problem fact: Employee (not modified by solver)
.add_class(
DomainClass::new("Employee")
.with_field(
FieldDescriptor::new("name", FieldType::Primitive(PrimitiveType::String))
.with_planning_annotation(PlanningAnnotation::PlanningId),
)
.with_field(FieldDescriptor::new(
"skills",
FieldType::list(FieldType::Primitive(PrimitiveType::String)),
)),
)
// Planning entity: Shift (employee assignment decided by solver)
.add_class(
DomainClass::new("Shift")
.with_annotation(PlanningAnnotation::PlanningEntity)
.with_field(
FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
.with_planning_annotation(PlanningAnnotation::PlanningId),
)
.with_field(
FieldDescriptor::new("employee", FieldType::object("Employee"))
.with_planning_annotation(
PlanningAnnotation::planning_variable(vec!["employees".to_string()])
),
)
.with_field(FieldDescriptor::new(
"requiredSkill",
FieldType::Primitive(PrimitiveType::String),
)),
)
// Planning solution: Schedule (container)
.add_class(
DomainClass::new("Schedule")
.with_annotation(PlanningAnnotation::PlanningSolution)
.with_field(
FieldDescriptor::new("employees", FieldType::list(FieldType::object("Employee")))
.with_planning_annotation(PlanningAnnotation::ProblemFactCollectionProperty)
.with_planning_annotation(PlanningAnnotation::value_range_provider("employees")),
)
.with_field(
FieldDescriptor::new("shifts", FieldType::list(FieldType::object("Shift")))
.with_planning_annotation(PlanningAnnotation::PlanningEntityCollectionProperty),
)
.with_field(
FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
.with_planning_annotation(PlanningAnnotation::planning_score()),
),
)
.build_validated()?;
Converting to DTO
Convert the model to a DTO for the solver request:
let domain_dto = model.to_dto();
The to_dto() method:
- Generates accessor names matching WASM exports:
get_{Class}_{field}, set_{Class}_{field} - Adds setters for planning variables and collection properties
- Adds mapper for the solution class (
parseSchedule, scheduleString)
DomainModel Methods
| Method | Purpose |
|---|
DomainModel::builder() | Create a builder |
.add_class(class) | Add a class |
.build() | Build without validation |
.build_validated() | Build with validation |
model.get_class(name) | Get a class by name |
model.get_solution_class() | Get the solution class |
model.get_entity_classes() | Iterator over entity classes |
model.to_dto() | Convert to solver DTO |
model.validate() | Validate the model |
1.2.2 - Planning Annotations
Configure planning behavior with PlanningAnnotation types
Annotations configure how the solver interprets your domain model.
Class Annotations
PlanningEntity
Marks a class as a planning entity - an object whose planning variables are assigned by the solver.
DomainClass::new("Shift")
.with_annotation(PlanningAnnotation::PlanningEntity)
PlanningSolution
Marks a class as the planning solution - the container for all problem facts, entities, and the score.
DomainClass::new("Schedule")
.with_annotation(PlanningAnnotation::PlanningSolution)
Field Annotations
PlanningId
Marks a field as the unique identifier for instances of the class.
FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
.with_planning_annotation(PlanningAnnotation::PlanningId)
PlanningVariable
Marks a field whose value is determined by the solver. Must reference value range providers.
// Basic planning variable
FieldDescriptor::new("employee", FieldType::object("Employee"))
.with_planning_annotation(
PlanningAnnotation::planning_variable(vec!["employees".to_string()])
)
// Planning variable that allows null (unassigned)
FieldDescriptor::new("room", FieldType::object("Room"))
.with_planning_annotation(
PlanningAnnotation::planning_variable_unassigned(vec!["rooms".to_string()])
)
Parameters:
value_range_provider_refs: List of value range provider IDs that supply valid valuesallows_unassigned: If true, the variable can remain unassigned (null)
PlanningListVariable
Marks a list field where the solver assigns which elements belong to the list.
FieldDescriptor::new("visits", FieldType::list(FieldType::object("Visit")))
.with_planning_annotation(
PlanningAnnotation::planning_list_variable(vec!["visits".to_string()])
)
PlanningScore
Marks the score field on the solution class.
// Standard score
FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
.with_planning_annotation(PlanningAnnotation::planning_score())
// Bendable score with specific levels
FieldDescriptor::new("score", FieldType::Score(ScoreType::Bendable { hard_levels: 2, soft_levels: 3 }))
.with_planning_annotation(PlanningAnnotation::planning_score_bendable(2, 3))
ValueRangeProvider
Marks a field that provides valid values for planning variables.
FieldDescriptor::new("employees", FieldType::list(FieldType::object("Employee")))
.with_planning_annotation(PlanningAnnotation::value_range_provider("employees"))
The ID ("employees") must match the value_range_provider_refs in corresponding planning variables.
ProblemFactCollectionProperty
Marks a collection of problem facts (read-only data) on the solution class.
FieldDescriptor::new("employees", FieldType::list(FieldType::object("Employee")))
.with_planning_annotation(PlanningAnnotation::ProblemFactCollectionProperty)
PlanningEntityCollectionProperty
Marks a collection of planning entities on the solution class.
FieldDescriptor::new("shifts", FieldType::list(FieldType::object("Shift")))
.with_planning_annotation(PlanningAnnotation::PlanningEntityCollectionProperty)
PlanningPin
Marks a boolean field that pins an entity’s assignment (prevents the solver from changing it).
FieldDescriptor::new("pinned", FieldType::Primitive(PrimitiveType::Bool))
.with_planning_annotation(PlanningAnnotation::PlanningPin)
InverseRelationShadowVariable
Marks a shadow variable that tracks the inverse of another planning variable.
FieldDescriptor::new("vehicle", FieldType::object("Vehicle"))
.with_planning_annotation(PlanningAnnotation::inverse_relation_shadow("visits"))
Annotation Summary
| Annotation | Target | Purpose |
|---|
PlanningEntity | Class | Mark as planning entity |
PlanningSolution | Class | Mark as solution container |
PlanningId | Field | Unique identifier |
PlanningVariable | Field | Solver-assigned value |
PlanningListVariable | Field | Solver-assigned list |
PlanningScore | Field | Score field on solution |
ValueRangeProvider | Field | Source of valid values |
ProblemFactCollectionProperty | Field | Problem fact collection |
PlanningEntityCollectionProperty | Field | Entity collection |
PlanningPin | Field | Pin entity assignment |
InverseRelationShadowVariable | Field | Shadow inverse relation |
Helper Methods
use solverforge_core::domain::PlanningAnnotation;
// Create planning variable
PlanningAnnotation::planning_variable(vec!["rooms".to_string()])
PlanningAnnotation::planning_variable_unassigned(vec!["slots".to_string()])
// Create list variable
PlanningAnnotation::planning_list_variable(vec!["tasks".to_string()])
// Create score annotation
PlanningAnnotation::planning_score()
PlanningAnnotation::planning_score_bendable(2, 3)
// Create value range provider
PlanningAnnotation::value_range_provider("timeslots")
// Create shadow variable
PlanningAnnotation::inverse_relation_shadow("visits")
Multiple Annotations
A field can have multiple annotations:
FieldDescriptor::new("employees", FieldType::list(FieldType::object("Employee")))
.with_planning_annotation(PlanningAnnotation::ProblemFactCollectionProperty)
.with_planning_annotation(PlanningAnnotation::value_range_provider("employees"))
1.2.3 - Field Types
Supported field types including primitives, objects, collections, and scores
SolverForge supports various field types for domain modeling.
Primitive Types
Basic value types:
use solverforge_core::domain::{FieldType, PrimitiveType};
// Boolean
FieldType::Primitive(PrimitiveType::Bool)
// Integers
FieldType::Primitive(PrimitiveType::Int) // 32-bit
FieldType::Primitive(PrimitiveType::Long) // 64-bit
// Floating point
FieldType::Primitive(PrimitiveType::Float) // 32-bit
FieldType::Primitive(PrimitiveType::Double) // 64-bit
// String
FieldType::Primitive(PrimitiveType::String)
// Date/Time (stored as epoch values)
FieldType::Primitive(PrimitiveType::Date) // LocalDate - epoch day (i64)
FieldType::Primitive(PrimitiveType::DateTime) // LocalDateTime - epoch second (i64)
Date and Time Handling
Dates and times are stored as integers:
Date (LocalDate): Epoch day (days since 1970-01-01)DateTime (LocalDateTime): Epoch second (seconds since 1970-01-01T00:00:00)
// Field definition
FieldDescriptor::new("start", FieldType::Primitive(PrimitiveType::DateTime))
// JSON representation
// "start": "2025-01-15T08:00:00" is converted to epoch seconds
Object Types
References to other domain classes:
// Reference to Employee class
FieldType::object("Employee")
// Usage in field descriptor
FieldDescriptor::new("employee", FieldType::object("Employee"))
Collection Types
List
Ordered collection (most common):
// List of strings
FieldType::list(FieldType::Primitive(PrimitiveType::String))
// List of objects
FieldType::list(FieldType::object("Shift"))
Array
Fixed-size array (similar to List in behavior):
FieldType::array(FieldType::Primitive(PrimitiveType::Int))
Set
Unordered unique elements:
FieldType::set(FieldType::object("Skill"))
Map
Key-value mapping:
FieldType::map(
FieldType::Primitive(PrimitiveType::String), // Key type
FieldType::object("Employee") // Value type
)
Score Types
Score types for the planning solution:
use solverforge_core::domain::ScoreType;
// Single dimension
FieldType::Score(ScoreType::Simple)
FieldType::Score(ScoreType::SimpleDecimal)
// Two dimensions (hard/soft)
FieldType::Score(ScoreType::HardSoft)
FieldType::Score(ScoreType::HardSoftDecimal)
// Three dimensions (hard/medium/soft)
FieldType::Score(ScoreType::HardMediumSoft)
FieldType::Score(ScoreType::HardMediumSoftDecimal)
// Configurable dimensions
FieldType::Score(ScoreType::Bendable { hard_levels: 2, soft_levels: 3 })
FieldType::Score(ScoreType::BendableDecimal { hard_levels: 2, soft_levels: 3 })
Type String Conversion
FieldType::to_type_string() converts to Java-compatible type strings:
| Rust Type | Java Type String |
|---|
PrimitiveType::Bool | boolean |
PrimitiveType::Int | int |
PrimitiveType::Long | long |
PrimitiveType::Float | float |
PrimitiveType::Double | double |
PrimitiveType::String | String |
PrimitiveType::Date | LocalDate |
PrimitiveType::DateTime | LocalDateTime |
FieldType::object("Foo") | Foo |
FieldType::list(...) | ...[] |
ScoreType::HardSoft | HardSoftScore |
Examples
Employee Class
DomainClass::new("Employee")
.with_field(
FieldDescriptor::new("name", FieldType::Primitive(PrimitiveType::String))
.with_planning_annotation(PlanningAnnotation::PlanningId),
)
.with_field(FieldDescriptor::new(
"skills",
FieldType::list(FieldType::Primitive(PrimitiveType::String)),
))
.with_field(FieldDescriptor::new(
"unavailableDates",
FieldType::list(FieldType::Primitive(PrimitiveType::Date)),
))
Shift Class
DomainClass::new("Shift")
.with_annotation(PlanningAnnotation::PlanningEntity)
.with_field(
FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
.with_planning_annotation(PlanningAnnotation::PlanningId),
)
.with_field(
FieldDescriptor::new("employee", FieldType::object("Employee"))
.with_planning_annotation(PlanningAnnotation::planning_variable(vec!["employees"])),
)
.with_field(FieldDescriptor::new(
"start",
FieldType::Primitive(PrimitiveType::DateTime),
))
.with_field(FieldDescriptor::new(
"end",
FieldType::Primitive(PrimitiveType::DateTime),
))
.with_field(FieldDescriptor::new(
"requiredSkill",
FieldType::Primitive(PrimitiveType::String),
))
Schedule Class
DomainClass::new("Schedule")
.with_annotation(PlanningAnnotation::PlanningSolution)
.with_field(
FieldDescriptor::new("employees", FieldType::list(FieldType::object("Employee")))
.with_planning_annotation(PlanningAnnotation::ProblemFactCollectionProperty)
.with_planning_annotation(PlanningAnnotation::value_range_provider("employees")),
)
.with_field(
FieldDescriptor::new("shifts", FieldType::list(FieldType::object("Shift")))
.with_planning_annotation(PlanningAnnotation::PlanningEntityCollectionProperty),
)
.with_field(
FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
.with_planning_annotation(PlanningAnnotation::planning_score()),
)
Collection Checks
let list_type = FieldType::list(FieldType::object("Shift"));
assert!(list_type.is_collection()); // true
let object_type = FieldType::object("Employee");
assert!(!object_type.is_collection()); // false
1.3 - Constraints
Define hard and soft constraints using the constraint streams API
This section covers the constraint streams API for defining constraints.
In This Section
- Constraint Streams - Build constraints with forEach, filter, join, groupBy, and more
- Joiners - Efficiently match entities with equal, lessThan, overlapping, and filtering joiners
- Collectors - Aggregate values with count, sum, average, min, max, toList, toSet, and loadBalance
Overview
Constraints are defined as pipelines of stream operations:
use solverforge_core::{Collector, StreamComponent, WasmFunction};
use indexmap::IndexMap;
let mut constraints = IndexMap::new();
constraints.insert(
"requiredSkill".to_string(),
vec![
StreamComponent::for_each("Shift"),
StreamComponent::filter(WasmFunction::new("skillMismatch")),
StreamComponent::penalize("1hard/0soft"),
],
);
Constraint Pipeline Pattern
Every constraint follows this pattern:
Source β [Filter/Join/Group] β Scoring
- Source: Select entities (
for_each, for_each_unique_pair) - Transform: Filter, join, group, or map the stream
- Score: Apply penalty or reward
Penalties and rewards use score format strings:
| Format | Score Type | Example |
|---|
"1hard" | Hard constraint | "1hard/0soft" |
"1soft" | Soft constraint | "0hard/1soft" |
"1hard/5soft" | Combined | Hard and soft |
"1" | Simple | Single dimension |
Common Constraint Patterns
Simple Filter
// Penalize unassigned shifts
StreamComponent::for_each("Shift"),
StreamComponent::filter(WasmFunction::new("hasNoEmployee")),
StreamComponent::penalize("1hard/0soft"),
Pairwise Comparison
// Penalize overlapping shifts for same employee
StreamComponent::for_each("Shift"),
StreamComponent::join_with_joiners("Shift", vec![
Joiner::equal(WasmFunction::new("get_Shift_employee"))
]),
StreamComponent::filter(WasmFunction::new("shiftsOverlap")),
StreamComponent::penalize("1hard/0soft"),
Load Balancing
// Balance shift assignments across employees
StreamComponent::for_each("Shift"),
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
),
StreamComponent::complement("Employee"),
StreamComponent::group_by(
vec![],
vec![Collector::load_balance_with_load(
WasmFunction::new("pick1"),
WasmFunction::new("pick2")
)]
),
StreamComponent::penalize_with_weigher("0hard/1soft", WasmFunction::new("scaleByFloat")),
1.3.1 - Constraint Streams
Build constraints with forEach, filter, join, groupBy, and more
Constraint streams are pipelines of operations that select, filter, and score entities.
Stream Operations
Source Operations
for_each
Select all instances of a class:
StreamComponent::for_each("Shift")
for_each_including_unassigned
Include entities with unassigned planning variables:
StreamComponent::for_each_including_unassigned("Shift")
for_each_unique_pair
Select unique pairs of the same class (avoiding duplicates and self-pairs):
StreamComponent::for_each_unique_pair("Shift")
// With joiners for efficient matching
StreamComponent::for_each_unique_pair_with_joiners(
"Shift",
vec![Joiner::equal(WasmFunction::new("get_Shift_employee"))]
)
Filter Operations
filter
Keep only elements matching a predicate:
StreamComponent::filter(WasmFunction::new("skillMismatch"))
The predicate is a WASM function that returns true to include the element.
Join Operations
join
Join with another class:
StreamComponent::join("Employee")
// With joiners
StreamComponent::join_with_joiners(
"Shift",
vec![Joiner::equal(WasmFunction::new("get_Shift_employee"))]
)
if_exists
Keep elements where a matching element exists in another class:
StreamComponent::if_exists("Conflict")
StreamComponent::if_exists_with_joiners(
"Conflict",
vec![Joiner::equal(WasmFunction::new("get_id"))]
)
if_not_exists
Keep elements where no matching element exists:
StreamComponent::if_not_exists("Conflict")
StreamComponent::if_not_exists_with_joiners(
"Conflict",
vec![Joiner::filtering(WasmFunction::new("is_conflict"))]
)
Aggregation Operations
group_by
Group elements by keys and aggregate with collectors:
// Group by employee, count shifts
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
)
// Group by key only (no aggregation)
StreamComponent::group_by_key(WasmFunction::new("get_employee"))
// Aggregate only (no grouping)
StreamComponent::group_by_collector(Collector::count())
map
Transform elements:
StreamComponent::map(vec![
WasmFunction::new("get_employee"),
WasmFunction::new("get_shift_count")
])
StreamComponent::map_single(WasmFunction::new("get_value"))
flatten_last
Flatten a collection in the last position of a tuple:
StreamComponent::flatten_last()
StreamComponent::flatten_last_with_map(WasmFunction::new("get_dates"))
expand
Expand elements by adding computed values:
StreamComponent::expand(vec![WasmFunction::new("compute_extra")])
complement
Add elements from a class that are missing from the current stream:
// After grouping by employee, add employees with zero count
StreamComponent::complement("Employee")
Scoring Operations
penalize
Apply a penalty to matching elements:
// Fixed penalty
StreamComponent::penalize("1hard/0soft")
// Dynamic penalty based on weigher function
StreamComponent::penalize_with_weigher(
"1hard/0soft",
WasmFunction::new("getOverlapMinutes")
)
reward
Apply a reward (negative penalty) to matching elements:
StreamComponent::reward("1soft")
StreamComponent::reward_with_weigher(
"1soft",
WasmFunction::new("getBonus")
)
Complete Examples
Skill Requirement Constraint
// Employee must have the skill required by the shift
constraints.insert(
"requiredSkill".to_string(),
vec![
StreamComponent::for_each("Shift"),
StreamComponent::filter(WasmFunction::new("skillMismatch")),
StreamComponent::penalize("1hard/0soft"),
],
);
No Overlapping Shifts
// Same employee cannot work overlapping shifts
constraints.insert(
"noOverlappingShifts".to_string(),
vec![
StreamComponent::for_each("Shift"),
StreamComponent::join_with_joiners(
"Shift",
vec![Joiner::equal(WasmFunction::new("get_Shift_employee"))]
),
StreamComponent::filter(WasmFunction::new("shiftsOverlap")),
StreamComponent::penalize_with_weigher(
"1hard/0soft",
WasmFunction::new("getMinuteOverlap")
),
],
);
Balance Shift Assignments
// Distribute shifts fairly across employees
constraints.insert(
"balanceEmployeeShiftAssignments".to_string(),
vec![
StreamComponent::for_each("Shift"),
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
),
StreamComponent::complement("Employee"),
StreamComponent::group_by(
vec![],
vec![Collector::load_balance_with_load(
WasmFunction::new("pick1"),
WasmFunction::new("pick2")
)]
),
StreamComponent::penalize_with_weigher(
"0hard/1soft",
WasmFunction::new("scaleByFloat")
),
],
);
API Reference
| Method | Description |
|---|
for_each(class) | Select all instances |
for_each_including_unassigned(class) | Include unassigned entities |
for_each_unique_pair(class) | Select unique pairs |
for_each_unique_pair_with_joiners(class, joiners) | Pairs with joiners |
filter(predicate) | Filter by predicate |
join(class) | Join with class |
join_with_joiners(class, joiners) | Join with joiners |
if_exists(class) | Keep if match exists |
if_exists_with_joiners(class, joiners) | Conditional with joiners |
if_not_exists(class) | Keep if no match |
if_not_exists_with_joiners(class, joiners) | Negated conditional |
group_by(keys, aggregators) | Group and aggregate |
group_by_key(key) | Group by single key |
group_by_collector(collector) | Aggregate without grouping |
map(mappers) | Transform elements |
map_single(mapper) | Single transformation |
flatten_last() | Flatten collection |
flatten_last_with_map(map) | Flatten with mapping |
expand(mappers) | Add computed values |
complement(class) | Add missing elements |
penalize(weight) | Fixed penalty |
penalize_with_weigher(weight, weigher) | Dynamic penalty |
reward(weight) | Fixed reward |
reward_with_weigher(weight, weigher) | Dynamic reward |
1.3.2 - Joiners
Efficiently match entities with equal, lessThan, overlapping, and filtering joiners
Joiners define how entities are matched in join operations. They enable indexed lookups instead of O(n*m) scans.
Joiner Types
equal
Match entities where a mapped value is equal:
// Same mapping for both sides
Joiner::equal(WasmFunction::new("get_employee"))
// Different mappings for left and right
Joiner::equal_with_mappings(
WasmFunction::new("get_left_timeslot"),
WasmFunction::new("get_right_timeslot")
)
// Custom equality and hash functions
Joiner::equal_with_custom_equals(
WasmFunction::new("get_id"),
WasmFunction::new("ids_equal"),
WasmFunction::new("id_hash")
)
less_than
Match where left < right:
Joiner::less_than(
WasmFunction::new("get_start_time"),
WasmFunction::new("compare_time")
)
Joiner::less_than_with_mappings(
WasmFunction::new("get_left_time"),
WasmFunction::new("get_right_time"),
WasmFunction::new("compare_time")
)
less_than_or_equal
Match where left <= right:
Joiner::less_than_or_equal(
WasmFunction::new("get_priority"),
WasmFunction::new("compare_priority")
)
greater_than
Match where left > right:
Joiner::greater_than(
WasmFunction::new("get_value"),
WasmFunction::new("compare_values")
)
greater_than_or_equal
Match where left >= right:
Joiner::greater_than_or_equal(
WasmFunction::new("get_score"),
WasmFunction::new("compare_scores")
)
overlapping
Match entities with overlapping time ranges:
// Same start/end mapping for both sides
Joiner::overlapping(
WasmFunction::new("get_start"),
WasmFunction::new("get_end")
)
// Different mappings for left and right
Joiner::overlapping_with_mappings(
WasmFunction::new("left_start"),
WasmFunction::new("left_end"),
WasmFunction::new("right_start"),
WasmFunction::new("right_end")
)
// With custom comparator
Joiner::overlapping_with_comparator(
WasmFunction::new("get_start"),
WasmFunction::new("get_end"),
WasmFunction::new("compare_time")
)
filtering
Custom filter predicate (least efficient, use as last resort):
Joiner::filtering(WasmFunction::new("is_compatible"))
Usage Examples
Join Shifts by Employee
StreamComponent::join_with_joiners(
"Shift",
vec![Joiner::equal(WasmFunction::new("get_Shift_employee"))]
)
Find Overlapping Time Slots
StreamComponent::for_each_unique_pair_with_joiners(
"Meeting",
vec![
Joiner::equal(WasmFunction::new("get_room")),
Joiner::overlapping(
WasmFunction::new("get_start"),
WasmFunction::new("get_end")
)
]
)
Conditional Existence Check
StreamComponent::if_exists_with_joiners(
"Constraint",
vec![
Joiner::equal(WasmFunction::new("get_entity_id")),
Joiner::filtering(WasmFunction::new("is_active"))
]
)
Multiple Joiners
Combine joiners for more specific matching (AND logic):
vec![
Joiner::equal(WasmFunction::new("get_employee")), // Same employee
Joiner::less_than( // Earlier shift first
WasmFunction::new("get_start"),
WasmFunction::new("compare_time")
)
]
- Use
equal when possible - enables hash-based indexing - Avoid
filtering alone - no optimization, O(n*m) scan - Combine with
equal first - equal + filtering is faster than filtering alone - Use specific joiners -
overlapping is optimized for interval queries
API Reference
| Method | Description |
|---|
equal(map) | Match on equal mapped values |
equal_with_mappings(left, right) | Different mappings per side |
equal_with_custom_equals(map, eq, hash) | Custom equality/hash |
less_than(map, cmp) | Match where left < right |
less_than_with_mappings(left, right, cmp) | Different mappings |
less_than_or_equal(map, cmp) | Match where left <= right |
greater_than(map, cmp) | Match where left > right |
greater_than_or_equal(map, cmp) | Match where left >= right |
overlapping(start, end) | Match overlapping ranges |
overlapping_with_mappings(ls, le, rs, re) | Different range mappings |
overlapping_with_comparator(start, end, cmp) | Custom comparator |
filtering(filter) | Custom predicate |
1.3.3 - Collectors
Aggregate values with count, sum, average, min, max, toList, toSet, and loadBalance
Collectors aggregate values during group_by operations.
Collector Types
count
Count elements:
// Count all
Collector::count()
// Count distinct
Collector::count_distinct()
// Count with mapping (count unique mapped values)
Collector::count_with_map(WasmFunction::new("get_id"))
sum
Sum numeric values:
Collector::sum(WasmFunction::new("get_duration"))
average
Calculate average:
Collector::average(WasmFunction::new("get_score"))
min / max
Find minimum or maximum with comparator:
Collector::min(
WasmFunction::new("get_time"),
WasmFunction::new("compare_time")
)
Collector::max(
WasmFunction::new("get_priority"),
WasmFunction::new("compare_priority")
)
to_list / to_set
Collect into a collection:
// Collect elements as-is
Collector::to_list()
Collector::to_set()
// Collect mapped values
Collector::to_list_with_map(WasmFunction::new("get_name"))
Collector::to_set_with_map(WasmFunction::new("get_id"))
load_balance
Calculate unfairness metric for balancing:
// Simple load balance (count per entity)
Collector::load_balance(WasmFunction::new("get_employee"))
// Load balance with custom load function
Collector::load_balance_with_load(
WasmFunction::new("pick1"), // Extract entity
WasmFunction::new("pick2") // Extract load value
)
The unfairness value represents how imbalanced the distribution is (0 = perfectly balanced).
compose
Combine multiple collectors:
Collector::compose(
vec![
Collector::count(),
Collector::sum(WasmFunction::new("get_duration"))
],
WasmFunction::new("combine_count_and_sum")
)
conditionally
Apply collector only when predicate matches:
Collector::conditionally(
WasmFunction::new("is_premium"),
Collector::count()
)
collect_and_then
Transform collected result:
Collector::collect_and_then(
Collector::count(),
WasmFunction::new("to_penalty_weight")
)
Usage Examples
Count Shifts per Employee
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
)
Output: Stream of (Employee, count) tuples.
Total Duration per Room
StreamComponent::group_by(
vec![WasmFunction::new("get_room")],
vec![Collector::sum(WasmFunction::new("get_duration"))]
)
Load Balancing
// Group shifts by employee with count
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
),
// Add employees with 0 shifts
StreamComponent::complement("Employee"),
// Calculate unfairness
StreamComponent::group_by(
vec![],
vec![Collector::load_balance_with_load(
WasmFunction::new("pick1"), // Get employee from tuple
WasmFunction::new("pick2") // Get count from tuple
)]
),
// Penalize by unfairness
StreamComponent::penalize_with_weigher(
"0hard/1soft",
WasmFunction::new("scaleByFloat")
)
Multiple Aggregations
StreamComponent::group_by(
vec![WasmFunction::new("get_department")],
vec![
Collector::count(),
Collector::sum(WasmFunction::new("get_salary")),
Collector::average(WasmFunction::new("get_years"))
]
)
Conditional Counting
StreamComponent::group_by(
vec![WasmFunction::new("get_region")],
vec![
Collector::conditionally(
WasmFunction::new("is_active"),
Collector::count()
)
]
)
API Reference
| Method | Description |
|---|
count() | Count elements |
count_distinct() | Count unique elements |
count_with_map(map) | Count unique mapped values |
sum(map) | Sum mapped values |
average(map) | Average of mapped values |
min(map, cmp) | Minimum with comparator |
max(map, cmp) | Maximum with comparator |
to_list() | Collect to list |
to_list_with_map(map) | Collect mapped values to list |
to_set() | Collect to set |
to_set_with_map(map) | Collect mapped values to set |
load_balance(map) | Calculate unfairness |
load_balance_with_load(map, load) | Unfairness with load function |
compose(collectors, combiner) | Combine collectors |
conditionally(predicate, collector) | Conditional collection |
collect_and_then(collector, mapper) | Transform result |
1.4 - Solver
Configure and run the constraint solver
This section covers solver configuration and execution.
In This Section
- Configuration - Configure solver behavior with
SolverConfig and TerminationConfig - Factory - Create and run solvers with
SolverFactory and Solver - Scores - Understand
SimpleScore, HardSoftScore, HardMediumSoftScore, and BendableScore
Overview
Running a solver involves:
- Configure - Set termination conditions, environment mode, and threading
- Build Request - Combine domain, constraints, WASM, and problem data
- Execute - Send to solver service and receive solution
- Interpret - Parse score and solution
use solverforge_core::{SolveRequest, SolveResponse, TerminationConfig};
// Build request with termination
let request = SolveRequest::new(
domain, constraints, wasm_base64,
"alloc", "dealloc", list_accessor, problem_json
)
.with_termination(TerminationConfig::new()
.with_spent_limit("PT5M")
.with_best_score_feasible(true)
);
// Send to solver
let response: SolveResponse = client
.post(&format!("{}/solve", service.url()))
.json(&request)
.send()?
.json()?;
// Check result
println!("Score: {}", response.score);
if response.score.starts_with("0hard") {
println!("Solution is feasible!");
}
Termination Conditions
The solver continues until a termination condition is met:
| Condition | Description |
|---|
spent_limit | Maximum solving time (e.g., "PT5M") |
best_score_feasible | Stop when feasible |
best_score_limit | Stop at target score |
move_count_limit | Maximum moves |
step_count_limit | Maximum steps |
Multiple conditions can be combined - the solver stops when any is satisfied.
1.4.1 - Solver Configuration
Configure solver behavior with SolverConfig and TerminationConfig
Configure how the solver runs with SolverConfig and TerminationConfig.
SolverConfig
Configure overall solver behavior:
use solverforge_core::{SolverConfig, TerminationConfig, EnvironmentMode, MoveThreadCount};
let config = SolverConfig::new()
.with_solution_class("Schedule")
.with_entity_class("Shift")
.with_environment_mode(EnvironmentMode::Reproducible)
.with_random_seed(42)
.with_move_thread_count(MoveThreadCount::Auto)
.with_termination(TerminationConfig::new()
.with_spent_limit("PT5M")
);
SolverConfig Methods
| Method | Description |
|---|
with_solution_class(class) | Set the solution class name |
with_entity_class(class) | Add an entity class |
with_entity_classes(classes) | Set all entity classes |
with_environment_mode(mode) | Set environment mode |
with_random_seed(seed) | Set random seed for reproducibility |
with_move_thread_count(count) | Set parallel thread count |
with_termination(config) | Set termination configuration |
Environment Modes
| Mode | Description |
|---|
Reproducible | Same seed = same solution (default) |
NonReproducible | Random behavior each run |
NoAssert | Minimal validation (fastest) |
PhaseAssert | Validate after each phase |
StepAssert | Validate after each step |
FullAssert | Maximum validation (slowest) |
TrackedFullAssert | Full validation with tracking |
Move Thread Count
| Option | Description |
|---|
MoveThreadCount::Auto | Use available CPUs |
MoveThreadCount::None | Single-threaded |
MoveThreadCount::Count(n) | Specific thread count |
TerminationConfig
Define when the solver should stop:
let termination = TerminationConfig::new()
.with_spent_limit("PT5M") // Max 5 minutes
.with_unimproved_spent_limit("PT30S") // Stop if no improvement for 30s
.with_best_score_feasible(true) // Stop when feasible
.with_move_count_limit(10000); // Max 10,000 moves
Time-Based Termination
Use ISO-8601 duration format:
// Duration format: PT{hours}H{minutes}M{seconds}S
.with_spent_limit("PT5M") // 5 minutes
.with_spent_limit("PT1H30M") // 1 hour 30 minutes
.with_spent_limit("PT10S") // 10 seconds
.with_unimproved_spent_limit("PT30S") // No improvement timeout
Score-Based Termination
// Stop when feasible (hard score >= 0)
.with_best_score_feasible(true)
// Stop at specific score
.with_best_score_limit("0hard/-100soft")
Count-Based Termination
.with_step_count_limit(1000) // Max solver steps
.with_move_count_limit(10000) // Max moves
.with_unimproved_step_count(100) // Steps without improvement
.with_score_calculation_count_limit(1000000) // Score calculations
Diminished Returns
Stop when improvements become too small:
use solverforge_core::DiminishedReturnsConfig;
let dr = DiminishedReturnsConfig::new()
.with_minimum_improvement_ratio("0.001")
.with_slow_improvement_limit("PT30S");
let termination = TerminationConfig::new()
.with_diminished_returns(dr);
TerminationConfig Methods
| Method | Description |
|---|
with_spent_limit(duration) | Maximum solving time |
with_unimproved_spent_limit(duration) | Timeout without improvement |
with_unimproved_step_count(count) | Steps without improvement |
with_best_score_limit(score) | Target score to reach |
with_best_score_feasible(bool) | Stop when hard >= 0 |
with_step_count_limit(count) | Maximum steps |
with_move_count_limit(count) | Maximum moves |
with_score_calculation_count_limit(count) | Max score calculations |
with_diminished_returns(config) | Diminishing returns config |
Complete Example
let config = SolverConfig::new()
.with_solution_class("Schedule")
.with_entity_class("Shift")
.with_environment_mode(EnvironmentMode::Reproducible)
.with_random_seed(42)
.with_termination(
TerminationConfig::new()
.with_spent_limit("PT10M") // Max 10 minutes
.with_unimproved_spent_limit("PT1M") // No improvement for 1 min
.with_best_score_feasible(true) // Stop if feasible
);
This configuration:
- Solves for up to 10 minutes
- Stops early if no improvement for 1 minute
- Stops immediately when a feasible solution is found
- Uses seed 42 for reproducible results
1.4.2 - Solver Factory
Create and run solvers with SolverFactory and Solver
The solver is executed via HTTP requests to the solver service.
SolveRequest
Build a request containing all solving inputs:
use solverforge_core::{
DomainObjectDto, ListAccessorDto, SolveRequest, TerminationConfig
};
use indexmap::IndexMap;
let request = SolveRequest::new(
domain, // IndexMap<String, DomainObjectDto>
constraints, // IndexMap<String, Vec<StreamComponent>>
wasm_base64, // Base64-encoded WASM module
"alloc".to_string(), // Memory allocator function
"dealloc".to_string(), // Memory deallocator function
list_accessor, // ListAccessorDto
problem_json, // JSON-serialized problem
);
Adding Configuration
let request = SolveRequest::new(/* ... */)
.with_environment_mode("REPRODUCIBLE")
.with_termination(
TerminationConfig::new()
.with_spent_limit("PT5M")
.with_best_score_feasible(true)
);
ListAccessorDto
Define WASM functions for list operations:
let list_accessor = ListAccessorDto::new(
"newList", // Create new list
"getItem", // Get item at index
"setItem", // Set item at index
"size", // Get list size
"append", // Append item
"insert", // Insert at index
"remove", // Remove at index
"dealloc", // Deallocate list
);
Sending Requests
Synchronous Solve
use reqwest::blocking::Client;
use solverforge_core::SolveResponse;
let client = Client::builder()
.timeout(std::time::Duration::from_secs(600))
.build()?;
let response: SolveResponse = client
.post(&format!("{}/solve", service.url()))
.header("Content-Type", "application/json")
.json(&request)
.send()?
.json()?;
println!("Score: {}", response.score);
println!("Solution: {}", response.solution);
Response Structure
pub struct SolveResponse {
pub score: String, // e.g., "0hard/-5soft"
pub solution: String, // JSON-serialized solution
pub stats: Option<SolverStats>,
}
SolveResponse Fields
| Field | Type | Description |
|---|
score | String | Final score (e.g., "0hard/-5soft") |
solution | String | JSON solution with assignments |
stats | Option<SolverStats> | Performance statistics |
Complete Example
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use indexmap::IndexMap;
use reqwest::blocking::Client;
use solverforge_core::{
DomainObjectDto, ListAccessorDto, SolveRequest, SolveResponse,
StreamComponent, TerminationConfig, WasmFunction
};
use solverforge_service::{EmbeddedService, ServiceConfig};
use std::time::Duration;
// Start the solver service
let service = EmbeddedService::start(ServiceConfig::new())?;
// Build domain DTO from model
let domain = model.to_dto();
// Build constraints
let mut constraints = IndexMap::new();
constraints.insert("requiredSkill".to_string(), vec![
StreamComponent::for_each("Shift"),
StreamComponent::filter(WasmFunction::new("skillMismatch")),
StreamComponent::penalize("1hard/0soft"),
]);
// Encode WASM
let wasm_base64 = BASE64.encode(&wasm_bytes);
// Build list accessor
let list_accessor = ListAccessorDto::new(
"newList", "getItem", "setItem", "size",
"append", "insert", "remove", "dealloc"
);
// Problem data as JSON
let problem_json = r#"{"employees": [...], "shifts": [...]}"#;
// Build request
let request = SolveRequest::new(
domain,
constraints,
wasm_base64,
"alloc".to_string(),
"dealloc".to_string(),
list_accessor,
problem_json.to_string(),
)
.with_termination(TerminationConfig::new()
.with_move_count_limit(1000)
);
// Send request
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let response: SolveResponse = client
.post(&format!("{}/solve", service.url()))
.json(&request)
.send()?
.json()?;
// Parse result
println!("Score: {}", response.score);
// Parse solution JSON
let solution: serde_json::Value = serde_json::from_str(&response.solution)?;
let shifts = solution.get("shifts").unwrap().as_array().unwrap();
for shift in shifts {
let id = shift.get("id").unwrap();
let employee = shift.get("employee");
println!("Shift {}: {:?}", id, employee);
}
Error Handling
Check HTTP status and handle errors:
let response = client
.post(&format!("{}/solve", service.url()))
.json(&request)
.send()?;
if !response.status().is_success() {
let error_text = response.text()?;
eprintln!("Solver error: {}", error_text);
return Err(/* error */);
}
let result: SolveResponse = response.json()?;
Interpreting Scores
// Check if solution is feasible
if result.score.starts_with("0hard") || result.score.starts_with("0/") {
println!("Solution is feasible!");
} else {
println!("Solution has hard constraint violations");
}
// Parse score for detailed analysis
let score = solverforge_core::HardSoftScore::parse(&result.score)?;
println!("Hard: {}, Soft: {}", score.hard_score, score.soft_score);
println!("Feasible: {}", score.is_feasible());
1.4.3 - Score Types
Understand SimpleScore, HardSoftScore, HardMediumSoftScore, and BendableScore
Scores measure solution quality. SolverForge supports multiple score types.
Score Trait
All score types implement the Score trait:
pub trait Score {
fn is_feasible(&self) -> bool;
fn is_solution_initialized(&self) -> bool;
fn zero() -> Self;
fn negate(&self) -> Self;
fn add(&self, other: &Self) -> Self;
fn subtract(&self, other: &Self) -> Self;
}
Score Types
SimpleScore
Single dimension score:
use solverforge_core::SimpleScore;
let score = SimpleScore::of(-5);
println!("{}", score); // "-5"
HardSoftScore
Two dimensions - hard constraints and soft constraints:
use solverforge_core::HardSoftScore;
// Create scores
let score = HardSoftScore::of(-2, 10);
let zero = HardSoftScore::ZERO;
let one_hard = HardSoftScore::ONE_HARD;
let one_soft = HardSoftScore::ONE_SOFT;
// Convenience constructors
let hard_only = HardSoftScore::of_hard(-5);
let soft_only = HardSoftScore::of_soft(10);
// Access components
println!("Hard: {}, Soft: {}", score.hard_score, score.soft_score);
// Display format
println!("{}", score); // "-2hard/10soft"
HardMediumSoftScore
Three dimensions - hard, medium, and soft:
use solverforge_core::HardMediumSoftScore;
let score = HardMediumSoftScore::of(-1, 5, 10);
// Convenience constructors
let hard = HardMediumSoftScore::of_hard(-2);
let medium = HardMediumSoftScore::of_medium(3);
let soft = HardMediumSoftScore::of_soft(10);
// Display format
println!("{}", score); // "-1hard/5medium/10soft"
BendableScore
Configurable number of hard and soft levels:
use solverforge_core::BendableScore;
// 2 hard levels, 3 soft levels
let score = BendableScore::of(
vec![-1, 0], // hard scores
vec![5, 10, 2] // soft scores
);
Decimal Variants
For precise decimal arithmetic:
SimpleDecimalScoreHardSoftDecimalScoreHardMediumSoftDecimalScoreBendableDecimalScore
use solverforge_core::HardSoftDecimalScore;
use rust_decimal::Decimal;
let score = HardSoftDecimalScore::of(
Decimal::from(-2),
Decimal::new(105, 1) // 10.5
);
Feasibility
A solution is feasible when all hard constraints are satisfied:
use solverforge_core::HardSoftScore;
let feasible = HardSoftScore::of(0, -100);
let infeasible = HardSoftScore::of(-1, 100);
assert!(feasible.is_feasible()); // true - hard >= 0
assert!(!infeasible.is_feasible()); // false - hard < 0
Score Comparison
Scores are compared lexicographically (hard first, then soft):
use solverforge_core::HardSoftScore;
// Hard score takes priority
assert!(HardSoftScore::of(0, 0) > HardSoftScore::of(-1, 1000));
// Same hard: compare soft
assert!(HardSoftScore::of(0, 10) > HardSoftScore::of(0, 5));
assert!(HardSoftScore::of(0, -5) > HardSoftScore::of(0, -10));
Score Arithmetic
use solverforge_core::HardSoftScore;
let a = HardSoftScore::of(-2, 10);
let b = HardSoftScore::of(-1, 5);
// Addition
let sum = a + b; // -3hard/15soft
// Subtraction
let diff = a - b; // -1hard/5soft
// Negation
let neg = -a; // 2hard/-10soft
Parsing Scores
Parse score strings returned by the solver:
use solverforge_core::HardSoftScore;
// With labels
let score = HardSoftScore::parse("0hard/-5soft")?;
// Without labels
let score = HardSoftScore::parse("-2/-10")?;
// Parse and check feasibility
let score = HardSoftScore::parse(&response.score)?;
if score.is_feasible() {
println!("Solution satisfies all hard constraints");
}
Weight Strings
Penalty/reward weights use score format strings:
// HardSoftScore weights
StreamComponent::penalize("1hard/0soft") // 1 hard point per match
StreamComponent::penalize("0hard/1soft") // 1 soft point per match
StreamComponent::penalize("1hard/5soft") // Both hard and soft
// SimpleScore weights
StreamComponent::penalize("1")
// HardMediumSoftScore weights
StreamComponent::penalize("0hard/1medium/0soft")
Score Type Summary
| Type | Dimensions | Format | Feasible When |
|---|
SimpleScore | 1 | -5 | score >= 0 |
HardSoftScore | 2 | -2hard/10soft | hard >= 0 |
HardMediumSoftScore | 3 | -1hard/5medium/10soft | hard >= 0 |
BendableScore | N | [-1, 0]/[5, 10, 2] | all hard >= 0 |
Constants
use solverforge_core::HardSoftScore;
HardSoftScore::ZERO // 0hard/0soft
HardSoftScore::ONE_HARD // 1hard/0soft
HardSoftScore::ONE_SOFT // 0hard/1soft
1.5 - WASM Generation
Generate WASM modules for constraint predicates
This section covers generating WebAssembly modules for constraint predicates.
In This Section
- Expressions - Build predicate expressions with the Expression API
- Module Builder - Generate WASM modules with WasmModuleBuilder
Overview
Constraint predicates (filter conditions, joiners, weighers) are compiled to WebAssembly. The WASM module is sent to the solver service along with your domain model and constraints.
use solverforge_core::wasm::{
Expr, FieldAccessExt, WasmModuleBuilder, PredicateDefinition,
HostFunctionRegistry,
};
// Build predicate: employee assigned but missing required skill
let predicate = {
let shift = Expr::param(0);
let employee = shift.clone().get("Shift", "employee");
Expr::and(
Expr::is_not_null(employee.clone()),
Expr::not(Expr::list_contains(
employee.get("Employee", "skills"),
shift.get("Shift", "requiredSkill"),
))
)
};
// Build WASM module
let wasm = WasmModuleBuilder::new()
.with_domain_model(model)
.with_host_functions(HostFunctionRegistry::with_standard_functions())
.add_predicate(PredicateDefinition::from_expression("skillMismatch", 1, predicate))
.build()?;
Why WASM?
WebAssembly provides:
- Portability: Same predicates work across platforms
- Safety: Sandboxed execution
- Performance: Near-native speed
- No JNI: Clean HTTP interface instead of complex native bindings
Components
| Component | Purpose |
|---|
Expression | AST for predicate logic |
Expr | Fluent builder for expressions |
PredicateDefinition | Named predicate with arity |
WasmModuleBuilder | Compiles expressions to WASM |
HostFunctionRegistry | Registers host function imports |
1.5.1 - Expressions
Build predicate expressions with the Expression API
The Expression enum represents predicate logic as an abstract syntax tree. Use the Expr helper for fluent construction.
Expr Helper
The Expr struct provides static methods for building expressions:
use solverforge_core::wasm::{Expr, FieldAccessExt};
// Get first parameter
let shift = Expr::param(0);
// Access field with FieldAccessExt trait
let employee = shift.clone().get("Shift", "employee");
// Build predicate: employee != null
let predicate = Expr::is_not_null(employee);
Literals
// Integer literal
Expr::int(42)
// Boolean literal
Expr::bool(true)
Expr::bool(false)
// Null
Expr::null()
Parameter Access
Access predicate function parameters:
// param(0) - first parameter (e.g., Shift in a filter)
// param(1) - second parameter (e.g., in a join)
let first = Expr::param(0);
let second = Expr::param(1);
Field Access
Use the FieldAccessExt trait to chain field access:
use solverforge_core::wasm::FieldAccessExt;
// shift.employee
let employee = Expr::param(0).get("Shift", "employee");
// shift.employee.name (nested)
let name = Expr::param(0)
.get("Shift", "employee")
.get("Employee", "name");
// shift.start
let start = Expr::param(0).get("Shift", "start");
Comparisons
// Equal (==)
Expr::eq(left, right)
// Not equal (!=)
Expr::ne(left, right)
// Less than (<)
Expr::lt(left, right)
// Less than or equal (<=)
Expr::le(left, right)
// Greater than (>)
Expr::gt(left, right)
// Greater than or equal (>=)
Expr::ge(left, right)
Logical Operations
// AND (&&)
Expr::and(left, right)
// OR (||)
Expr::or(left, right)
// NOT (!)
Expr::not(operand)
// Null checks
Expr::is_null(operand)
Expr::is_not_null(operand)
Arithmetic
// Addition (+)
Expr::add(left, right)
// Subtraction (-)
Expr::sub(left, right)
// Multiplication (*)
Expr::mul(left, right)
// Division (/)
Expr::div(left, right)
List Operations
// Check if list contains element
Expr::list_contains(list, element)
// Example: employee.skills contains shift.requiredSkill
let skills = Expr::param(0).get("Shift", "employee").get("Employee", "skills");
let required = Expr::param(0).get("Shift", "requiredSkill");
Expr::list_contains(skills, required)
Host Function Calls
Call host-provided functions:
// Generic host call
Expr::host_call("functionName", vec![arg1, arg2])
// String equality (convenience method)
Expr::string_equals(left, right)
// Time range overlap (convenience method)
Expr::ranges_overlap(start1, end1, start2, end2)
Conditional
// if condition { then } else { else }
Expr::if_then_else(condition, then_branch, else_branch)
// Example: if (x > 0) { 1 } else { 0 }
Expr::if_then_else(
Expr::gt(Expr::param(0), Expr::int(0)),
Expr::int(1),
Expr::int(0)
)
Complete Examples
Skill Mismatch Predicate
// Returns true if employee assigned but doesn't have required skill
let shift = Expr::param(0);
let employee = shift.clone().get("Shift", "employee");
let predicate = Expr::and(
Expr::is_not_null(employee.clone()),
Expr::not(Expr::list_contains(
employee.get("Employee", "skills"),
shift.get("Shift", "requiredSkill"),
))
);
Shifts Overlap Predicate
// Returns true if two shifts overlap in time
let shift1 = Expr::param(0);
let shift2 = Expr::param(1);
let predicate = Expr::ranges_overlap(
shift1.clone().get("Shift", "start"),
shift1.get("Shift", "end"),
shift2.clone().get("Shift", "start"),
shift2.get("Shift", "end"),
);
Gap Too Small Predicate
// Returns true if gap between shifts is less than 10 hours
let shift1 = Expr::param(0);
let shift2 = Expr::param(1);
let shift1_end = shift1.get("Shift", "end");
let shift2_start = shift2.get("Shift", "start");
let gap_seconds = Expr::sub(shift2_start, shift1_end);
let gap_hours = Expr::div(gap_seconds, Expr::int(3600));
let predicate = Expr::lt(gap_hours, Expr::int(10));
Same Employee Check
// Returns true if both shifts have the same employee
let shift1 = Expr::param(0);
let shift2 = Expr::param(1);
let emp1 = shift1.get("Shift", "employee");
let emp2 = shift2.get("Shift", "employee");
let predicate = Expr::and(
Expr::and(
Expr::is_not_null(emp1.clone()),
Expr::is_not_null(emp2.clone())
),
Expr::eq(emp1, emp2)
);
API Reference
| Method | Description |
|---|
Expr::int(value) | Integer literal |
Expr::bool(value) | Boolean literal |
Expr::null() | Null value |
Expr::param(index) | Function parameter |
expr.get(class, field) | Field access |
Expr::eq(l, r) | Equal |
Expr::ne(l, r) | Not equal |
Expr::lt(l, r) | Less than |
Expr::le(l, r) | Less than or equal |
Expr::gt(l, r) | Greater than |
Expr::ge(l, r) | Greater than or equal |
Expr::and(l, r) | Logical AND |
Expr::or(l, r) | Logical OR |
Expr::not(expr) | Logical NOT |
Expr::is_null(expr) | Is null check |
Expr::is_not_null(expr) | Is not null check |
Expr::add(l, r) | Addition |
Expr::sub(l, r) | Subtraction |
Expr::mul(l, r) | Multiplication |
Expr::div(l, r) | Division |
Expr::list_contains(list, elem) | List contains |
Expr::host_call(name, args) | Host function call |
Expr::string_equals(l, r) | String comparison |
Expr::ranges_overlap(s1, e1, s2, e2) | Time overlap |
Expr::if_then_else(c, t, e) | Conditional |
1.5.2 - Module Builder
Generate WASM modules with WasmModuleBuilder
The WasmModuleBuilder compiles expressions into a WebAssembly module.
Basic Usage
use solverforge_core::wasm::{
Expr, FieldAccessExt, HostFunctionRegistry, PredicateDefinition, WasmModuleBuilder,
};
let wasm_bytes = WasmModuleBuilder::new()
.with_domain_model(model)
.with_host_functions(HostFunctionRegistry::with_standard_functions())
.add_predicate(PredicateDefinition::from_expression(
"skillMismatch",
1,
build_skill_mismatch_expression(),
))
.build()?;
Configuration
Domain Model
The domain model is required for field access layout:
let builder = WasmModuleBuilder::new()
.with_domain_model(model);
Host Functions
Register host functions that can be called from WASM:
// Standard functions: string_equals, list_contains, ranges_overlap, etc.
let builder = WasmModuleBuilder::new()
.with_host_functions(HostFunctionRegistry::with_standard_functions());
// Or start with empty registry
let builder = WasmModuleBuilder::new()
.with_host_functions(HostFunctionRegistry::new());
Memory Configuration
let builder = WasmModuleBuilder::new()
.with_initial_memory(16) // 16 pages (1 MB)
.with_max_memory(Some(256)); // Max 256 pages (16 MB)
PredicateDefinition
Define predicates to include in the module:
From Expression
// Predicate with arity (number of parameters)
PredicateDefinition::from_expression(
"skillMismatch", // Name (used in WasmFunction references)
1, // Arity (1 parameter: the Shift)
expression, // The Expression tree
)
// With explicit parameter types
use wasm_encoder::ValType;
PredicateDefinition::from_expression_with_types(
"scaleByFloat",
vec![ValType::F32], // Float parameter
expression,
)
Always True/False
// Predicate that always returns true
PredicateDefinition::always_true("alwaysMatch", 1)
Simple Comparison
use solverforge_core::wasm::{Comparison, FieldAccess};
// Equal comparison between two fields
PredicateDefinition::equal(
"sameEmployee",
FieldAccess::new(0, "Shift", "employee"),
FieldAccess::new(1, "Shift", "employee"),
)
Adding Predicates
let builder = WasmModuleBuilder::new()
.with_domain_model(model)
.with_host_functions(HostFunctionRegistry::with_standard_functions())
.add_predicate(skill_mismatch_predicate)
.add_predicate(shifts_overlap_predicate)
.add_predicate(same_employee_predicate);
Building the Module
As Bytes
let wasm_bytes: Vec<u8> = builder.build()?;
As Base64
let wasm_base64: String = builder.build_base64()?;
Generated Exports
The builder generates these exports:
| Export | Description |
|---|
memory | Linear memory for objects |
alloc | Allocate memory |
dealloc | Deallocate memory |
newList | Create new list |
getItem | Get list item |
setItem | Set list item |
size | Get list size |
append | Append to list |
insert | Insert into list |
remove | Remove from list |
get_{Class}_{field} | Field getter |
set_{Class}_{field} | Field setter (for planning variables) |
{predicate_name} | Your custom predicates |
Complete Example
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use solverforge_core::wasm::{
Expr, FieldAccessExt, HostFunctionRegistry, PredicateDefinition, WasmModuleBuilder,
};
// Build predicates
let skill_mismatch = {
let shift = Expr::param(0);
let employee = shift.clone().get("Shift", "employee");
Expr::and(
Expr::is_not_null(employee.clone()),
Expr::not(Expr::list_contains(
employee.get("Employee", "skills"),
shift.get("Shift", "requiredSkill"),
))
)
};
let shifts_overlap = {
let s1 = Expr::param(0);
let s2 = Expr::param(1);
let emp1 = s1.clone().get("Shift", "employee");
let emp2 = s2.clone().get("Shift", "employee");
Expr::and(
Expr::and(
Expr::is_not_null(emp1.clone()),
Expr::eq(emp1, emp2)
),
Expr::ranges_overlap(
s1.clone().get("Shift", "start"),
s1.get("Shift", "end"),
s2.clone().get("Shift", "start"),
s2.get("Shift", "end"),
)
)
};
// Build module
let wasm_bytes = WasmModuleBuilder::new()
.with_domain_model(model)
.with_host_functions(HostFunctionRegistry::with_standard_functions())
.with_initial_memory(16)
.with_max_memory(Some(256))
.add_predicate(PredicateDefinition::from_expression(
"skillMismatch", 1, skill_mismatch
))
.add_predicate(PredicateDefinition::from_expression(
"shiftsOverlap", 2, shifts_overlap
))
.build()?;
// Encode for HTTP request
let wasm_base64 = BASE64.encode(&wasm_bytes);
API Reference
| Method | Description |
|---|
WasmModuleBuilder::new() | Create builder |
.with_domain_model(model) | Set domain model |
.with_host_functions(registry) | Set host functions |
.with_initial_memory(pages) | Initial memory pages |
.with_max_memory(Some(pages)) | Max memory pages |
.add_predicate(def) | Add predicate |
.build() | Build as Vec<u8> |
.build_base64() | Build as base64 string |
1.6 - Reference
API reference and quick lookup guides
Quick reference guides and API documentation.
In This Section
Import Summary
// Domain modeling
use solverforge_core::domain::{
DomainClass, DomainModel, FieldDescriptor, FieldType,
PlanningAnnotation, PrimitiveType, ScoreType,
};
// Constraints
use solverforge_core::{
Collector, Constraint, ConstraintSet, Joiner,
StreamComponent, WasmFunction,
};
// Scores
use solverforge_core::{
HardSoftScore, HardMediumSoftScore, SimpleScore, BendableScore,
Score, // trait
};
// Solver
use solverforge_core::{
SolveRequest, SolveResponse, SolverConfig, TerminationConfig,
EnvironmentMode, MoveThreadCount,
};
// WASM
use solverforge_core::wasm::{
Expr, FieldAccessExt, Expression,
WasmModuleBuilder, PredicateDefinition, HostFunctionRegistry,
};
// Errors
use solverforge_core::{SolverForgeError, SolverForgeResult};
// Service
use solverforge_service::{EmbeddedService, ServiceConfig};
Crate Structure
solverforge_core
βββ domain # Domain model definitions
β βββ DomainModel
β βββ DomainClass
β βββ FieldDescriptor
β βββ FieldType
β βββ PlanningAnnotation
β βββ PrimitiveType
βββ constraints # Constraint streams
β βββ StreamComponent
β βββ Collector
β βββ Joiner
β βββ WasmFunction
βββ score # Score types
β βββ Score (trait)
β βββ HardSoftScore
β βββ HardMediumSoftScore
β βββ BendableScore
βββ solver # Solver configuration
β βββ SolverConfig
β βββ TerminationConfig
β βββ EnvironmentMode
βββ wasm # WASM generation
β βββ Expression
β βββ Expr
β βββ WasmModuleBuilder
β βββ PredicateDefinition
βββ error # Error types
βββ SolverForgeError
βββ SolverForgeResult
1.6.1 - API Quick Reference
Cheat sheet for common SolverForge APIs
Domain Model
DomainModel
DomainModel::builder()
.add_class(class)
.build() // Build without validation
.build_validated()? // Build with validation
DomainClass
DomainClass::new("ClassName")
.with_annotation(PlanningAnnotation::PlanningEntity)
.with_field(field)
FieldDescriptor
FieldDescriptor::new("fieldName", FieldType::...)
.with_planning_annotation(PlanningAnnotation::...)
Field Types
| Type | Rust |
|---|
| Primitives | FieldType::Primitive(PrimitiveType::String) |
| Object | FieldType::object("ClassName") |
| List | FieldType::list(element_type) |
| Set | FieldType::set(element_type) |
| Array | FieldType::array(element_type) |
| Map | FieldType::map(key_type, value_type) |
| Score | FieldType::Score(ScoreType::HardSoft) |
Primitive Types
| Type | Rust |
|---|
| Boolean | PrimitiveType::Bool |
| Int (32-bit) | PrimitiveType::Int |
| Long (64-bit) | PrimitiveType::Long |
| Float | PrimitiveType::Float |
| Double | PrimitiveType::Double |
| String | PrimitiveType::String |
| Date | PrimitiveType::Date |
| DateTime | PrimitiveType::DateTime |
Planning Annotations
| Annotation | Rust |
|---|
| PlanningId | PlanningAnnotation::PlanningId |
| PlanningEntity | PlanningAnnotation::PlanningEntity |
| PlanningSolution | PlanningAnnotation::PlanningSolution |
| PlanningVariable | PlanningAnnotation::planning_variable(vec!["id"]) |
| PlanningVariable (nullable) | PlanningAnnotation::planning_variable_unassigned(vec![]) |
| PlanningListVariable | PlanningAnnotation::planning_list_variable(vec![]) |
| PlanningScore | PlanningAnnotation::planning_score() |
| PlanningScore (bendable) | PlanningAnnotation::planning_score_bendable(2, 3) |
| ValueRangeProvider | PlanningAnnotation::value_range_provider("id") |
| ProblemFactCollectionProperty | PlanningAnnotation::ProblemFactCollectionProperty |
| PlanningEntityCollectionProperty | PlanningAnnotation::PlanningEntityCollectionProperty |
| PlanningPin | PlanningAnnotation::PlanningPin |
| InverseRelationShadow | PlanningAnnotation::inverse_relation_shadow("var") |
Constraint Streams
StreamComponent
| Operation | Rust |
|---|
| forEach | StreamComponent::for_each("Class") |
| forEach (unassigned) | StreamComponent::for_each_including_unassigned("Class") |
| forEachUniquePair | StreamComponent::for_each_unique_pair("Class") |
| forEachUniquePair (joiners) | StreamComponent::for_each_unique_pair_with_joiners("Class", joiners) |
| filter | StreamComponent::filter(WasmFunction::new("pred")) |
| join | StreamComponent::join("Class") |
| join (joiners) | StreamComponent::join_with_joiners("Class", joiners) |
| ifExists | StreamComponent::if_exists("Class") |
| ifNotExists | StreamComponent::if_not_exists("Class") |
| groupBy | StreamComponent::group_by(keys, collectors) |
| groupBy (key only) | StreamComponent::group_by_key(key) |
| groupBy (collect only) | StreamComponent::group_by_collector(collector) |
| map | StreamComponent::map(mappers) |
| map (single) | StreamComponent::map_single(mapper) |
| flattenLast | StreamComponent::flatten_last() |
| expand | StreamComponent::expand(mappers) |
| complement | StreamComponent::complement("Class") |
| penalize | StreamComponent::penalize("1hard/0soft") |
| penalize (weigher) | StreamComponent::penalize_with_weigher("1hard", weigher) |
| reward | StreamComponent::reward("1soft") |
Joiners
| Joiner | Rust |
|---|
| equal | Joiner::equal(map) |
| equal (separate) | Joiner::equal_with_mappings(left, right) |
| lessThan | Joiner::less_than(map, comparator) |
| greaterThan | Joiner::greater_than(map, comparator) |
| overlapping | Joiner::overlapping(start, end) |
| filtering | Joiner::filtering(filter) |
Collectors
| Collector | Rust |
|---|
| count | Collector::count() |
| countDistinct | Collector::count_distinct() |
| sum | Collector::sum(map) |
| average | Collector::average(map) |
| min | Collector::min(map, comparator) |
| max | Collector::max(map, comparator) |
| toList | Collector::to_list() |
| toSet | Collector::to_set() |
| loadBalance | Collector::load_balance(map) |
| loadBalance (with load) | Collector::load_balance_with_load(map, load) |
| compose | Collector::compose(collectors, combiner) |
| conditionally | Collector::conditionally(pred, collector) |
| collectAndThen | Collector::collect_and_then(collector, mapper) |
Scores
Score Types
| Type | Create | Parse |
|---|
| SimpleScore | SimpleScore::of(-5) | SimpleScore::parse("-5") |
| HardSoftScore | HardSoftScore::of(-2, 10) | HardSoftScore::parse("-2hard/10soft") |
| HardMediumSoftScore | HardMediumSoftScore::of(-1, 5, 10) | HardMediumSoftScore::parse(...) |
| BendableScore | BendableScore::of(hard_vec, soft_vec) | N/A |
Score Methods
score.is_feasible() // hard >= 0
score + other // Addition
score - other // Subtraction
-score // Negation
Solver Configuration
SolverConfig
SolverConfig::new()
.with_solution_class("Schedule")
.with_entity_class("Shift")
.with_environment_mode(EnvironmentMode::Reproducible)
.with_random_seed(42)
.with_move_thread_count(MoveThreadCount::Auto)
.with_termination(termination)
TerminationConfig
TerminationConfig::new()
.with_spent_limit("PT5M")
.with_unimproved_spent_limit("PT30S")
.with_best_score_feasible(true)
.with_best_score_limit("0hard/-100soft")
.with_step_count_limit(1000)
.with_move_count_limit(10000)
WASM Expressions
Expr Builder
| Expression | Rust |
|---|
| Integer | Expr::int(42) |
| Boolean | Expr::bool(true) |
| Null | Expr::null() |
| Parameter | Expr::param(0) |
| Field access | expr.get("Class", "field") |
| Equal | Expr::eq(left, right) |
| Not equal | Expr::ne(left, right) |
| Less than | Expr::lt(left, right) |
| Greater than | Expr::gt(left, right) |
| AND | Expr::and(left, right) |
| OR | Expr::or(left, right) |
| NOT | Expr::not(expr) |
| Is null | Expr::is_null(expr) |
| Is not null | Expr::is_not_null(expr) |
| Add | Expr::add(left, right) |
| Subtract | Expr::sub(left, right) |
| Multiply | Expr::mul(left, right) |
| Divide | Expr::div(left, right) |
| List contains | Expr::list_contains(list, elem) |
| String equals | Expr::string_equals(left, right) |
| Ranges overlap | Expr::ranges_overlap(s1, e1, s2, e2) |
| If-then-else | Expr::if_then_else(cond, then, else) |
WasmModuleBuilder
WasmModuleBuilder::new()
.with_domain_model(model)
.with_host_functions(HostFunctionRegistry::with_standard_functions())
.with_initial_memory(16)
.with_max_memory(Some(256))
.add_predicate(PredicateDefinition::from_expression(name, arity, expr))
.build()? // Vec<u8>
.build_base64()? // String
1.6.2 - Error Handling
Handle SolverForgeError types and troubleshoot common issues
SolverForge uses the SolverForgeError enum for all error types.
SolverForgeError
use solverforge_core::{SolverForgeError, SolverForgeResult};
fn solve_problem() -> SolverForgeResult<String> {
// ... operations that may fail
Ok("solution".to_string())
}
match solve_problem() {
Ok(solution) => println!("Success: {}", solution),
Err(e) => eprintln!("Error: {}", e),
}
Error Variants
Serialization
JSON serialization/deserialization errors:
SolverForgeError::Serialization(String)
Common causes:
- Invalid JSON in problem data
- Malformed score strings
- Type mismatches
Example:
let score = HardSoftScore::parse("invalid")?;
// Error: Serialization error: Invalid HardSoftScore format...
Http
HTTP communication errors with the solver service:
SolverForgeError::Http(String)
Common causes:
- Service not running
- Network timeout
- Connection refused
Solver
Errors returned by the solver service:
SolverForgeError::Solver(String)
Common causes:
- Invalid constraint configuration
- WASM execution failure
- Memory allocation failure
WasmGeneration
Errors during WASM module generation:
SolverForgeError::WasmGeneration(String)
Common causes:
- Invalid expression tree
- Unknown field access
- Missing domain model
Bridge
Language binding bridge errors:
SolverForgeError::Bridge(String)
Common causes:
- Handle invalidation
- Type conversion failures
Validation
Domain model validation errors:
SolverForgeError::Validation(String)
Common causes:
- Missing
@PlanningSolution class - No
@PlanningEntity classes - Missing
@PlanningVariable on entities - Missing
@PlanningScore field
Example:
let model = DomainModel::builder()
.add_class(DomainClass::new("Shift")) // No annotations!
.build_validated()?;
// Error: Validation error: No @PlanningSolution class found
Configuration
Configuration errors:
SolverForgeError::Configuration(String)
Common causes:
- Invalid termination config
- Invalid environment mode
Service
Embedded service lifecycle errors:
SolverForgeError::Service(String)
Common causes:
- Java not found
- Service startup timeout
- Port already in use
Io
Standard I/O errors:
SolverForgeError::Io(std::io::Error)
Common causes:
- File not found
- Permission denied
Other
Generic errors:
SolverForgeError::Other(String)
Error Conversion
SolverForgeError automatically converts from common error types:
// From serde_json::Error
let err: SolverForgeError = serde_json::from_str::<i32>("bad")
.unwrap_err()
.into();
// From std::io::Error
let err: SolverForgeError = std::fs::read("nonexistent")
.unwrap_err()
.into();
Using SolverForgeResult
The type alias simplifies return types:
use solverforge_core::SolverForgeResult;
fn build_model() -> SolverForgeResult<DomainModel> {
let model = DomainModel::builder()
.add_class(/* ... */)
.build_validated()?; // Returns SolverForgeResult
Ok(model)
}
Error Handling Patterns
Match on Variants
match result {
Ok(solution) => { /* handle success */ }
Err(SolverForgeError::Validation(msg)) => {
eprintln!("Model validation failed: {}", msg);
}
Err(SolverForgeError::Http(msg)) => {
eprintln!("Service communication failed: {}", msg);
}
Err(e) => {
eprintln!("Other error: {}", e);
}
}
Propagate with ?
fn solve() -> SolverForgeResult<SolveResponse> {
let model = build_model()?;
let wasm = build_wasm(&model)?;
let response = send_request(&wasm)?;
Ok(response)
}
Convert to String
let error_message = format!("{}", error);
Troubleshooting
“Service not running”
Error: Http error: connection refused
Fix: Start the solver service:
let service = EmbeddedService::start(ServiceConfig::new())?;
“WASM generation failed”
Error: WasmGeneration error: Unknown class 'Shift'
Fix: Ensure domain model is set:
WasmModuleBuilder::new()
.with_domain_model(model) // Required!
“Validation error: No solution class”
Error: Validation error: No @PlanningSolution class found
Fix: Add PlanningSolution annotation:
DomainClass::new("Schedule")
.with_annotation(PlanningAnnotation::PlanningSolution)
Error: Serialization error: Invalid HardSoftScore format
Fix: Use correct format: "0hard/-5soft" not "0/-5"
2 - Installation
Add SolverForge to your Rust project and set up the solver service
Add Dependencies
Add SolverForge to your Cargo.toml:
[dependencies]
solverforge-core = { path = "../solverforge-core" }
solverforge-service = { path = "../solverforge-service" }
# Required dependencies
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
indexmap = { version = "2.0", features = ["serde"] }
base64 = "0.22"
Build the Workspace
# Clone the repository
git clone https://github.com/solverforge/solverforge.git
cd solverforge
# Initialize submodules (required for the Java solver service)
git submodule update --init --recursive
# Build all crates
cargo build --workspace
Solver Service
SolverForge requires a running Java solver service (timefold-wasm-service) to execute the actual solving. The service is included as a Git submodule.
Using EmbeddedService (Recommended)
The EmbeddedService automatically starts and manages the Java service:
use solverforge_service::{EmbeddedService, ServiceConfig};
use std::path::PathBuf;
use std::time::Duration;
let config = ServiceConfig::new()
.with_startup_timeout(Duration::from_secs(120))
.with_java_home(PathBuf::from("/usr/lib64/jvm/java-24-openjdk-24"))
.with_submodule_dir(PathBuf::from("./timefold-wasm-service"));
let service = EmbeddedService::start(config)?;
println!("Service running at {}", service.url());
Building the Service Manually
If you prefer to run the service separately:
cd timefold-wasm-service
# Set Java 24
export JAVA_HOME=/usr/lib64/jvm/java-24-openjdk-24
export PATH=$JAVA_HOME/bin:$PATH
# Build and run
mvn quarkus:dev
The service starts on http://localhost:8080 by default.
Verify Installation
Run the tests to verify everything is working:
# Run all tests (requires Java 24)
JAVA_HOME=/usr/lib64/jvm/java-24-openjdk-24 cargo test --workspace
# Run a specific integration test
JAVA_HOME=/usr/lib64/jvm/java-24-openjdk-24 \
cargo test -p solverforge-service test_employee_scheduling_solve
Project Structure
After setup, your workspace should look like:
your-project/
βββ Cargo.toml
βββ src/
β βββ main.rs
βββ solverforge/ # Cloned repository
βββ solverforge-core/ # Core library
βββ solverforge-service/ # Service management
βββ timefold-wasm-service/ # Java solver (submodule)
3 - Quickstart
Build and run your first constraint solver
This page has moved!
The Rust Quickstart is now part of the main Getting Started section alongside the Python guides.
π Go to Rust Quickstart