KindAlien commited on
Commit
b11ca52
Β·
verified Β·
1 Parent(s): 210d88d

Update partial_optimizer.py

Browse files
Files changed (1) hide show
  1. partial_optimizer.py +294 -59
partial_optimizer.py CHANGED
@@ -71,22 +71,61 @@ class PartialOptimizer:
71
  self.tasks_by_subject[t.subject.subject_code].append(t)
72
 
73
  # ═════════════════════════════════════════════════════════════════
74
- # PUBLIC ENTRY POINT
75
  # ═════════════════════════════════════════════════════════════════
76
 
77
- def apply_constraint_and_reoptimize(
78
- self,
79
- slm_constraint: Dict[str, Any],
80
- time_limit: int = 30
81
- ) -> Tuple[str, Dict[str, Any], List[str], str]:
82
- """
83
- Route to the correct handler based on constraint type.
84
- Returns: (status, new_solution, affected_task_ids, summary)
85
- """
86
- ctype = slm_constraint.get('type', '').upper()
87
-
88
- # Direct mutation handlers (no CP-SAT needed)
89
- direct_handlers = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  'FACULTY_SUBSTITUTION': self._op_faculty_substitution,
91
  'MOVE_CLASS': self._op_move_class,
92
  'SWAP_CLASSES': self._op_swap_classes,
@@ -98,58 +137,254 @@ class PartialOptimizer:
98
  'CHANGE_FACULTY': self._op_change_faculty,
99
  'FREEZE_SLOT': self._op_freeze_slot,
100
  }
 
101
 
102
- # Re-optimization handlers (CP-SAT partial solve)
103
- reopt_handlers = {
104
- 'FACULTY_UNAVAILABLE': True,
105
- 'FACULTY_FREE_DAY': True,
106
- 'FACULTY_MAX_DAILY_HOURS': True,
107
- 'FACULTY_NO_CONSECUTIVE': True,
108
- 'SECTION_FREE_SLOT': True,
109
- 'WORKING_DAYS': True,
110
- 'SUBJECT_PREFERRED_TIME': True,
111
- 'HEAVY_SUBJECT_MORNING': True,
112
- 'LAB_MUST_CONSECUTIVE': True,
113
- 'NO_BACK_TO_BACK_SUBJECTS': True,
114
- 'DISTRIBUTE_SUBJECTS_EVENLY': True,
115
- 'SUBJECT_SPACING': True,
116
- 'NO_FREE_PERIOD': True,
117
- }
118
 
119
- # ── Type aliases β€” map SLM variants to canonical types ──────────
120
- aliases = {
121
- 'SUBJECT_FREE_DAY': 'CANCEL_CLASS',
122
- 'FACULTY_LEAVE': 'FACULTY_SUBSTITUTION',
123
- 'BLOCK_SLOT': 'SECTION_FREE_SLOT',
124
- 'REMOVE_CLASS': 'CANCEL_CLASS',
125
- 'DELETE_CLASS': 'CANCEL_CLASS',
126
- 'CLASS_CANCELLED': 'CANCEL_CLASS',
127
- 'SHIFT_CLASS': 'MOVE_CLASS',
128
- 'RELOCATE_CLASS': 'MOVE_CLASS',
129
- 'TEACHER_SUBSTITUTION': 'FACULTY_SUBSTITUTION',
130
- 'REPLACE_FACULTY': 'FACULTY_SUBSTITUTION',
131
- 'SWAP_FACULTY': 'FACULTY_SUBSTITUTION',
132
- 'SUBJECT_UNAVAILABLE': 'CANCEL_CLASS',
133
- 'NO_CLASS': 'CANCEL_CLASS',
134
- 'HOLIDAY': 'MARK_HOLIDAY',
135
- 'MOVE_LAB': 'RESCHEDULE_LAB',
136
- 'SHIFT_LAB': 'RESCHEDULE_LAB',
137
- 'LOCK_SLOT': 'FREEZE_SLOT',
138
- 'PIN_SLOT': 'FREEZE_SLOT',
139
- }
140
- if ctype in aliases:
141
- slm_constraint = dict(slm_constraint)
142
- slm_constraint['type'] = aliases[ctype]
143
- ctype = aliases[ctype]
144
-
145
- if ctype in direct_handlers:
146
- return direct_handlers[ctype](slm_constraint)
147
- elif ctype in reopt_handlers:
148
  return self._reoptimize(slm_constraint, time_limit)
149
  else:
150
  return ('NO_CHANGE', self.current_solution, [],
151
  f'⚠️ Unknown constraint type: {ctype}')
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  # ═════════════════════════════════════════════════════════════════
154
  # DIRECT MUTATION OPERATIONS
155
  # ═════════════════════════════════════════════════════════════════
 
71
  self.tasks_by_subject[t.subject.subject_code].append(t)
72
 
73
  # ═════════════════════════════════════════════════════════════════
74
+ # TYPE CLASSIFICATION HELPERS
75
  # ═════════════════════════════════════════════════════════════════
76
 
77
+ # Direct mutation handlers (no CP-SAT needed)
78
+ DIRECT_TYPES = {
79
+ 'FACULTY_SUBSTITUTION', 'MOVE_CLASS', 'SWAP_CLASSES',
80
+ 'CANCEL_CLASS', 'CHANGE_ROOM', 'ADD_EXTRA_CLASS',
81
+ 'MARK_HOLIDAY', 'RESCHEDULE_LAB', 'CHANGE_FACULTY',
82
+ 'FREEZE_SLOT',
83
+ }
84
+
85
+ # Re-optimization handlers (CP-SAT partial solve)
86
+ REOPT_TYPES = {
87
+ 'FACULTY_UNAVAILABLE', 'FACULTY_FREE_DAY',
88
+ 'FACULTY_MAX_DAILY_HOURS', 'FACULTY_NO_CONSECUTIVE',
89
+ 'SECTION_FREE_SLOT', 'WORKING_DAYS',
90
+ 'SUBJECT_PREFERRED_TIME', 'HEAVY_SUBJECT_MORNING',
91
+ 'LAB_MUST_CONSECUTIVE', 'NO_BACK_TO_BACK_SUBJECTS',
92
+ 'DISTRIBUTE_SUBJECTS_EVENLY', 'SUBJECT_SPACING',
93
+ 'NO_FREE_PERIOD',
94
+ }
95
+
96
+ # Type aliases β€” map SLM variants to canonical types
97
+ ALIASES = {
98
+ 'SUBJECT_FREE_DAY': 'CANCEL_CLASS',
99
+ 'FACULTY_LEAVE': 'FACULTY_SUBSTITUTION',
100
+ 'BLOCK_SLOT': 'SECTION_FREE_SLOT',
101
+ 'REMOVE_CLASS': 'CANCEL_CLASS',
102
+ 'DELETE_CLASS': 'CANCEL_CLASS',
103
+ 'CLASS_CANCELLED': 'CANCEL_CLASS',
104
+ 'SHIFT_CLASS': 'MOVE_CLASS',
105
+ 'RELOCATE_CLASS': 'MOVE_CLASS',
106
+ 'TEACHER_SUBSTITUTION': 'FACULTY_SUBSTITUTION',
107
+ 'REPLACE_FACULTY': 'FACULTY_SUBSTITUTION',
108
+ 'SWAP_FACULTY': 'FACULTY_SUBSTITUTION',
109
+ 'SUBJECT_UNAVAILABLE': 'CANCEL_CLASS',
110
+ 'NO_CLASS': 'CANCEL_CLASS',
111
+ 'HOLIDAY': 'MARK_HOLIDAY',
112
+ 'MOVE_LAB': 'RESCHEDULE_LAB',
113
+ 'SHIFT_LAB': 'RESCHEDULE_LAB',
114
+ 'LOCK_SLOT': 'FREEZE_SLOT',
115
+ 'PIN_SLOT': 'FREEZE_SLOT',
116
+ }
117
+
118
+ def _resolve_alias(self, constraint: Dict[str, Any]) -> Dict[str, Any]:
119
+ """Resolve type aliases to canonical constraint types."""
120
+ ctype = constraint.get('type', '').upper()
121
+ if ctype in self.ALIASES:
122
+ constraint = dict(constraint)
123
+ constraint['type'] = self.ALIASES[ctype]
124
+ return constraint
125
+
126
+ def _get_direct_handler(self, ctype: str):
127
+ """Return the direct-mutation handler function for a constraint type."""
128
+ handlers = {
129
  'FACULTY_SUBSTITUTION': self._op_faculty_substitution,
130
  'MOVE_CLASS': self._op_move_class,
131
  'SWAP_CLASSES': self._op_swap_classes,
 
137
  'CHANGE_FACULTY': self._op_change_faculty,
138
  'FREEZE_SLOT': self._op_freeze_slot,
139
  }
140
+ return handlers.get(ctype)
141
 
142
+ # ═════════════════════════════════════════════════════════════════
143
+ # PUBLIC ENTRY POINT (single constraint β€” legacy)
144
+ # ═════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
+ def apply_constraint_and_reoptimize(
147
+ self,
148
+ slm_constraint: Dict[str, Any],
149
+ time_limit: int = 30
150
+ ) -> Tuple[str, Dict[str, Any], List[str], str]:
151
+ """
152
+ Route to the correct handler based on constraint type.
153
+ Returns: (status, new_solution, affected_task_ids, summary)
154
+ """
155
+ slm_constraint = self._resolve_alias(slm_constraint)
156
+ ctype = slm_constraint.get('type', '').upper()
157
+
158
+ handler = self._get_direct_handler(ctype)
159
+ if handler:
160
+ return handler(slm_constraint)
161
+ elif ctype in self.REOPT_TYPES:
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  return self._reoptimize(slm_constraint, time_limit)
163
  else:
164
  return ('NO_CHANGE', self.current_solution, [],
165
  f'⚠️ Unknown constraint type: {ctype}')
166
 
167
+ # ═════════════════════════════════════════════════════════════════
168
+ # PUBLIC ENTRY POINT (N constraints β€” batch)
169
+ # ═════════════════════════════════════════════════════════════════
170
+
171
+ def apply_constraints_batch(
172
+ self,
173
+ constraints: List[Dict[str, Any]],
174
+ time_limit: int = 30,
175
+ ) -> Tuple[str, Dict[str, Any], List[str], List[str]]:
176
+ """
177
+ Apply N constraints together. Direct-mutation constraints are
178
+ applied immediately in sequence; all re-optimization constraints
179
+ are batched into a SINGLE CP-SAT solve so the solver sees them
180
+ simultaneously (avoiding sequential conflicts).
181
+
182
+ Returns: (status, new_solution, affected_task_ids, change_summaries)
183
+ """
184
+ if not constraints:
185
+ return ('NO_CHANGE', self.current_solution, [], ['No constraints provided.'])
186
+
187
+ # ── 1. Resolve aliases ──────────────────────────────────────────
188
+ resolved = [self._resolve_alias(c) for c in constraints]
189
+
190
+ # ── 2. Classify into direct vs. reopt ───────────────────────────
191
+ direct_constraints = []
192
+ reopt_constraints = []
193
+ unknown_types = []
194
+ for c in resolved:
195
+ ctype = c.get('type', '').upper()
196
+ if ctype in self.DIRECT_TYPES:
197
+ direct_constraints.append(c)
198
+ elif ctype in self.REOPT_TYPES:
199
+ reopt_constraints.append(c)
200
+ else:
201
+ unknown_types.append(ctype)
202
+
203
+ all_changes: List[str] = []
204
+ final_solution = self.current_solution
205
+ overall_status = 'NO_CHANGE'
206
+
207
+ # ── 3. Apply direct-mutation constraints sequentially ───────────
208
+ # (These don't need CP-SAT; they mutate the schedule dict
209
+ # directly. Each sees the result of the previous one.)
210
+ for c in direct_constraints:
211
+ ctype = c.get('type', '').upper()
212
+ handler = self._get_direct_handler(ctype)
213
+ if handler:
214
+ # Update self.current_solution so the handler sees latest state
215
+ self.current_solution = final_solution
216
+ status, new_sol, _, summary = handler(c)
217
+ if status in ('OPTIMAL', 'FEASIBLE'):
218
+ final_solution = new_sol
219
+ overall_status = 'FEASIBLE'
220
+ all_changes.append(summary)
221
+
222
+ # ── 4. Batch all re-optimization constraints into ONE solve ─────
223
+ if reopt_constraints:
224
+ # Update solution reference for the reopt phase
225
+ self.current_solution = final_solution
226
+
227
+ # Detect conflicts before solving
228
+ conflicts = self.detect_conflicts(reopt_constraints)
229
+ if conflicts:
230
+ all_changes.append(
231
+ f"⚠️ Potential conflicts detected:\n"
232
+ + "\n".join(f" β€’ {cf}" for cf in conflicts)
233
+ )
234
+
235
+ # Collect affected tasks across ALL reopt constraints
236
+ all_affected = set()
237
+ for c in reopt_constraints:
238
+ affected = self._find_affected_tasks(c)
239
+ all_affected.update(affected)
240
+
241
+ if all_affected:
242
+ # Scale time limit based on constraint count and task count
243
+ scaled_limit = time_limit + (len(reopt_constraints) * 10)
244
+ scaled_limit = min(scaled_limit, 300) # cap at 5 minutes
245
+
246
+ status, partial = self._solve_partial(
247
+ list(all_affected), reopt_constraints, scaled_limit
248
+ )
249
+
250
+ if status in ('OPTIMAL', 'FEASIBLE'):
251
+ final_solution = dict(final_solution)
252
+ final_solution.update(partial)
253
+ overall_status = status
254
+
255
+ # Build change summaries
256
+ changes = []
257
+ for tid in all_affected:
258
+ old = self.current_solution.get(tid, {})
259
+ new = final_solution.get(tid, {})
260
+ if old and new:
261
+ old_slot = f"{const.DAYS[old['day_index']]} P{old['period_index']+1}"
262
+ new_slot = f"{const.DAYS[new['day_index']]} P{new['period_index']+1}"
263
+ if old_slot != new_slot:
264
+ changes.append(
265
+ f"β€’ {new.get('subject_code','?').upper()} "
266
+ f"({new.get('section_id','?')}): "
267
+ f"{old_slot} β†’ {new_slot}"
268
+ )
269
+
270
+ summary = (
271
+ f"βœ… Batch: {len(reopt_constraints)} constraint(s), "
272
+ f"{len(changes)} slot(s) moved:\n" + "\n".join(changes)
273
+ if changes else
274
+ f"βœ… {len(reopt_constraints)} constraint(s) applied β€” no slot changes needed."
275
+ )
276
+ all_changes.append(summary)
277
+ else:
278
+ all_changes.append(
279
+ f"❌ Could not satisfy {len(reopt_constraints)} batched "
280
+ f"constraint(s) affecting {len(all_affected)} task(s). "
281
+ f"Constraints may be too restrictive or mutually conflicting."
282
+ )
283
+ overall_status = status
284
+ else:
285
+ all_changes.append(
286
+ f"ℹ️ {len(reopt_constraints)} re-optimization constraint(s) "
287
+ f"matched no affected tasks."
288
+ )
289
+
290
+ # ── 5. Report unknown types ─────────────────────────────────────
291
+ for utype in unknown_types:
292
+ all_changes.append(f"⚠️ Unknown constraint type: {utype}")
293
+
294
+ return (overall_status, final_solution, [], all_changes)
295
+
296
+ # ═════════════════════════════════════════════════════════════════
297
+ # CONFLICT DETECTION
298
+ # ═════════════════════════════════════════════════════════════════
299
+
300
+ def detect_conflicts(
301
+ self, constraints: List[Dict[str, Any]]
302
+ ) -> List[str]:
303
+ """
304
+ Detect obvious conflicts between constraints BEFORE solving.
305
+ Returns a list of human-readable conflict descriptions.
306
+ Does not block solving β€” just warns the user.
307
+ """
308
+ conflicts = []
309
+
310
+ # Index constraints by faculty and section for cross-checking
311
+ faculty_constraints = defaultdict(list)
312
+ section_constraints = defaultdict(list)
313
+ day_constraints = defaultdict(list)
314
+
315
+ for i, c in enumerate(constraints):
316
+ ctype = c.get('type', '').upper()
317
+ fid = c.get('faculty_id')
318
+ sid = c.get('section_id')
319
+ days = c.get('days') or []
320
+
321
+ if fid:
322
+ faculty_constraints[fid].append((i, c))
323
+ if sid:
324
+ section_constraints[sid].append((i, c))
325
+ for d in days:
326
+ day_constraints[d].append((i, c))
327
+
328
+ # Check: Faculty made unavailable on a day where another
329
+ # constraint tries to move their class TO that day
330
+ for fid, fac_cs in faculty_constraints.items():
331
+ unavailable_days = set()
332
+ move_to_days = set()
333
+ for _, c in fac_cs:
334
+ ctype = c.get('type', '').upper()
335
+ if ctype == 'FACULTY_UNAVAILABLE':
336
+ for d in (c.get('days') or []):
337
+ unavailable_days.add(d)
338
+ if ctype == 'SUBJECT_PREFERRED_TIME':
339
+ # Check if we're trying to move this faculty's
340
+ # subject when they're also being made unavailable
341
+ move_to_days.add(c.get('subject_code', '?'))
342
+
343
+ if unavailable_days and move_to_days:
344
+ fac_name = self.fac_by_id.get(fid)
345
+ fac_label = fac_name.name if fac_name else fid
346
+ conflicts.append(
347
+ f"Faculty '{fac_label}' is made unavailable on "
348
+ f"{', '.join(unavailable_days)} but other constraints "
349
+ f"affect their subjects ({', '.join(move_to_days)})"
350
+ )
351
+
352
+ # Check: Section has conflicting free-slot and no-free-period
353
+ for sid, sec_cs in section_constraints.items():
354
+ free_slots = set()
355
+ no_free_periods = set()
356
+ for _, c in sec_cs:
357
+ ctype = c.get('type', '').upper()
358
+ if ctype == 'SECTION_FREE_SLOT':
359
+ slot = c.get('slot')
360
+ if slot is not None:
361
+ free_slots.add(slot - 1) # 0-indexed
362
+ if ctype == 'NO_FREE_PERIOD':
363
+ for p in (c.get('periods') or []):
364
+ no_free_periods.add(p)
365
+
366
+ overlap = free_slots & no_free_periods
367
+ if overlap:
368
+ conflicts.append(
369
+ f"Section '{sid}': period(s) {[p+1 for p in overlap]} "
370
+ f"are set as BOTH free-slot AND no-free-period"
371
+ )
372
+
373
+ # Check: Working-day restrictions that eliminate too many days
374
+ for fid, fac_cs in faculty_constraints.items():
375
+ for _, c in fac_cs:
376
+ if c.get('type', '').upper() == 'WORKING_DAYS':
377
+ allowed = c.get('days') or []
378
+ if len(allowed) < 2:
379
+ fac_name = self.fac_by_id.get(fid)
380
+ fac_label = fac_name.name if fac_name else fid
381
+ conflicts.append(
382
+ f"Working-days constraint for '{fac_label}' allows "
383
+ f"only {len(allowed)} day(s) β€” likely too restrictive"
384
+ )
385
+
386
+ return conflicts
387
+
388
  # ═════════════════════════════════════════════════════════════════
389
  # DIRECT MUTATION OPERATIONS
390
  # ═════════════════════════════════════════════════════════════════