Constraint Streams
Constraint streams are the primary way to define constraints in SolverForge. They provide a pipeline-style API where you select entities, transform the stream, and terminate with a scoring impact.
Defining Constraints
Constraints are defined as a function that returns a tuple of constraint objects. The #[planning_solution] macro wires this up automatically.
use solverforge::prelude::*;
fn define_constraints() -> impl ConstraintSet<Schedule, HardSoftScore> {
let factory = ConstraintFactory::<Schedule, HardSoftScore>::new();
(
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.filter(|shift| shift.employee_idx.is_none())
.penalize(HardSoftScore::ONE_HARD)
.named("Unassigned shift"),
)
}
Each constraint builder chain produces an IncrementalUniConstraint (or similar) via .named(). Return them as a tuple — SolverForge implements ConstraintSet for tuples of up to 16 constraints.
Source Operations
for_each
Selects all items from a collection in the solution, using a closure extractor.
factory.for_each(|s: &Schedule| s.shifts.as_slice())
Intermediate Operations
filter
Keeps only elements that match a predicate.
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.filter(|shift| shift.employee_idx.is_none())
join
Combines elements from the same or different collections. The join target determines the behavior:
Self-join — pairs from the same collection, using an equal joiner:
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.join(equal(|shift: &Shift| shift.employee_idx))
Cross-join — pairs from two different collections, using an equal_bi joiner:
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.join((
|s: &Schedule| s.unavailability.as_slice(),
equal_bi(|shift: &Shift| shift.employee_idx, |u: &Unavailability| u.employee_idx),
))
See Joiners for all available joiner types.
flatten_last
Flattens a collection in the last element into individual elements. Takes three arguments: a slice extractor, a key function for the flattened items, and a lookup function for matching.
factory.for_each(|s: &Schedule| s.employees.as_slice())
.join((
|s: &Schedule| s.shifts.as_slice(),
equal_bi(|e: &Employee| e.id, |s: &Shift| s.employee_idx),
))
.flatten_last(
|e: &Employee| e.available_days.as_slice(), // slice extractor
|d| *d, // key for flattened item
|s: &Shift| s.date(), // lookup from A
)
group_by
Groups elements and applies a collector to aggregate.
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.group_by(
|shift: &Shift| shift.employee_idx, // grouping key
count::<Shift>(), // collector
)
balance
Calculates load imbalance across a grouping key. The key function returns Option<K> — None values are skipped (useful for unassigned entities).
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.balance(|shift: &Shift| shift.employee_idx)
if_exists_filtered / if_not_exists_filtered
Filters based on the existence (or absence) of matching entities in another collection.
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.if_exists_filtered(
|s: &Schedule| s.unavailability.clone(),
equal_bi(|shift: &Shift| shift.employee_idx, |u: &Unavailability| u.employee_idx),
)
Terminal Operations
penalize / reward
Apply a fixed score impact per match, then finalize with .named().
.penalize(HardSoftScore::ONE_HARD)
.named("Constraint name")
.reward(HardSoftScore::ONE_SOFT)
.named("Preference bonus")
penalize_hard / penalize_soft / reward_hard / reward_soft
Convenience methods that use the score type’s unit hard or soft value.
.penalize_hard()
.named("Hard violation")
.penalize_soft()
.named("Soft preference")
penalize_hard_with / penalize_soft_with / reward_hard_with
Apply a dynamic score impact based on the matched element.
.penalize_hard_with(|shift: &Shift| HardSoftScore::of(1, shift.overtime_hours() as i64))
.named("Overtime")
penalize_with / reward_with
Apply a fully custom score impact.
.penalize_with(|shift: &Shift| HardSoftScore::of_soft(shift.preference_penalty()))
.named("Preference")
Full Example
use solverforge::prelude::*;
fn define_constraints() -> impl ConstraintSet<Schedule, HardSoftScore> {
let factory = ConstraintFactory::<Schedule, HardSoftScore>::new();
(
// Hard: every shift must be assigned
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.filter(|shift| shift.employee_idx.is_none())
.penalize(HardSoftScore::ONE_HARD)
.named("Unassigned shift"),
// Hard: no employee works two overlapping shifts
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.join(equal(|shift: &Shift| shift.employee_idx))
.filter(|(a, b)| a.overlaps(b))
.penalize(HardSoftScore::ONE_HARD)
.named("Overlap"),
// Soft: prefer assigning employees to their preferred shifts
factory.for_each(|s: &Schedule| s.shifts.as_slice())
.filter(|shift| shift.is_preferred_by_employee())
.reward(HardSoftScore::ONE_SOFT)
.named("Preference"),
)
}
See Also
- Joiners — Controlling how streams are joined
- Collectors — Aggregation functions for
group_by - Score Types — Available score types
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.