Documentation
Constraint Factory Methods
Use ConstraintFactory and generated collection accessors as the typed entry points for constraint streams.
ConstraintFactory<Solution, Score> is the typed entry point for constraint
streams. In normal application constraints, start each independent stream source
from a fresh zero-state factory and then call the generated collection accessor,
such as Streams::new().shifts() or Streams::new().employees().
Use raw for_each(...) only for custom collection surfaces that are not
generated from the planning solution. Generated methods carry source ownership
metadata that raw extractor closures do not carry.
Standard Pattern
use solverforge::prelude::*;
use solverforge::stream::{joiner::*, ConstraintFactory};
fn define_constraints() -> impl ConstraintSet<Schedule, HardSoftScore> {
type Streams = ConstraintFactory<Schedule, HardSoftScore>;
(
Streams::new()
.shifts()
.unassigned()
.penalize_hard()
.named("Unassigned shift"),
Streams::new()
.shifts()
.join((
Streams::new().employees(),
equal_bi(
|shift: &Shift| shift.employee_idx,
|employee: &Employee| Some(employee.index),
),
))
.filter(|shift: &Shift, employee: &Employee| {
!employee.skills.contains(&shift.required_skill)
})
.penalize_hard()
.named("Missing skill"),
)
}
The generated accessor traits live next to the model types emitted by
#[planning_solution]. When constraints live in the same module as the model,
the generated traits are already in scope. When constraints live in another
module, import the generated traits from the model module, for example
use crate::domain::{ScheduleConstraintStreams, ShiftUnassignedFilter};.
ConstraintFactory::new()
ConstraintFactory::<Solution, Score>::new() constructs the zero-state factory
for a concrete planning solution and score type.
let factory = ConstraintFactory::<Schedule, HardSoftScore>::new();
The solution type is the struct annotated with #[planning_solution]. The score
type is the same score type used by the #[planning_score] field, such as
SoftScore, HardSoftScore, HardMediumSoftScore, HardSoftDecimalScore, or
BendableScore.
ConstraintFactory also implements Default, so these are equivalent:
let explicit = ConstraintFactory::<Schedule, HardSoftScore>::new();
let defaulted = ConstraintFactory::<Schedule, HardSoftScore>::default();
Prefer new() in documentation and application code because it makes the
solution and score binding obvious at the top of the constraint function.
Generated accessors consume the factory value so stream builder types remain
concrete. ConstraintFactory stores no runtime solution data, so construct a
fresh factory for each independent source:
type Streams = ConstraintFactory<Schedule, HardSoftScore>;
let unassigned = Streams::new()
.shifts()
.unassigned()
.penalize_hard()
.named("Unassigned shift");
let preference = Streams::new()
.shifts()
.filter(|shift| shift.is_preferred())
.reward_soft()
.named("Preferred assignment");
Generated Collection Accessors
For every collection field on a #[planning_solution], SolverForge generates a
method with the same name as the field:
#[planning_solution(constraints = "define_constraints")]
pub struct Schedule {
#[planning_entity_collection]
pub shifts: Vec<Shift>,
#[problem_fact_collection]
pub employees: Vec<Employee>,
#[planning_score]
pub score: Option<HardSoftScore>,
}
This solution generates:
Streams::new().shifts()
Streams::new().employees()
The generated trait name is {Solution}ConstraintStreams, so Schedule emits
ScheduleConstraintStreams<Sc>. The implementation is provided for
ConstraintFactory<Schedule, Sc>.
Each generated method returns a UniConstraintStream over the collection item
type. The stream starts with the default true filter and a source-aware
extractor, so it can be used directly as a constraint source or as the
right-hand collection in a keyed cross-join.
Use these methods as the first operation in a stream:
Streams::new()
.shifts()
.filter(|shift| shift.employee_idx.is_none())
.penalize_hard()
.named("Unassigned shift")
Use generated methods again when joining another solution collection:
Streams::new()
.shifts()
.join((
Streams::new().employees(),
equal_bi(
|shift: &Shift| shift.employee_idx,
|employee: &Employee| Some(employee.index),
),
))
Do not replace generated methods with custom slice-returning helpers unless you intentionally need a lower-level custom extractor.
Source Metadata
Generated factory methods preserve the source of each collection:
| Solution field annotation | Generated source kind | Incremental meaning |
|---|---|---|
#[planning_entity_collection] |
Descriptor source | The stream owns localized updates for that entity descriptor |
#[problem_fact_collection] |
Static source | The stream reads immutable facts and does not react to entity moves |
That metadata matters for localized incremental scoring. Entity-source streams
can react only to the descriptor that changed; fact-source streams remain
stable. This is important for generated accessors used with operations such as
join, if_exists, if_not_exists, project, flatten_last, balance, and
unassigned.
Custom extractors passed to for_each(...) do not have this source metadata.
They are still supported, but they are a lower-level API.
for_each(...)
for_each(...) starts a stream from any extractor that implements
CollectionExtract<Solution>.
use solverforge::stream::vec;
Streams::new().for_each(vec(|solution: &Schedule| &solution.custom_rows))
Use it when:
- the collection is not a generated planning entity or problem fact collection
- you are intentionally exposing a custom view of solution data
- you are writing low-level scoring or runtime tests
For Vec<T> fields, use the vec(...) wrapper from the stream API:
use solverforge::stream::vec;
Streams::new().for_each(vec(|solution: &Schedule| &solution.custom_rows))
For normal application constraints over generated solution fields, prefer the generated accessor:
Streams::new().shifts()
instead of:
Streams::new().for_each(vec(|solution: &Schedule| &solution.shifts))
Generated unassigned()
unassigned() is not a ConstraintFactory method, but it is generated for
streams of planning entities that have exactly one Option<_> planning
variable.
Streams::new()
.shifts()
.unassigned()
.penalize_hard()
.named("Unassigned shift")
The generated trait name is {Entity}UnassignedFilter, so Shift emits
ShiftUnassignedFilter. Bring that trait into scope where you call
.unassigned().
Projected Rows
Generated accessors are the preferred source for projected scoring rows because the projection can keep the same localized source ownership.
Streams::new()
.shifts()
.project(ShiftPenaltyProjection)
.penalize_with(|row: &ShiftPenalty| row.score)
.named("Shift penalty")
Joined-pair projection should also start from generated methods on both sides:
Streams::new()
.assignments()
.join((
Streams::new().capacities(),
equal_bi(
|assignment: &Assignment| assignment.capacity_id,
|capacity: &Capacity| Some(capacity.id),
),
))
.project(|assignment: &Assignment, capacity: &Capacity| CapacityViolation {
assignment_id: assignment.id,
score: HardSoftScore::of_hard((assignment.demand - capacity.amount).max(0)),
})
.penalize_with(|row: &CapacityViolation| row.score)
.named("Capacity violation")
Naming Rules
Generated factory methods use the exact Rust field names from the planning
solution. A field named shifts generates .shifts(). A field named
employee_skills generates .employee_skills().
The methods do not create aliases. If you rename a solution collection field, update every constraint that calls the generated method.
Solver configuration still refers to canonical entity and variable descriptor names, not these stream method names.
Common Mistakes
- Starting normal constraints with
Streams::new().for_each(vec(|s: &Schedule| &s.shifts))instead ofStreams::new().shifts(). - Joining facts through a custom extractor or closure when
.employees()is generated. - Forgetting to import
{Solution}ConstraintStreamsbefore calling generated collection methods. - Forgetting to import
{Entity}UnassignedFilterbefore calling.unassigned(). - Expecting generated method aliases that do not match the solution field name.
- Using
for_each(...)for projected rows that should keep localized source ownership.
See Also
- Constraint Streams - the stream operation pipeline
- Projected Scoring Rows - retained scoring-only rows
- Planning Solutions - generated solution helpers