KindAlien commited on
Commit
0fcfd96
Β·
verified Β·
1 Parent(s): e4a379d

Update partial_optimizer.py

Browse files
Files changed (1) hide show
  1. partial_optimizer.py +59 -294
partial_optimizer.py CHANGED
@@ -71,61 +71,22 @@ class PartialOptimizer:
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,254 +98,58 @@ class PartialOptimizer:
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
  # ═════════════════════════════════════════════════════════════════
 
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
  '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
  # ═════════════════════════════════════════════════════════════════