File size: 8,813 Bytes
4b94493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
//! Planning solution for the lesson timetabling problem.
//!
//! `Plan` is both the input to SolverForge and the domain value converted to
//! JSON snapshots after solving. Facts stay read-only; lessons carry the
//! mutable timeslot and room choices.

use serde::{Deserialize, Serialize};
use solverforge::prelude::*;

// @solverforge:begin solution-imports
use super::Group;
use super::Lesson;
use super::Room;
use super::Teacher;
use super::Timeslot;
// @solverforge:end solution-imports

/// Full planning solution passed to the SolverForge runtime and HTTP API.
#[planning_solution(
    constraints = "crate::constraints::create_constraints",
    solver_toml = "../../solver.toml"
)]
#[derive(Serialize, Deserialize)]
pub struct Plan {
    // @solverforge:begin solution-collections
    /// Weekly slots a lesson can occupy.
    #[problem_fact_collection]
    pub timeslots: Vec<Timeslot>,
    /// Teachers and their availability calendars.
    #[problem_fact_collection]
    pub teachers: Vec<Teacher>,
    /// Student cohorts that need complete timetables.
    #[problem_fact_collection]
    pub groups: Vec<Group>,
    /// Lesson entities whose timeslot and room variables are changed by search.
    #[planning_entity_collection]
    pub lessons: Vec<Lesson>,
    /// Candidate teaching spaces.
    #[problem_fact_collection]
    pub rooms: Vec<Room>,
    // @solverforge:end solution-collections
    #[planning_score]
    pub score: Option<HardMediumSoftScore>,
}

impl Plan {
    /// Builds a normalized timetable plan from facts and lesson entities.
    #[rustfmt::skip]
    pub fn new(
        // @solverforge:begin solution-constructor-params
        timeslots: Vec<Timeslot>,
        teachers: Vec<Teacher>,
        groups: Vec<Group>,
        lessons: Vec<Lesson>,
        rooms: Vec<Room>,
        // @solverforge:end solution-constructor-params
    ) -> Self {
        let mut schedule: Plan = Self{
            // @solverforge:begin solution-constructor-init
            timeslots,
            teachers,
            groups,
            lessons,
            rooms,
            // @solverforge:end solution-constructor-init
            score: None,
        };
        schedule.rebuild_derived_fields();
        schedule
    }

    /// Recomputes indexes for entity join keys.
    ///
    /// This runs after generation and after transport decoding so the domain
    /// model always reaches the solver in a normalized state.
    ///
    /// Sets the `index` field on facts and lessons to match their position in
    /// their respective collections. These indexes are used as solver-facing
    /// join keys for constraint streams (e.g., `lesson.timeslot_idx` joins with
    /// `timeslot.index`, while `lesson.index` separates lesson pairs).
    pub fn rebuild_derived_fields(&mut self) {
        for (index, timeslot) in self.timeslots.iter_mut().enumerate() {
            timeslot.index = index;
        }
        for (index, teacher) in self.teachers.iter_mut().enumerate() {
            teacher.index = index;
        }
        for (index, group) in self.groups.iter_mut().enumerate() {
            group.index = index;
        }
        for (index, room) in self.rooms.iter_mut().enumerate() {
            room.index = index;
        }

        // Planning variables are optional indexes. When a stale browser payload
        // sends an out-of-range index, clear it so SolverForge sees an
        // unassigned variable instead of indexing past the candidate list.
        for (index, lesson) in self.lessons.iter_mut().enumerate() {
            lesson.index = index;
            lesson.timeslot_idx = lesson
                .timeslot_idx
                .filter(|idx| *idx < self.timeslots.len());
            lesson.room_idx = lesson.room_idx.filter(|idx| *idx < self.rooms.len());
        }
    }

    /// Safe index lookup used by constraints and diagnostics.
    #[inline]
    pub fn get_timeslot(&self, idx: usize) -> Option<&Timeslot> {
        self.timeslots.get(idx)
    }

    /// Safe index lookup used by constraints and diagnostics.
    #[inline]
    pub fn get_teacher(&self, idx: usize) -> Option<&Teacher> {
        self.teachers.get(idx)
    }

    /// Safe index lookup used by constraints and diagnostics.
    #[inline]
    pub fn get_group(&self, idx: usize) -> Option<&Group> {
        self.groups.get(idx)
    }

    /// Safe index lookup used by constraints and diagnostics.
    #[inline]
    pub fn get_room(&self, idx: usize) -> Option<&Room> {
        self.rooms.get(idx)
    }

    /// Named slice accessor used by joins and SolverForge transport code.
    #[inline]
    pub fn timeslots_slice(&self) -> &[Timeslot] {
        self.timeslots.as_slice()
    }

    /// Named slice accessor used by joins and SolverForge transport code.
    #[inline]
    pub fn teachers_slice(&self) -> &[Teacher] {
        self.teachers.as_slice()
    }

    /// Named slice accessor used by joins and SolverForge transport code.
    #[inline]
    pub fn groups_slice(&self) -> &[Group] {
        self.groups.as_slice()
    }

    /// Named slice accessor used by joins and SolverForge transport code.
    #[inline]
    pub fn rooms_slice(&self) -> &[Room] {
        self.rooms.as_slice()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::Weekday;

    #[test]
    fn test_rebuild_derived_fields_filters_out_of_bounds_indices() {
        use chrono::NaiveTime;

        // Create a plan with 2 timeslots and 2 rooms
        let timeslots = vec![
            Timeslot::new(0, Weekday::Mon, NaiveTime::from_hms_opt(8, 0, 0).unwrap(), NaiveTime::from_hms_opt(10, 0, 0).unwrap()),
            Timeslot::new(1, Weekday::Mon, NaiveTime::from_hms_opt(10, 0, 0).unwrap(), NaiveTime::from_hms_opt(12, 0, 0).unwrap()),
        ];
        let teachers = vec![];
        let groups = vec![];
        let rooms = vec![
            Room::new(0, "Room A"),
            Room::new(1, "Room B"),
        ];
        let lessons = vec![
            Lesson::new(0, "Math".to_string(), 0, None, 120),
            Lesson::new(1, "Physics".to_string(), 0, None, 120),
        ];

        let mut plan = Plan::new(timeslots, teachers, groups, lessons, rooms);

        // Manually corrupt the indices to simulate deserialization from invalid data
        plan.lessons[0].timeslot_idx = Some(100); // Out of bounds
        plan.lessons[0].room_idx = Some(100);    // Out of bounds
        plan.lessons[1].timeslot_idx = Some(1);  // Valid
        plan.lessons[1].room_idx = Some(1);      // Valid

        // Rebuild should filter out the invalid indices
        plan.rebuild_derived_fields();

        // timeslot_idx=100 should be filtered to None (only 2 timeslots exist)
        assert_eq!(plan.lessons[0].timeslot_idx, None);
        // room_idx=100 should be filtered to None (only 2 rooms exist)
        assert_eq!(plan.lessons[0].room_idx, None);
        // Valid indices should remain
        assert_eq!(plan.lessons[1].timeslot_idx, Some(1));
        assert_eq!(plan.lessons[1].room_idx, Some(1));
    }

    #[test]
    fn test_rebuild_derived_fields_restores_lesson_indexes() {
        use chrono::NaiveTime;

        let timeslots = vec![Timeslot::new(
            0,
            Weekday::Mon,
            NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
            NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
        )];
        let lessons = vec![
            Lesson::new(0, "Math".to_string(), 0, None, 120),
            Lesson::new(1, "Physics".to_string(), 0, None, 120),
        ];
        let mut plan = Plan::new(timeslots, vec![], vec![], lessons, vec![]);

        plan.lessons[0].index = 0;
        plan.lessons[1].index = 0;
        plan.rebuild_derived_fields();

        assert_eq!(plan.lessons[0].index, 0);
        assert_eq!(plan.lessons[1].index, 1);
    }

    #[test]
    fn test_getters_return_none_for_invalid_indices() {
        use chrono::NaiveTime;

        let timeslots = vec![Timeslot::new(0, Weekday::Mon, NaiveTime::from_hms_opt(8, 0, 0).unwrap(), NaiveTime::from_hms_opt(10, 0, 0).unwrap())];
        let teachers = vec![Teacher::new(0, "Teacher A", [true; 10])];
        let groups = vec![Group::new(0, "Group A", 30, [true; 10])];
        let rooms = vec![Room::new(0, "Room A")];
        let lessons = vec![];

        let plan = Plan::new(timeslots, teachers, groups, lessons, rooms);

        // Valid indices
        assert!(plan.get_timeslot(0).is_some());
        assert!(plan.get_teacher(0).is_some());
        assert!(plan.get_group(0).is_some());
        assert!(plan.get_room(0).is_some());

        // Out of bounds indices
        assert!(plan.get_timeslot(100).is_none());
        assert!(plan.get_teacher(100).is_none());
        assert!(plan.get_group(100).is_none());
        assert!(plan.get_room(100).is_none());
    }
}