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

Return to the regular view of this page.

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

  1. The solver sees timeslot needs a value
  2. It looks for a ValueRangeProvider for Timeslot in the solution
  3. It tries different values and evaluates the score
  4. 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:

  1. Problem Facts - Immutable input data
  2. Planning Entities - Mutable entities with planning variables
  3. 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 level
  • HardSoftScore - Feasibility + optimization
  • HardMediumSoftScore - Three levels
  • BendableScore - 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:

  1. InverseRelationShadowVariable - First (depends only on list variable)
  2. PreviousElementShadowVariable - Second
  3. NextElementShadowVariable - Second
  4. IndexShadowVariable - Second
  5. CascadingUpdateShadowVariable - After their dependencies
  6. PiggybackShadowVariable - 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 place
  • pinned_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