Spaces:
Sleeping
Sleeping
File size: 7,311 Bytes
7596726 | 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 | //! Preference shaping for the public dataset.
//!
//! The hidden witness gives us a hard-feasible schedule. This module then adds
//! desired/undesired dates so the public problem contains soft-score movement
//! without throwing away that feasibility margin.
mod exchange;
mod floor;
mod support;
mod top_up;
use chrono::NaiveDate;
use std::collections::{BTreeMap, BTreeSet};
use crate::domain::{Employee, Shift};
use self::exchange::assign_exchange_preferences;
use self::floor::ensure_preference_floor;
use self::top_up::{add_weekend_preference_bias, top_up_preferences};
use super::coverage::employee_can_cover_shift_without_schedule;
use super::employees::EmployeeBlueprint;
use super::vocabulary::{EXCHANGE_MARK_LIMIT_PER_EMPLOYEE, MAX_DESIRED_DATES, MAX_UNDESIRED_DATES};
use super::witness::WitnessRoster;
#[cfg(test)]
pub(super) fn shift_soft_preference_score(employee: &Employee, shift: &Shift) -> i64 {
support::shift_soft_preference_score(employee, shift)
}
/// Adds the full preference surface for the public dataset.
pub(super) fn add_preferences(
employees: &mut [Employee],
start_date: NaiveDate,
blueprints: &[EmployeeBlueprint],
shifts: &[Shift],
witness: &WitnessRoster,
) {
let analysis = PreferenceAnalysis::build(employees, shifts, witness);
// Phase 1: create direct witness-relative exchange pressure.
//
// For a curated subset of shifts we mark the witness holder as disliking the
// touched date and mark one feasible alternative as preferring it. This makes
// the hidden witness intentionally non-soft-optimal and, more importantly,
// creates real one-move improvement opportunities in the public solution
// space instead of generic weekday-themed noise.
if let Some(max_exchange_marks_per_employee) = EXCHANGE_MARK_LIMIT_PER_EMPLOYEE {
assign_exchange_preferences(
employees,
blueprints,
shifts,
witness,
&analysis,
max_exchange_marks_per_employee,
);
}
// Phase 2: fill the remaining preference volume with stable weekday-themed
// dates. The pure witness-relative variant created a richer soft surface,
// but it also made cheapest-insertion too eager to burn hard feasibility.
// The hybrid shape keeps one explicit exchange signal while leaving the
// bulk of the dataset in a feasibility-friendly, deterministic pattern.
top_up_preferences(employees, start_date, blueprints);
// Phase 3: add a tiny weekend bias only when the employee is still below the
// target preference floor. This keeps the designed feasibility margin while
// still breaking some of the largest weekday-only symmetries.
add_weekend_preference_bias(employees, start_date, blueprints);
ensure_preference_floor(
employees,
&witness.employee_touched_dates,
&analysis.coverable_dates_by_employee,
&analysis.date_pressure,
);
for employee in employees.iter() {
assert!(
employee.desired_dates.len() >= 4 && employee.desired_dates.len() <= MAX_DESIRED_DATES,
"desired-date volume should stay in the designed band"
);
assert!(
employee.undesired_dates.len() >= 4
&& employee.undesired_dates.len() <= MAX_UNDESIRED_DATES,
"undesired-date volume should stay in the designed band"
);
assert!(
employee
.desired_dates
.iter()
.all(|date| !employee.undesired_dates.contains(date)),
"desired and undesired dates must stay disjoint"
);
}
}
/// Cached facts shared by the different preference-shaping passes.
struct PreferenceAnalysis {
candidate_lists: Vec<Vec<usize>>,
candidate_counts: Vec<usize>,
witness_shifts_by_employee: Vec<Vec<usize>>,
coverable_dates_by_employee: Vec<BTreeSet<NaiveDate>>,
date_pressure: BTreeMap<NaiveDate, usize>,
}
impl PreferenceAnalysis {
/// Precomputes the helper views every preference phase needs.
fn build(employees: &[Employee], shifts: &[Shift], witness: &WitnessRoster) -> Self {
let candidate_lists = eligible_employees_by_shift(employees, shifts);
let candidate_counts: Vec<usize> = candidate_lists.iter().map(Vec::len).collect();
let witness_shifts_by_employee =
witness_shift_indices_by_employee(&witness.assignments, employees.len());
let coverable_dates_by_employee =
coverable_dates_by_employee(&candidate_lists, shifts, employees.len());
let date_pressure = date_pressure_by_day(shifts, &candidate_counts);
Self {
candidate_lists,
candidate_counts,
witness_shifts_by_employee,
coverable_dates_by_employee,
date_pressure,
}
}
}
/// Lists the legal employees for each public shift before scheduling interactions.
pub(super) fn eligible_employees_by_shift(
employees: &[Employee],
shifts: &[Shift],
) -> Vec<Vec<usize>> {
shifts
.iter()
.map(|shift| {
employees
.iter()
.enumerate()
.filter(|(_, employee)| employee_can_cover_shift_without_schedule(employee, shift))
.map(|(employee_index, _)| employee_index)
.collect()
})
.collect()
}
/// Reverses witness assignments into "which shifts belong to each employee".
fn witness_shift_indices_by_employee(
assignments: &[usize],
employee_count: usize,
) -> Vec<Vec<usize>> {
let mut shifts_by_employee = vec![Vec::new(); employee_count];
for (shift_index, &employee_index) in assignments.iter().enumerate() {
shifts_by_employee[employee_index].push(shift_index);
}
shifts_by_employee
}
/// Collects every date an employee could legally cover in the public dataset.
fn coverable_dates_by_employee(
candidate_lists: &[Vec<usize>],
shifts: &[Shift],
employee_count: usize,
) -> Vec<BTreeSet<NaiveDate>> {
let mut dates_by_employee = vec![BTreeSet::new(); employee_count];
for (shift_index, candidates) in candidate_lists.iter().enumerate() {
for &candidate in candidates {
dates_by_employee[candidate].extend(shifts[shift_index].touched_dates.iter().copied());
}
}
dates_by_employee
}
/// Scores dates by how much soft-pressure they can safely carry.
fn date_pressure_by_day(
shifts: &[Shift],
candidate_counts: &[usize],
) -> BTreeMap<NaiveDate, usize> {
let mut pressure = BTreeMap::new();
for (shift, &candidate_count) in shifts.iter().zip(candidate_counts.iter()) {
// The goal here is not to make the hard problem tighter. It is to create
// lots of feasible reassignment opportunities. We therefore score dates
// by how many "comfortable" alternatives they carry, not by how scarce
// they are. Scarce dates belong to feasibility; abundant dates are where
// soft pressure can live without poisoning construction.
let shift_pressure = candidate_count.clamp(2, 8) - 1;
for &date in &shift.touched_dates {
*pressure.entry(date).or_default() += shift_pressure.max(1);
}
}
pressure
}
|