This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

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

SectionDescription
ConceptsArchitecture and core concepts
ModelingDefine planning domains
ConstraintsBuild constraint streams
SolverConfigure and run the solver
WASMGenerate WASM predicates
ReferenceAPI reference and error handling

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 - 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:

  1. Satisfies all hard constraints (feasibility)
  2. Optimizes soft constraints (quality)

Example: Employee Scheduling

ConceptExample
Planning EntityShift
Planning VariableShift.employee
Problem FactEmployee, Skill
Hard ConstraintEmployee must have required skill
Soft ConstraintBalance assignments fairly
ScoreHardSoftScore (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 - 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:

ModulePurpose
domainDefine planning entities and fields
constraintsBuild constraint streams
wasmGenerate WASM modules
solverConfigure solver and send requests
scoreScore types (HardSoft, Bendable, etc.)

timefold-wasm-service (Java)

The solver service handles:

ComponentPurpose
Chicory WASM RuntimeExecute constraint predicates
Dynamic Class GenerationCreate Java classes from domain DTOs
Timefold SolverRun optimization algorithms
Host FunctionsBridge 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:

TypeAlignmentSize
int, float, pointer4 bytes4 bytes
long, double, DateTime8 bytes8 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:

FunctionPurpose
string_equalsCompare two strings
list_containsCheck if list contains element
ranges_overlapCheck if time ranges overlap
hroundRound float to integer

These are injected into the WASM module via HostFunctionRegistry.

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 TypeLevelsExample
SimpleScore1-5
HardSoftScore20hard/-10soft
HardMediumSoftScore30hard/0medium/-5soft
BendableScoreNConfigurable 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

  1. Initial Solution: Start with planning variables unassigned or randomly assigned
  2. Move Selection: Choose a move (e.g., assign employee A to shift 1)
  3. Score Calculation: Evaluate all constraints after the move
  4. Move Acceptance: Accept or reject based on score improvement
  5. Termination: Stop when time limit, score limit, or other condition is met

Constraint Streams

Constraints are expressed as pipelines that:

  1. Select entities: for_each("Shift")
  2. Filter matches: filter(predicate)
  3. Join with other entities: join("Employee")
  4. Group for aggregation: group_by(key, collector)
  5. 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

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

ConceptPurposeAnnotation
Planning EntityObject modified by solver@PlanningEntity
Planning VariableField assigned by solver@PlanningVariable
Planning SolutionContainer for all data@PlanningSolution
Problem FactRead-only data(no annotation needed)
Value Range ProviderSource 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

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

MethodPurpose
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

MethodPurpose
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

MethodPurpose
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

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 values
  • allows_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

AnnotationTargetPurpose
PlanningEntityClassMark as planning entity
PlanningSolutionClassMark as solution container
PlanningIdFieldUnique identifier
PlanningVariableFieldSolver-assigned value
PlanningListVariableFieldSolver-assigned list
PlanningScoreFieldScore field on solution
ValueRangeProviderFieldSource of valid values
ProblemFactCollectionPropertyFieldProblem fact collection
PlanningEntityCollectionPropertyFieldEntity collection
PlanningPinFieldPin entity assignment
InverseRelationShadowVariableFieldShadow 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"))

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 TypeJava Type String
PrimitiveType::Boolboolean
PrimitiveType::Intint
PrimitiveType::Longlong
PrimitiveType::Floatfloat
PrimitiveType::Doubledouble
PrimitiveType::StringString
PrimitiveType::DateLocalDate
PrimitiveType::DateTimeLocalDateTime
FieldType::object("Foo")Foo
FieldType::list(...)...[]
ScoreType::HardSoftHardSoftScore

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

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
  1. Source: Select entities (for_each, for_each_unique_pair)
  2. Transform: Filter, join, group, or map the stream
  3. Score: Apply penalty or reward

Weight Format

Penalties and rewards use score format strings:

FormatScore TypeExample
"1hard"Hard constraint"1hard/0soft"
"1soft"Soft constraint"0hard/1soft"
"1hard/5soft"CombinedHard and soft
"1"SimpleSingle 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")),

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())

Transformation Operations

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

MethodDescription
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

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")
    )
]

Performance Tips

  1. Use equal when possible - enables hash-based indexing
  2. Avoid filtering alone - no optimization, O(n*m) scan
  3. Combine with equal first - equal + filtering is faster than filtering alone
  4. Use specific joiners - overlapping is optimized for interval queries

API Reference

MethodDescription
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

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

MethodDescription
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

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:

  1. Configure - Set termination conditions, environment mode, and threading
  2. Build Request - Combine domain, constraints, WASM, and problem data
  3. Execute - Send to solver service and receive solution
  4. 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:

ConditionDescription
spent_limitMaximum solving time (e.g., "PT5M")
best_score_feasibleStop when feasible
best_score_limitStop at target score
move_count_limitMaximum moves
step_count_limitMaximum steps

Multiple conditions can be combined - the solver stops when any is satisfied.

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

MethodDescription
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

ModeDescription
ReproducibleSame seed = same solution (default)
NonReproducibleRandom behavior each run
NoAssertMinimal validation (fastest)
PhaseAssertValidate after each phase
StepAssertValidate after each step
FullAssertMaximum validation (slowest)
TrackedFullAssertFull validation with tracking

Move Thread Count

OptionDescription
MoveThreadCount::AutoUse available CPUs
MoveThreadCount::NoneSingle-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

MethodDescription
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:

  1. Solves for up to 10 minutes
  2. Stops early if no improvement for 1 minute
  3. Stops immediately when a feasible solution is found
  4. Uses seed 42 for reproducible results

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

FieldTypeDescription
scoreStringFinal score (e.g., "0hard/-5soft")
solutionStringJSON solution with assignments
statsOption<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());

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:

  • SimpleDecimalScore
  • HardSoftDecimalScore
  • HardMediumSoftDecimalScore
  • BendableDecimalScore
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

TypeDimensionsFormatFeasible When
SimpleScore1-5score >= 0
HardSoftScore2-2hard/10softhard >= 0
HardMediumSoftScore3-1hard/5medium/10softhard >= 0
BendableScoreN[-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

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

ComponentPurpose
ExpressionAST for predicate logic
ExprFluent builder for expressions
PredicateDefinitionNamed predicate with arity
WasmModuleBuilderCompiles expressions to WASM
HostFunctionRegistryRegisters host function imports

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

MethodDescription
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

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:

ExportDescription
memoryLinear memory for objects
allocAllocate memory
deallocDeallocate memory
newListCreate new list
getItemGet list item
setItemSet list item
sizeGet list size
appendAppend to list
insertInsert into list
removeRemove 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

MethodDescription
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

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

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

TypeRust
PrimitivesFieldType::Primitive(PrimitiveType::String)
ObjectFieldType::object("ClassName")
ListFieldType::list(element_type)
SetFieldType::set(element_type)
ArrayFieldType::array(element_type)
MapFieldType::map(key_type, value_type)
ScoreFieldType::Score(ScoreType::HardSoft)

Primitive Types

TypeRust
BooleanPrimitiveType::Bool
Int (32-bit)PrimitiveType::Int
Long (64-bit)PrimitiveType::Long
FloatPrimitiveType::Float
DoublePrimitiveType::Double
StringPrimitiveType::String
DatePrimitiveType::Date
DateTimePrimitiveType::DateTime

Planning Annotations

AnnotationRust
PlanningIdPlanningAnnotation::PlanningId
PlanningEntityPlanningAnnotation::PlanningEntity
PlanningSolutionPlanningAnnotation::PlanningSolution
PlanningVariablePlanningAnnotation::planning_variable(vec!["id"])
PlanningVariable (nullable)PlanningAnnotation::planning_variable_unassigned(vec![])
PlanningListVariablePlanningAnnotation::planning_list_variable(vec![])
PlanningScorePlanningAnnotation::planning_score()
PlanningScore (bendable)PlanningAnnotation::planning_score_bendable(2, 3)
ValueRangeProviderPlanningAnnotation::value_range_provider("id")
ProblemFactCollectionPropertyPlanningAnnotation::ProblemFactCollectionProperty
PlanningEntityCollectionPropertyPlanningAnnotation::PlanningEntityCollectionProperty
PlanningPinPlanningAnnotation::PlanningPin
InverseRelationShadowPlanningAnnotation::inverse_relation_shadow("var")

Constraint Streams

StreamComponent

OperationRust
forEachStreamComponent::for_each("Class")
forEach (unassigned)StreamComponent::for_each_including_unassigned("Class")
forEachUniquePairStreamComponent::for_each_unique_pair("Class")
forEachUniquePair (joiners)StreamComponent::for_each_unique_pair_with_joiners("Class", joiners)
filterStreamComponent::filter(WasmFunction::new("pred"))
joinStreamComponent::join("Class")
join (joiners)StreamComponent::join_with_joiners("Class", joiners)
ifExistsStreamComponent::if_exists("Class")
ifNotExistsStreamComponent::if_not_exists("Class")
groupByStreamComponent::group_by(keys, collectors)
groupBy (key only)StreamComponent::group_by_key(key)
groupBy (collect only)StreamComponent::group_by_collector(collector)
mapStreamComponent::map(mappers)
map (single)StreamComponent::map_single(mapper)
flattenLastStreamComponent::flatten_last()
expandStreamComponent::expand(mappers)
complementStreamComponent::complement("Class")
penalizeStreamComponent::penalize("1hard/0soft")
penalize (weigher)StreamComponent::penalize_with_weigher("1hard", weigher)
rewardStreamComponent::reward("1soft")

Joiners

JoinerRust
equalJoiner::equal(map)
equal (separate)Joiner::equal_with_mappings(left, right)
lessThanJoiner::less_than(map, comparator)
greaterThanJoiner::greater_than(map, comparator)
overlappingJoiner::overlapping(start, end)
filteringJoiner::filtering(filter)

Collectors

CollectorRust
countCollector::count()
countDistinctCollector::count_distinct()
sumCollector::sum(map)
averageCollector::average(map)
minCollector::min(map, comparator)
maxCollector::max(map, comparator)
toListCollector::to_list()
toSetCollector::to_set()
loadBalanceCollector::load_balance(map)
loadBalance (with load)Collector::load_balance_with_load(map, load)
composeCollector::compose(collectors, combiner)
conditionallyCollector::conditionally(pred, collector)
collectAndThenCollector::collect_and_then(collector, mapper)

Scores

Score Types

TypeCreateParse
SimpleScoreSimpleScore::of(-5)SimpleScore::parse("-5")
HardSoftScoreHardSoftScore::of(-2, 10)HardSoftScore::parse("-2hard/10soft")
HardMediumSoftScoreHardMediumSoftScore::of(-1, 5, 10)HardMediumSoftScore::parse(...)
BendableScoreBendableScore::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

ExpressionRust
IntegerExpr::int(42)
BooleanExpr::bool(true)
NullExpr::null()
ParameterExpr::param(0)
Field accessexpr.get("Class", "field")
EqualExpr::eq(left, right)
Not equalExpr::ne(left, right)
Less thanExpr::lt(left, right)
Greater thanExpr::gt(left, right)
ANDExpr::and(left, right)
ORExpr::or(left, right)
NOTExpr::not(expr)
Is nullExpr::is_null(expr)
Is not nullExpr::is_not_null(expr)
AddExpr::add(left, right)
SubtractExpr::sub(left, right)
MultiplyExpr::mul(left, right)
DivideExpr::div(left, right)
List containsExpr::list_contains(list, elem)
String equalsExpr::string_equals(left, right)
Ranges overlapExpr::ranges_overlap(s1, e1, s2, e2)
If-then-elseExpr::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

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)

“Invalid score format”

Error: Serialization error: Invalid HardSoftScore format

Fix: Use correct format: "0hard/-5soft" not "0/-5"