Constraints
Define hard and soft constraints using the constraint streams API
This section covers the constraint streams API for defining constraints.
In This Section
- Constraint Streams - Build constraints with forEach, filter, join, groupBy, and more
- Joiners - Efficiently match entities with equal, lessThan, overlapping, and filtering joiners
- Collectors - Aggregate values with count, sum, average, min, max, toList, toSet, and loadBalance
Overview
Constraints are defined as pipelines of stream operations:
use solverforge_core::{Collector, StreamComponent, WasmFunction};
use indexmap::IndexMap;
let mut constraints = IndexMap::new();
constraints.insert(
"requiredSkill".to_string(),
vec![
StreamComponent::for_each("Shift"),
StreamComponent::filter(WasmFunction::new("skillMismatch")),
StreamComponent::penalize("1hard/0soft"),
],
);
Constraint Pipeline Pattern
Every constraint follows this pattern:
Source → [Filter/Join/Group] → Scoring
- Source: Select entities (
for_each, for_each_unique_pair) - Transform: Filter, join, group, or map the stream
- Score: Apply penalty or reward
Penalties and rewards use score format strings:
| Format | Score Type | Example |
|---|
"1hard" | Hard constraint | "1hard/0soft" |
"1soft" | Soft constraint | "0hard/1soft" |
"1hard/5soft" | Combined | Hard and soft |
"1" | Simple | Single dimension |
Common Constraint Patterns
Simple Filter
// Penalize unassigned shifts
StreamComponent::for_each("Shift"),
StreamComponent::filter(WasmFunction::new("hasNoEmployee")),
StreamComponent::penalize("1hard/0soft"),
Pairwise Comparison
// Penalize overlapping shifts for same employee
StreamComponent::for_each("Shift"),
StreamComponent::join_with_joiners("Shift", vec![
Joiner::equal(WasmFunction::new("get_Shift_employee"))
]),
StreamComponent::filter(WasmFunction::new("shiftsOverlap")),
StreamComponent::penalize("1hard/0soft"),
Load Balancing
// Balance shift assignments across employees
StreamComponent::for_each("Shift"),
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
),
StreamComponent::complement("Employee"),
StreamComponent::group_by(
vec![],
vec![Collector::load_balance_with_load(
WasmFunction::new("pick1"),
WasmFunction::new("pick2")
)]
),
StreamComponent::penalize_with_weigher("0hard/1soft", WasmFunction::new("scaleByFloat")),
1 - Constraint Streams
Build constraints with forEach, filter, join, groupBy, and more
Constraint streams are pipelines of operations that select, filter, and score entities.
Stream Operations
Source Operations
for_each
Select all instances of a class:
StreamComponent::for_each("Shift")
for_each_including_unassigned
Include entities with unassigned planning variables:
StreamComponent::for_each_including_unassigned("Shift")
for_each_unique_pair
Select unique pairs of the same class (avoiding duplicates and self-pairs):
StreamComponent::for_each_unique_pair("Shift")
// With joiners for efficient matching
StreamComponent::for_each_unique_pair_with_joiners(
"Shift",
vec![Joiner::equal(WasmFunction::new("get_Shift_employee"))]
)
Filter Operations
filter
Keep only elements matching a predicate:
StreamComponent::filter(WasmFunction::new("skillMismatch"))
The predicate is a WASM function that returns true to include the element.
Join Operations
join
Join with another class:
StreamComponent::join("Employee")
// With joiners
StreamComponent::join_with_joiners(
"Shift",
vec![Joiner::equal(WasmFunction::new("get_Shift_employee"))]
)
if_exists
Keep elements where a matching element exists in another class:
StreamComponent::if_exists("Conflict")
StreamComponent::if_exists_with_joiners(
"Conflict",
vec![Joiner::equal(WasmFunction::new("get_id"))]
)
if_not_exists
Keep elements where no matching element exists:
StreamComponent::if_not_exists("Conflict")
StreamComponent::if_not_exists_with_joiners(
"Conflict",
vec![Joiner::filtering(WasmFunction::new("is_conflict"))]
)
Aggregation Operations
group_by
Group elements by keys and aggregate with collectors:
// Group by employee, count shifts
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
)
// Group by key only (no aggregation)
StreamComponent::group_by_key(WasmFunction::new("get_employee"))
// Aggregate only (no grouping)
StreamComponent::group_by_collector(Collector::count())
map
Transform elements:
StreamComponent::map(vec![
WasmFunction::new("get_employee"),
WasmFunction::new("get_shift_count")
])
StreamComponent::map_single(WasmFunction::new("get_value"))
flatten_last
Flatten a collection in the last position of a tuple:
StreamComponent::flatten_last()
StreamComponent::flatten_last_with_map(WasmFunction::new("get_dates"))
expand
Expand elements by adding computed values:
StreamComponent::expand(vec![WasmFunction::new("compute_extra")])
complement
Add elements from a class that are missing from the current stream:
// After grouping by employee, add employees with zero count
StreamComponent::complement("Employee")
Scoring Operations
penalize
Apply a penalty to matching elements:
// Fixed penalty
StreamComponent::penalize("1hard/0soft")
// Dynamic penalty based on weigher function
StreamComponent::penalize_with_weigher(
"1hard/0soft",
WasmFunction::new("getOverlapMinutes")
)
reward
Apply a reward (negative penalty) to matching elements:
StreamComponent::reward("1soft")
StreamComponent::reward_with_weigher(
"1soft",
WasmFunction::new("getBonus")
)
Complete Examples
Skill Requirement Constraint
// Employee must have the skill required by the shift
constraints.insert(
"requiredSkill".to_string(),
vec![
StreamComponent::for_each("Shift"),
StreamComponent::filter(WasmFunction::new("skillMismatch")),
StreamComponent::penalize("1hard/0soft"),
],
);
No Overlapping Shifts
// Same employee cannot work overlapping shifts
constraints.insert(
"noOverlappingShifts".to_string(),
vec![
StreamComponent::for_each("Shift"),
StreamComponent::join_with_joiners(
"Shift",
vec![Joiner::equal(WasmFunction::new("get_Shift_employee"))]
),
StreamComponent::filter(WasmFunction::new("shiftsOverlap")),
StreamComponent::penalize_with_weigher(
"1hard/0soft",
WasmFunction::new("getMinuteOverlap")
),
],
);
Balance Shift Assignments
// Distribute shifts fairly across employees
constraints.insert(
"balanceEmployeeShiftAssignments".to_string(),
vec![
StreamComponent::for_each("Shift"),
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
),
StreamComponent::complement("Employee"),
StreamComponent::group_by(
vec![],
vec![Collector::load_balance_with_load(
WasmFunction::new("pick1"),
WasmFunction::new("pick2")
)]
),
StreamComponent::penalize_with_weigher(
"0hard/1soft",
WasmFunction::new("scaleByFloat")
),
],
);
API Reference
| Method | Description |
|---|
for_each(class) | Select all instances |
for_each_including_unassigned(class) | Include unassigned entities |
for_each_unique_pair(class) | Select unique pairs |
for_each_unique_pair_with_joiners(class, joiners) | Pairs with joiners |
filter(predicate) | Filter by predicate |
join(class) | Join with class |
join_with_joiners(class, joiners) | Join with joiners |
if_exists(class) | Keep if match exists |
if_exists_with_joiners(class, joiners) | Conditional with joiners |
if_not_exists(class) | Keep if no match |
if_not_exists_with_joiners(class, joiners) | Negated conditional |
group_by(keys, aggregators) | Group and aggregate |
group_by_key(key) | Group by single key |
group_by_collector(collector) | Aggregate without grouping |
map(mappers) | Transform elements |
map_single(mapper) | Single transformation |
flatten_last() | Flatten collection |
flatten_last_with_map(map) | Flatten with mapping |
expand(mappers) | Add computed values |
complement(class) | Add missing elements |
penalize(weight) | Fixed penalty |
penalize_with_weigher(weight, weigher) | Dynamic penalty |
reward(weight) | Fixed reward |
reward_with_weigher(weight, weigher) | Dynamic reward |
2 - Joiners
Efficiently match entities with equal, lessThan, overlapping, and filtering joiners
Joiners define how entities are matched in join operations. They enable indexed lookups instead of O(n*m) scans.
Joiner Types
equal
Match entities where a mapped value is equal:
// Same mapping for both sides
Joiner::equal(WasmFunction::new("get_employee"))
// Different mappings for left and right
Joiner::equal_with_mappings(
WasmFunction::new("get_left_timeslot"),
WasmFunction::new("get_right_timeslot")
)
// Custom equality and hash functions
Joiner::equal_with_custom_equals(
WasmFunction::new("get_id"),
WasmFunction::new("ids_equal"),
WasmFunction::new("id_hash")
)
less_than
Match where left < right:
Joiner::less_than(
WasmFunction::new("get_start_time"),
WasmFunction::new("compare_time")
)
Joiner::less_than_with_mappings(
WasmFunction::new("get_left_time"),
WasmFunction::new("get_right_time"),
WasmFunction::new("compare_time")
)
less_than_or_equal
Match where left <= right:
Joiner::less_than_or_equal(
WasmFunction::new("get_priority"),
WasmFunction::new("compare_priority")
)
greater_than
Match where left > right:
Joiner::greater_than(
WasmFunction::new("get_value"),
WasmFunction::new("compare_values")
)
greater_than_or_equal
Match where left >= right:
Joiner::greater_than_or_equal(
WasmFunction::new("get_score"),
WasmFunction::new("compare_scores")
)
overlapping
Match entities with overlapping time ranges:
// Same start/end mapping for both sides
Joiner::overlapping(
WasmFunction::new("get_start"),
WasmFunction::new("get_end")
)
// Different mappings for left and right
Joiner::overlapping_with_mappings(
WasmFunction::new("left_start"),
WasmFunction::new("left_end"),
WasmFunction::new("right_start"),
WasmFunction::new("right_end")
)
// With custom comparator
Joiner::overlapping_with_comparator(
WasmFunction::new("get_start"),
WasmFunction::new("get_end"),
WasmFunction::new("compare_time")
)
filtering
Custom filter predicate (least efficient, use as last resort):
Joiner::filtering(WasmFunction::new("is_compatible"))
Usage Examples
Join Shifts by Employee
StreamComponent::join_with_joiners(
"Shift",
vec![Joiner::equal(WasmFunction::new("get_Shift_employee"))]
)
Find Overlapping Time Slots
StreamComponent::for_each_unique_pair_with_joiners(
"Meeting",
vec![
Joiner::equal(WasmFunction::new("get_room")),
Joiner::overlapping(
WasmFunction::new("get_start"),
WasmFunction::new("get_end")
)
]
)
Conditional Existence Check
StreamComponent::if_exists_with_joiners(
"Constraint",
vec![
Joiner::equal(WasmFunction::new("get_entity_id")),
Joiner::filtering(WasmFunction::new("is_active"))
]
)
Multiple Joiners
Combine joiners for more specific matching (AND logic):
vec![
Joiner::equal(WasmFunction::new("get_employee")), // Same employee
Joiner::less_than( // Earlier shift first
WasmFunction::new("get_start"),
WasmFunction::new("compare_time")
)
]
- Use
equal when possible - enables hash-based indexing - Avoid
filtering alone - no optimization, O(n*m) scan - Combine with
equal first - equal + filtering is faster than filtering alone - Use specific joiners -
overlapping is optimized for interval queries
API Reference
| Method | Description |
|---|
equal(map) | Match on equal mapped values |
equal_with_mappings(left, right) | Different mappings per side |
equal_with_custom_equals(map, eq, hash) | Custom equality/hash |
less_than(map, cmp) | Match where left < right |
less_than_with_mappings(left, right, cmp) | Different mappings |
less_than_or_equal(map, cmp) | Match where left <= right |
greater_than(map, cmp) | Match where left > right |
greater_than_or_equal(map, cmp) | Match where left >= right |
overlapping(start, end) | Match overlapping ranges |
overlapping_with_mappings(ls, le, rs, re) | Different range mappings |
overlapping_with_comparator(start, end, cmp) | Custom comparator |
filtering(filter) | Custom predicate |
3 - Collectors
Aggregate values with count, sum, average, min, max, toList, toSet, and loadBalance
Collectors aggregate values during group_by operations.
Collector Types
count
Count elements:
// Count all
Collector::count()
// Count distinct
Collector::count_distinct()
// Count with mapping (count unique mapped values)
Collector::count_with_map(WasmFunction::new("get_id"))
sum
Sum numeric values:
Collector::sum(WasmFunction::new("get_duration"))
average
Calculate average:
Collector::average(WasmFunction::new("get_score"))
min / max
Find minimum or maximum with comparator:
Collector::min(
WasmFunction::new("get_time"),
WasmFunction::new("compare_time")
)
Collector::max(
WasmFunction::new("get_priority"),
WasmFunction::new("compare_priority")
)
to_list / to_set
Collect into a collection:
// Collect elements as-is
Collector::to_list()
Collector::to_set()
// Collect mapped values
Collector::to_list_with_map(WasmFunction::new("get_name"))
Collector::to_set_with_map(WasmFunction::new("get_id"))
load_balance
Calculate unfairness metric for balancing:
// Simple load balance (count per entity)
Collector::load_balance(WasmFunction::new("get_employee"))
// Load balance with custom load function
Collector::load_balance_with_load(
WasmFunction::new("pick1"), // Extract entity
WasmFunction::new("pick2") // Extract load value
)
The unfairness value represents how imbalanced the distribution is (0 = perfectly balanced).
compose
Combine multiple collectors:
Collector::compose(
vec![
Collector::count(),
Collector::sum(WasmFunction::new("get_duration"))
],
WasmFunction::new("combine_count_and_sum")
)
conditionally
Apply collector only when predicate matches:
Collector::conditionally(
WasmFunction::new("is_premium"),
Collector::count()
)
collect_and_then
Transform collected result:
Collector::collect_and_then(
Collector::count(),
WasmFunction::new("to_penalty_weight")
)
Usage Examples
Count Shifts per Employee
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
)
Output: Stream of (Employee, count) tuples.
Total Duration per Room
StreamComponent::group_by(
vec![WasmFunction::new("get_room")],
vec![Collector::sum(WasmFunction::new("get_duration"))]
)
Load Balancing
// Group shifts by employee with count
StreamComponent::group_by(
vec![WasmFunction::new("get_Shift_employee")],
vec![Collector::count()]
),
// Add employees with 0 shifts
StreamComponent::complement("Employee"),
// Calculate unfairness
StreamComponent::group_by(
vec![],
vec![Collector::load_balance_with_load(
WasmFunction::new("pick1"), // Get employee from tuple
WasmFunction::new("pick2") // Get count from tuple
)]
),
// Penalize by unfairness
StreamComponent::penalize_with_weigher(
"0hard/1soft",
WasmFunction::new("scaleByFloat")
)
Multiple Aggregations
StreamComponent::group_by(
vec![WasmFunction::new("get_department")],
vec![
Collector::count(),
Collector::sum(WasmFunction::new("get_salary")),
Collector::average(WasmFunction::new("get_years"))
]
)
Conditional Counting
StreamComponent::group_by(
vec![WasmFunction::new("get_region")],
vec![
Collector::conditionally(
WasmFunction::new("is_active"),
Collector::count()
)
]
)
API Reference
| Method | Description |
|---|
count() | Count elements |
count_distinct() | Count unique elements |
count_with_map(map) | Count unique mapped values |
sum(map) | Sum mapped values |
average(map) | Average of mapped values |
min(map, cmp) | Minimum with comparator |
max(map, cmp) | Maximum with comparator |
to_list() | Collect to list |
to_list_with_map(map) | Collect mapped values to list |
to_set() | Collect to set |
to_set_with_map(map) | Collect mapped values to set |
load_balance(map) | Calculate unfairness |
load_balance_with_load(map, load) | Unfairness with load function |
compose(collectors, combiner) | Combine collectors |
conditionally(predicate, collector) | Conditional collection |
collect_and_then(collector, mapper) | Transform result |