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

Return to the regular view of this page.

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

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

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