Domain Modeling
Model your planning problem with entities, variables, and solutions.
Domain modeling is the foundation of any SolverForge application. You define your problem structure using Python dataclasses and type annotations.
Core Concepts
Model Structure
A typical SolverForge model consists of:
Planning Solution
├── Problem Facts (immutable data)
│ ├── Timeslots, Rooms, Employees, etc.
│ └── Value Range Providers
├── Planning Entities (mutable)
│ └── Planning Variables (assigned by solver)
└── Score (calculated from constraints)
Example
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
subject: str
teacher: str
# Planning variables - assigned by the solver
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
room: Annotated[Room | None, PlanningVariable] = field(default=None)
@planning_solution
@dataclass
class Timetable:
# Problem facts - immutable
timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
# Planning entities - contain variables to optimize
lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
# Score - calculated by constraints
score: Annotated[HardSoftScore, PlanningScore] = field(default=None)
1 - Planning Entities
Define planning entities that the solver will optimize.
A planning entity is a class whose instances the solver can change during optimization. Planning entities contain planning variables that get assigned values.
The @planning_entity Decorator
Mark a class as a planning entity with @planning_entity:
from dataclasses import dataclass, field
from typing import Annotated
from solverforge_legacy.solver.domain import planning_entity, PlanningId, PlanningVariable
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
subject: str
teacher: str
student_group: str
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
room: Annotated[Room | None, PlanningVariable] = field(default=None)
Planning ID
Every planning entity must have a unique identifier marked with PlanningId:
id: Annotated[str, PlanningId]
The ID is used for:
- Tracking entities during solving
- Cloning solutions
- Score explanation
The ID type can be str, int, or any hashable type.
Genuine vs Shadow Entities
There are two types of planning entities:
Genuine Entities
A genuine planning entity has at least one genuine planning variable that the solver directly assigns:
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
# Genuine variable - solver assigns this
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
Shadow-Only Entities
A shadow-only entity has only shadow variables (calculated from other entities):
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
location: Location
# Shadow variable - calculated from vehicle's visit list
vehicle: Annotated[Vehicle | None, InverseRelationShadowVariable(...)] = field(default=None)
Entity Properties
Immutable Properties
Properties without PlanningVariable annotations are immutable during solving:
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
subject: str # Immutable
teacher: str # Immutable
student_group: str # Immutable
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None) # Mutable
The solver never changes subject, teacher, or student_group.
Default Values
Planning variables should have default values (typically None) for uninitialized state:
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
Multiple Planning Variables
An entity can have multiple planning variables:
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
subject: str
teacher: str
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
room: Annotated[Room | None, PlanningVariable] = field(default=None)
Each variable is assigned independently by the solver.
Entity Collections in Solution
Planning entities are collected in the planning solution:
@planning_solution
@dataclass
class Timetable:
lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
The solver iterates over this collection to find entities to optimize.
Nullable Variables
By default, planning variables must be assigned. For nullable variables (when some entities might be unassigned), see Planning Variables.
Best Practices
Do
- Use
@dataclass for clean, simple entity definitions - Give each entity a unique, stable ID
- Initialize planning variables to
None - Keep entities focused on a single concept
Don’t
- Put business logic in entities (use constraints instead)
- Make planning variables required in
__init__ - Use mutable default arguments (use
field(default_factory=...) instead)
Example: Shift Assignment
@planning_entity
@dataclass
class Shift:
id: Annotated[str, PlanningId]
start_time: datetime
end_time: datetime
required_skill: str
# Assigned by solver
employee: Annotated[Employee | None, PlanningVariable] = field(default=None)
Example: Vehicle Routing
@planning_entity
@dataclass
class Vehicle:
id: Annotated[str, PlanningId]
capacity: int
home_location: Location
# List of visits assigned to this vehicle
visits: Annotated[list[Visit], PlanningListVariable] = field(default_factory=list)
Next Steps
2 - Planning Variables
Define what the solver assigns: simple variables and list variables.
A planning variable is a property of a planning entity that the solver assigns values to during optimization.
Simple Planning Variable
The most common type assigns a single value from a value range:
from dataclasses import dataclass, field
from typing import Annotated
from solverforge_legacy.solver.domain import planning_entity, PlanningId, PlanningVariable
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
subject: str
# Simple planning variable
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
room: Annotated[Room | None, PlanningVariable] = field(default=None)
How It Works
- The solver sees
timeslot needs a value - It looks for a
ValueRangeProvider for Timeslot in the solution - It tries different values and evaluates the score
- It assigns the best value found within the time limit
Planning List Variable
For routing problems where order matters, use PlanningListVariable:
from solverforge_legacy.solver.domain import PlanningListVariable
@planning_entity
@dataclass
class Vehicle:
id: Annotated[str, PlanningId]
capacity: int
home_location: Location
# List variable - ordered sequence of visits
visits: Annotated[list[Visit], PlanningListVariable] = field(default_factory=list)
How It Works
The solver:
- Assigns visits to vehicles
- Determines the order of visits within each vehicle’s route
- Uses moves like insert, swap, and 2-opt for optimization
When to Use List Variables
Use PlanningListVariable when:
- Order matters (routing, sequencing)
- Entities belong to groups (visits per vehicle, tasks per worker)
- Chain relationships exist (predecessor/successor patterns)
Nullable Variables
By default, all planning variables must be assigned. For optional assignments:
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
location: Location
# This visit might not be assigned to any vehicle
vehicle: Annotated[Vehicle | None, PlanningVariable(allows_unassigned=True)] = field(default=None)
Note: When using nullable variables, add medium constraints to penalize unassigned entities.
Value Range Providers
Planning variables need a source of possible values. This is configured in the planning solution:
@planning_solution
@dataclass
class Timetable:
# This list provides values for 'timeslot' variables
timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
# This list provides values for 'room' variables
rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
score: Annotated[HardSoftScore, PlanningScore] = field(default=None)
The solver matches variables to value ranges by type:
timeslot: Annotated[Timeslot | None, PlanningVariable] uses list[Timeslot]room: Annotated[Room | None, PlanningVariable] uses list[Room]
Variable Configuration Options
Strength Comparator
For construction heuristics, you can specify how to order values:
# Stronger values tried first during construction
timeslot: Annotated[
Timeslot | None,
PlanningVariable(value_range_provider_refs=["timeslots"])
] = field(default=None)
Multiple Variables on One Entity
Entities can have multiple independent variables:
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
# Two independent variables
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
room: Annotated[Room | None, PlanningVariable] = field(default=None)
Each variable is optimized independently—assigning timeslot doesn’t affect room.
Chained Variables (Alternative to List)
For simpler routing without list variables, you can use chained planning variables. However, PlanningListVariable is generally easier and more efficient.
Variable Listener Pattern
When one variable affects another, use shadow variables:
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
location: Location
# Calculated from vehicle's visit list
vehicle: Annotated[Vehicle | None, InverseRelationShadowVariable(source_variable_name="visits")] = field(default=None)
# Calculated from previous visit
arrival_time: Annotated[datetime | None, CascadingUpdateShadowVariable(target_method_name="update_arrival_time")] = field(default=None)
See Shadow Variables for details.
Best Practices
Do
- Initialize variables to
None or empty list - Use type hints with
| None for nullable types - Match value range types exactly
Don’t
- Mix list variables with simple variables for the same concept
- Use complex types as planning variables (use references instead)
- Forget to provide a value range
Common Patterns
Scheduling
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
Assignment
employee: Annotated[Employee | None, PlanningVariable] = field(default=None)
Routing
visits: Annotated[list[Visit], PlanningListVariable] = field(default_factory=list)
Next Steps
3 - Planning Solutions
Define the container for problem data and solution score.
A planning solution is the container class that holds all problem data, planning entities, and the solution score.
The @planning_solution Decorator
from dataclasses import dataclass, field
from typing import Annotated
from solverforge_legacy.solver.domain import (
planning_solution,
ProblemFactCollectionProperty,
ProblemFactProperty,
PlanningEntityCollectionProperty,
ValueRangeProvider,
PlanningScore,
)
from solverforge_legacy.solver.score import HardSoftScore
@planning_solution
@dataclass
class Timetable:
id: str
timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
score: Annotated[HardSoftScore, PlanningScore] = field(default=None)
Solution Components
A planning solution contains:
- Problem Facts - Immutable input data
- Planning Entities - Mutable entities with planning variables
- Score - Quality measure of the solution
Problem Facts
Problem facts are immutable data that define the problem:
Collection Property
For lists of facts:
timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty]
rooms: Annotated[list[Room], ProblemFactCollectionProperty]
employees: Annotated[list[Employee], ProblemFactCollectionProperty]
Single Property
For single facts:
config: Annotated[ScheduleConfig, ProblemFactProperty]
start_date: Annotated[date, ProblemFactProperty]
Value Range Providers
Value ranges provide possible values for planning variables. Combine with problem fact annotations:
# This list provides values for Timeslot planning variables
timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
# This list provides values for Room planning variables
rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
The solver automatically matches variables to value ranges by type:
PlanningVariable of type Timeslot uses list[Timeslot]PlanningVariable of type Room uses list[Room]
Multiple Ranges for Same Type
If you have multiple value ranges of the same type, use explicit references:
@planning_solution
@dataclass
class Schedule:
preferred_timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
backup_timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
Planning Entities
Collect planning entities in the solution:
lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
For a single entity:
main_vehicle: Annotated[Vehicle, PlanningEntityProperty]
Score
Every solution needs a score field:
score: Annotated[HardSoftScore, PlanningScore] = field(default=None)
Common score types:
SimpleScore - Single levelHardSoftScore - Feasibility + optimizationHardMediumSoftScore - Three levelsBendableScore - Custom levels
The solver calculates and updates this field automatically.
Solution Identity
Include an identifier for tracking:
@planning_solution
@dataclass
class Timetable:
id: str # For tracking in SolverManager
...
Complete Example
from dataclasses import dataclass, field
from typing import Annotated
from datetime import date
from solverforge_legacy.solver.domain import (
planning_solution,
ProblemFactCollectionProperty,
ProblemFactProperty,
PlanningEntityCollectionProperty,
ValueRangeProvider,
PlanningScore,
)
from solverforge_legacy.solver.score import HardSoftScore
@planning_solution
@dataclass
class EmployeeSchedule:
# Identity
id: str
# Problem facts
schedule_start: Annotated[date, ProblemFactProperty]
employees: Annotated[list[Employee], ProblemFactCollectionProperty, ValueRangeProvider]
skills: Annotated[list[Skill], ProblemFactCollectionProperty]
# Planning entities
shifts: Annotated[list[Shift], PlanningEntityCollectionProperty]
# Score
score: Annotated[HardSoftScore, PlanningScore] = field(default=None)
Creating Problem Instances
Load or generate problem data:
def load_problem() -> Timetable:
timeslots = [
Timeslot("MONDAY", time(8, 30), time(9, 30)),
Timeslot("MONDAY", time(9, 30), time(10, 30)),
# ...
]
rooms = [
Room("Room A"),
Room("Room B"),
]
lessons = [
Lesson("1", "Math", "A. Turing", "9th grade"),
Lesson("2", "Physics", "M. Curie", "9th grade"),
# ...
]
return Timetable(
id="problem-001",
timeslots=timeslots,
rooms=rooms,
lessons=lessons,
score=None, # Solver will calculate this
)
Accessing the Solved Solution
After solving, the solution contains assigned variables and score:
solution = solver.solve(problem)
print(f"Score: {solution.score}")
print(f"Is feasible: {solution.score.is_feasible}")
for lesson in solution.lessons:
print(f"{lesson.subject}: {lesson.timeslot} in {lesson.room}")
Solution Cloning
The solver internally clones solutions to track the best solution. This happens automatically with @dataclass entities.
For custom classes, ensure proper cloning behavior or use @deep_planning_clone:
from solverforge_legacy.solver.domain import deep_planning_clone
@deep_planning_clone
class CustomClass:
# This class will be deeply cloned during solving
pass
Best Practices
Do
- Use
@dataclass for solutions - Initialize score to
None - Include all data needed for constraint evaluation
- Use descriptive field names
Don’t
- Include data not used in constraints (performance impact)
- Modify problem facts during solving
- Forget value range providers for planning variables
Next Steps
4 - Shadow Variables
Define calculated variables that update automatically.
A shadow variable is a planning variable whose value is calculated from other variables, not directly assigned by the solver. Shadow variables update automatically when their source variables change.
When to Use Shadow Variables
Use shadow variables for:
- Derived values - Arrival times calculated from routes
- Inverse relationships - A visit knowing which vehicle it belongs to
- Cascading calculations - End times derived from start times and durations
Shadow Variable Types
Inverse Relation Shadow Variable
Maintains a reverse reference when using list variables:
from solverforge_legacy.solver.domain import InverseRelationShadowVariable
@planning_entity
@dataclass
class Vehicle:
id: Annotated[str, PlanningId]
visits: Annotated[list[Visit], PlanningListVariable] = field(default_factory=list)
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
location: Location
# Automatically set to the vehicle that contains this visit
vehicle: Annotated[
Vehicle | None,
InverseRelationShadowVariable(source_variable_name="visits")
] = field(default=None)
When a visit is added to vehicle.visits, visit.vehicle is automatically set.
Previous Element Shadow Variable
Tracks the previous element in a list variable:
from solverforge_legacy.solver.domain import PreviousElementShadowVariable
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
# The visit that comes before this one in the route
previous_visit: Annotated[
Visit | None,
PreviousElementShadowVariable(source_variable_name="visits")
] = field(default=None)
Next Element Shadow Variable
Tracks the next element in a list variable:
from solverforge_legacy.solver.domain import NextElementShadowVariable
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
# The visit that comes after this one in the route
next_visit: Annotated[
Visit | None,
NextElementShadowVariable(source_variable_name="visits")
] = field(default=None)
Index Shadow Variable
Tracks the position in a list variable:
from solverforge_legacy.solver.domain import IndexShadowVariable
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
# Position in the vehicle's visit list (0-based)
index: Annotated[
int | None,
IndexShadowVariable(source_variable_name="visits")
] = field(default=None)
Cascading Update Shadow Variable
For custom calculations that depend on other variables:
from solverforge_legacy.solver.domain import CascadingUpdateShadowVariable
from datetime import datetime, timedelta
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
location: Location
service_duration: timedelta
vehicle: Annotated[
Vehicle | None,
InverseRelationShadowVariable(source_variable_name="visits")
] = field(default=None)
previous_visit: Annotated[
Visit | None,
PreviousElementShadowVariable(source_variable_name="visits")
] = field(default=None)
# Calculated arrival time
arrival_time: Annotated[
datetime | None,
CascadingUpdateShadowVariable(target_method_name="update_arrival_time")
] = field(default=None)
def update_arrival_time(self):
"""Called automatically when previous_visit or vehicle changes."""
if self.vehicle is None:
self.arrival_time = None
elif self.previous_visit is None:
# First visit: departure from depot
travel_time = self.vehicle.depot.driving_time_to(self.location)
self.arrival_time = self.vehicle.departure_time + travel_time
else:
# Subsequent visit: after previous visit's departure
travel_time = self.previous_visit.location.driving_time_to(self.location)
self.arrival_time = self.previous_visit.departure_time + travel_time
@property
def departure_time(self) -> datetime | None:
"""Time when service at this visit completes."""
if self.arrival_time is None:
return None
return self.arrival_time + self.service_duration
Piggyback Shadow Variable
For variables that should be updated at the same time as another shadow variable:
from solverforge_legacy.solver.domain import PiggybackShadowVariable
@planning_entity
@dataclass
class Visit:
arrival_time: Annotated[
datetime | None,
CascadingUpdateShadowVariable(target_method_name="update_times")
] = field(default=None)
# Updated by the same method as arrival_time
departure_time: Annotated[
datetime | None,
PiggybackShadowVariable(shadow_variable_name="arrival_time")
] = field(default=None)
def update_times(self):
# Update both arrival_time and departure_time
if self.vehicle is None:
self.arrival_time = None
self.departure_time = None
else:
self.arrival_time = self.calculate_arrival()
self.departure_time = self.arrival_time + self.service_duration
Complete Vehicle Routing Example
from dataclasses import dataclass, field
from typing import Annotated
from datetime import datetime, timedelta
from solverforge_legacy.solver.domain import (
planning_entity,
PlanningId,
PlanningListVariable,
InverseRelationShadowVariable,
PreviousElementShadowVariable,
NextElementShadowVariable,
CascadingUpdateShadowVariable,
)
@dataclass
class Location:
latitude: float
longitude: float
def driving_time_to(self, other: "Location") -> timedelta:
# Simplified: assume 1 second per km
distance = ((self.latitude - other.latitude)**2 +
(self.longitude - other.longitude)**2) ** 0.5
return timedelta(seconds=int(distance * 1000))
@planning_entity
@dataclass
class Vehicle:
id: Annotated[str, PlanningId]
depot: Location
departure_time: datetime
capacity: int
visits: Annotated[list["Visit"], PlanningListVariable] = field(default_factory=list)
@planning_entity
@dataclass
class Visit:
id: Annotated[str, PlanningId]
location: Location
demand: int
service_duration: timedelta
ready_time: datetime # Earliest arrival
due_time: datetime # Latest arrival
# Shadow variables
vehicle: Annotated[
Vehicle | None,
InverseRelationShadowVariable(source_variable_name="visits")
] = field(default=None)
previous_visit: Annotated[
"Visit | None",
PreviousElementShadowVariable(source_variable_name="visits")
] = field(default=None)
next_visit: Annotated[
"Visit | None",
NextElementShadowVariable(source_variable_name="visits")
] = field(default=None)
arrival_time: Annotated[
datetime | None,
CascadingUpdateShadowVariable(target_method_name="update_arrival_time")
] = field(default=None)
def update_arrival_time(self):
if self.vehicle is None:
self.arrival_time = None
return
if self.previous_visit is None:
# First visit in route
travel = self.vehicle.depot.driving_time_to(self.location)
self.arrival_time = self.vehicle.departure_time + travel
else:
# After previous visit
prev_departure = self.previous_visit.departure_time
if prev_departure is None:
self.arrival_time = None
return
travel = self.previous_visit.location.driving_time_to(self.location)
self.arrival_time = prev_departure + travel
@property
def departure_time(self) -> datetime | None:
if self.arrival_time is None:
return None
# Wait until ready_time if arriving early
start = max(self.arrival_time, self.ready_time)
return start + self.service_duration
def is_late(self) -> bool:
return self.arrival_time is not None and self.arrival_time > self.due_time
Shadow Variable Evaluation Order
Shadow variables are evaluated in dependency order:
InverseRelationShadowVariable - First (depends only on list variable)PreviousElementShadowVariable - SecondNextElementShadowVariable - SecondIndexShadowVariable - SecondCascadingUpdateShadowVariable - After their dependenciesPiggybackShadowVariable - With their shadow source
Using Shadow Variables in Constraints
Shadow variables can be used in constraints just like regular properties:
def arrival_after_due_time(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each(Visit)
.filter(lambda visit: visit.is_late())
.penalize(
HardSoftScore.ONE_SOFT,
lambda visit: int((visit.arrival_time - visit.due_time).total_seconds())
)
.as_constraint("Arrival after due time")
)
Best Practices
Do
- Use
InverseRelationShadowVariable when entities need to know their container - Use
CascadingUpdateShadowVariable for calculated values like arrival times - Keep update methods simple and fast
Don’t
- Create circular shadow variable dependencies
- Do expensive calculations in update methods
- Forget to handle
None cases
Next Steps
5 - Pinning
Lock specific assignments to prevent the solver from changing them.
Pinning locks certain assignments so the solver cannot change them. This is useful for:
- Preserving manual decisions
- Locking in-progress or completed work
- Incremental planning with fixed history
PlanningPin Annotation
Mark an entity as pinned using the PlanningPin annotation:
from dataclasses import dataclass, field
from typing import Annotated
from solverforge_legacy.solver.domain import (
planning_entity,
PlanningId,
PlanningVariable,
PlanningPin,
)
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
subject: str
teacher: str
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
room: Annotated[Room | None, PlanningVariable] = field(default=None)
# When True, solver won't change this lesson's assignments
pinned: Annotated[bool, PlanningPin] = field(default=False)
When pinned=True, the solver will not modify timeslot or room for this lesson.
Setting Pinned State
At Problem Creation
lessons = [
Lesson("1", "Math", "A. Turing", timeslot=monday_8am, room=room_a, pinned=True), # Fixed
Lesson("2", "Physics", "M. Curie", pinned=False), # Solver will assign
]
Based on Time
Pin lessons that are already in progress or past:
from datetime import datetime
def create_problem(lessons: list[Lesson], current_time: datetime) -> Timetable:
for lesson in lessons:
if lesson.timeslot and lesson.timeslot.start_time <= current_time:
lesson.pinned = True
return Timetable(...)
Based on User Decisions
def pin_manual_assignments(lesson: Lesson, is_manual: bool):
lesson.pinned = is_manual
PlanningPinToIndex for List Variables
For list variables (routing), you can pin elements up to a certain index:
from solverforge_legacy.solver.domain import PlanningPinToIndex
@planning_entity
@dataclass
class Vehicle:
id: Annotated[str, PlanningId]
visits: Annotated[list[Visit], PlanningListVariable] = field(default_factory=list)
# Elements at index 0, 1, ..., (pinned_index-1) are pinned
pinned_index: Annotated[int, PlanningPinToIndex] = field(default=0)
Example:
pinned_index=0 - No visits are pinned (all can be reordered)pinned_index=3 - First 3 visits are locked in placepinned_index=len(visits) - All visits are pinned
Updating Pinned Index
def update_pinned_for_in_progress(vehicle: Vehicle, current_time: datetime):
"""Pin visits that have already started."""
pinned_count = 0
for visit in vehicle.visits:
if visit.arrival_time and visit.arrival_time <= current_time:
pinned_count += 1
else:
break # Stop at first unstarted visit
vehicle.pinned_index = pinned_count
Use Cases
Continuous Planning
In continuous planning, pin the past and near future:
def prepare_for_replanning(solution: Schedule, current_time: datetime, buffer: timedelta):
"""
Pin assignments that:
- Have already started (in the past)
- Are starting soon (within buffer time)
"""
publish_deadline = current_time + buffer
for shift in solution.shifts:
if shift.start_time < publish_deadline:
shift.pinned = True
else:
shift.pinned = False
Respecting User Decisions
def load_schedule_with_pins(raw_data) -> Schedule:
shifts = []
for data in raw_data:
shift = Shift(
id=data["id"],
employee=find_employee(data["employee_id"]),
pinned=data.get("manually_assigned", False)
)
shifts.append(shift)
return Schedule(shifts=shifts)
Incremental Solving
Pin everything except new entities:
def add_new_lessons(solution: Timetable, new_lessons: list[Lesson]) -> Timetable:
# Pin all existing lessons
for lesson in solution.lessons:
lesson.pinned = True
# Add new lessons (unpinned)
for lesson in new_lessons:
lesson.pinned = False
solution.lessons.append(lesson)
return solution
Behavior Notes
Pinned Entities Still Affect Score
Pinned entities participate in constraint evaluation:
# This constraint still fires if a pinned lesson conflicts with an unpinned one
def room_conflict(factory: ConstraintFactory) -> Constraint:
return (
factory.for_each_unique_pair(Lesson, ...)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("Room conflict")
)
Initialization
Pinned entities must have their planning variables already assigned:
# Correct: pinned entity has assigned values
Lesson("1", "Math", "Teacher", timeslot=slot, room=room, pinned=True)
# Incorrect: pinned entity without assignment (will cause issues)
Lesson("2", "Physics", "Teacher", timeslot=None, room=None, pinned=True)
Constraints with Pinning
You might want different constraint behavior for pinned vs unpinned:
def prefer_unpinned_over_pinned(factory: ConstraintFactory) -> Constraint:
"""If there's a conflict, prefer to move the unpinned lesson."""
return (
factory.for_each(Lesson)
.filter(lambda lesson: lesson.pinned)
.join(
Lesson,
Joiners.equal(lambda l: l.timeslot),
Joiners.equal(lambda l: l.room),
Joiners.filtering(lambda pinned, other: not other.pinned)
)
# Penalize the unpinned lesson in conflict
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("Conflict with pinned lesson")
)
Best Practices
Do
- Pin entities that represent completed or in-progress work
- Use
PlanningPinToIndex for routing problems - Ensure pinned entities have valid assignments
Don’t
- Pin too many entities (solver has less freedom)
- Forget to unpin entities when requirements change
- Create infeasible problems by pinning conflicting entities
Next Steps