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
}