File size: 8,738 Bytes
195a426 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 |
//! Zero-erasure constraints for Employee Scheduling using fluent API.
//!
//! All constraints use the fluent constraint stream API with concrete generic
//! types - no Arc, no dyn, fully monomorphized.
use chrono::NaiveDate;
use solverforge::prelude::*;
use solverforge::stream::joiner::equal_bi;
use crate::domain::{Employee, EmployeeSchedule, Shift};
/// Creates all constraints using the fluent API (fully monomorphized).
pub fn create_fluent_constraints() -> impl ConstraintSet<EmployeeSchedule, HardSoftDecimalScore> {
let factory = ConstraintFactory::<EmployeeSchedule, HardSoftDecimalScore>::new();
// =========================================================================
// HARD: Required Skill
// =========================================================================
let required_skill = factory
.clone()
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice())
.join(
|s: &EmployeeSchedule| s.employees.as_slice(),
equal_bi(
|shift: &Shift| shift.employee_idx,
|emp: &Employee| Some(emp.index),
),
)
.filter(|shift: &Shift, emp: &Employee| {
shift.employee_idx.is_some() && !emp.skills.contains(&shift.required_skill)
})
.penalize(HardSoftDecimalScore::ONE_HARD)
.as_constraint("Required skill");
// =========================================================================
// HARD: No Overlapping Shifts
// =========================================================================
// Note: overlapping joiner can't be composed with equality joiner for self-joins
// because for_each_unique_pair requires EqualJoiner for hash indexing.
// The filter approach is correct for self-join overlap detection.
let no_overlap = factory
.clone()
.for_each_unique_pair(
|s: &EmployeeSchedule| s.shifts.as_slice(),
joiner::equal(|shift: &Shift| shift.employee_idx),
)
.filter(|a: &Shift, b: &Shift| {
a.employee_idx.is_some() && a.start < b.end && b.start < a.end
})
.penalize_hard_with(|a: &Shift, b: &Shift| {
HardSoftDecimalScore::of_hard_scaled(overlap_minutes(a, b) * 100000)
})
.as_constraint("Overlapping shift");
// =========================================================================
// HARD: At Least 10 Hours Between Shifts
// =========================================================================
let at_least_10_hours = factory
.clone()
.for_each_unique_pair(
|s: &EmployeeSchedule| s.shifts.as_slice(),
joiner::equal(|shift: &Shift| shift.employee_idx),
)
.filter(|a: &Shift, b: &Shift| a.employee_idx.is_some() && gap_penalty_minutes(a, b) > 0)
.penalize_hard_with(|a: &Shift, b: &Shift| {
HardSoftDecimalScore::of_hard_scaled(gap_penalty_minutes(a, b) * 100000)
})
.as_constraint("At least 10 hours between 2 shifts");
// =========================================================================
// HARD: One Shift Per Day
// =========================================================================
let one_per_day = factory
.clone()
.for_each_unique_pair(
|s: &EmployeeSchedule| s.shifts.as_slice(),
joiner::equal(|shift: &Shift| (shift.employee_idx, shift.date())),
)
.filter(|a: &Shift, b: &Shift| a.employee_idx.is_some() && b.employee_idx.is_some())
.penalize(HardSoftDecimalScore::ONE_HARD)
.as_constraint("One shift per day");
// =========================================================================
// HARD: Unavailable Employee
// =========================================================================
// Uses flatten_last for O(1) lookup by date.
// Pre-indexes unavailable dates, looks up by shift.date() in O(1).
let unavailable = factory
.clone()
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice())
.join(
|s: &EmployeeSchedule| s.employees.as_slice(),
equal_bi(
|shift: &Shift| shift.employee_idx,
|emp: &Employee| Some(emp.index),
),
)
.flatten_last(
|emp: &Employee| emp.unavailable_days.as_slice(),
|date: &NaiveDate| *date, // C → index key
|shift: &Shift| shift.date(), // A → lookup key
)
.filter(|shift: &Shift, date: &NaiveDate| {
shift.employee_idx.is_some() && shift_date_overlap_minutes(shift, *date) > 0
})
.penalize_hard_with(|shift: &Shift, date: &NaiveDate| {
HardSoftDecimalScore::of_hard_scaled(shift_date_overlap_minutes(shift, *date) * 100000)
})
.as_constraint("Unavailable employee");
// =========================================================================
// SOFT: Undesired Day
// =========================================================================
// Uses flatten_last for O(1) lookup. Penalizes 1 per match (Timefold pattern).
let undesired = factory
.clone()
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice())
.join(
|s: &EmployeeSchedule| s.employees.as_slice(),
equal_bi(
|shift: &Shift| shift.employee_idx,
|emp: &Employee| Some(emp.index),
),
)
.flatten_last(
|emp: &Employee| emp.undesired_days.as_slice(),
|date: &NaiveDate| *date,
|shift: &Shift| shift.date(),
)
.filter(|shift: &Shift, _date: &NaiveDate| shift.employee_idx.is_some())
.penalize(HardSoftDecimalScore::ONE_SOFT)
.as_constraint("Undesired day for employee");
// =========================================================================
// SOFT: Desired Day
// =========================================================================
// Uses flatten_last for O(1) lookup. Rewards 1 per match (Timefold pattern).
let desired = factory
.clone()
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice())
.join(
|s: &EmployeeSchedule| s.employees.as_slice(),
equal_bi(
|shift: &Shift| shift.employee_idx,
|emp: &Employee| Some(emp.index),
),
)
.flatten_last(
|emp: &Employee| emp.desired_days.as_slice(),
|date: &NaiveDate| *date,
|shift: &Shift| shift.date(),
)
.filter(|shift: &Shift, _date: &NaiveDate| shift.employee_idx.is_some())
.reward(HardSoftDecimalScore::ONE_SOFT)
.as_constraint("Desired day for employee");
// =========================================================================
// SOFT: Balance Assignments
// =========================================================================
// Uses simple balance() for O(1) incremental std-dev calculation.
let balanced = factory
.for_each(|s: &EmployeeSchedule| s.shifts.as_slice())
.balance(|shift: &Shift| shift.employee_idx)
.penalize(HardSoftDecimalScore::of_soft(1))
.as_constraint("Balance employee assignments");
(
required_skill,
no_overlap,
at_least_10_hours,
one_per_day,
unavailable,
undesired,
desired,
balanced,
)
}
// ============================================================================
// Helper functions
// ============================================================================
#[inline]
fn overlap_minutes(a: &Shift, b: &Shift) -> i64 {
let start = a.start.max(b.start);
let end = a.end.min(b.end);
if start < end {
(end - start).num_minutes()
} else {
0
}
}
#[inline]
fn gap_penalty_minutes(a: &Shift, b: &Shift) -> i64 {
const MIN_GAP_MINUTES: i64 = 600;
let (earlier, later) = if a.end <= b.start {
(a, b)
} else if b.end <= a.start {
(b, a)
} else {
return 0;
};
let gap = (later.start - earlier.end).num_minutes();
if (0..MIN_GAP_MINUTES).contains(&gap) {
MIN_GAP_MINUTES - gap
} else {
0
}
}
#[inline]
fn shift_date_overlap_minutes(shift: &Shift, date: NaiveDate) -> i64 {
let day_start = date.and_hms_opt(0, 0, 0).unwrap();
let day_end = date.succ_opt().unwrap_or(date).and_hms_opt(0, 0, 0).unwrap();
let start = shift.start.max(day_start);
let end = shift.end.min(day_end);
if start < end {
(end - start).num_minutes()
} else {
0
}
}
|