Skip to main content

converge_optimization/packs/meeting_scheduler/
solver.rs

1//! Solver for Meeting Scheduler pack
2
3use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{
7    Diagnostic, DiagnosticKind, ProblemSpec, ReplayEnvelope, SolverReport, StopReason,
8};
9
10/// Greedy scoring solver for meeting scheduling
11///
12/// Algorithm:
13/// 1. For each slot, calculate score:
14///    - required_available * 1000 (heavily weighted)
15///    - optional_available * 10
16///    - sum of preference scores
17/// 2. Filter to slots where all required attendees can attend
18/// 3. Sort by score (descending), apply tie-breaking
19/// 4. Select highest-scoring feasible slot
20pub struct GreedySolver;
21
22impl GreedySolver {
23    /// Solve the meeting scheduling problem
24    pub fn solve_meeting(
25        &self,
26        input: &MeetingSchedulerInput,
27        spec: &ProblemSpec,
28    ) -> Result<(MeetingSchedulerOutput, SolverReport)> {
29        let start = std::time::Instant::now();
30
31        // Collect all required attendee IDs
32        let required_ids: Vec<&str> = input.required_attendees().map(|a| a.id.as_str()).collect();
33
34        // Score each slot
35        let mut scored_slots: Vec<ScoredSlot> = input
36            .slots
37            .iter()
38            .map(|slot| self.score_slot(slot, input, &required_ids))
39            .collect();
40
41        // Sort by score descending (higher is better)
42        scored_slots.sort_by(|a, b| {
43            b.score
44                .total_cmp(&a.score)
45                .then_with(|| a.slot.id.cmp(&b.slot.id))
46        });
47
48        // Apply tie-breaking for equal scores
49        let tie_break = &spec.determinism.tie_break;
50        let seed = spec.seed();
51
52        // Group by score and apply tie-breaking within groups
53        let mut final_slots = Vec::new();
54        let mut current_score = f64::NEG_INFINITY;
55        let mut score_group = Vec::new();
56
57        for scored in scored_slots {
58            if (scored.score - current_score).abs() < f64::EPSILON {
59                score_group.push(scored);
60            } else {
61                if !score_group.is_empty() {
62                    // Apply tie-breaking to group
63                    score_group.sort_by(|a, b| a.slot.id.cmp(&b.slot.id));
64                    if let Some(selected) =
65                        tie_break.select_by(&score_group, seed, |a, b| a.slot.id.cmp(&b.slot.id))
66                    {
67                        final_slots.push(selected.clone());
68                    }
69                }
70                score_group = vec![scored.clone()];
71                current_score = scored.score;
72            }
73        }
74        // Don't forget the last group
75        if !score_group.is_empty() {
76            score_group.sort_by(|a, b| a.slot.id.cmp(&b.slot.id));
77            if let Some(selected) =
78                tie_break.select_by(&score_group, seed, |a, b| a.slot.id.cmp(&b.slot.id))
79            {
80                final_slots.push(selected.clone());
81            }
82        }
83
84        // Find best feasible slot (all required can attend, meets min attendees)
85        let best = final_slots
86            .into_iter()
87            .find(|s| s.is_feasible && s.attending.len() >= input.requirements.min_attendees);
88
89        let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
90
91        let (output, stop_reason) = match best {
92            Some(scored) => {
93                let output = MeetingSchedulerOutput {
94                    selected_slot: Some(scored.slot.clone()),
95                    attending: scored.attending.clone(),
96                    not_attending: scored.not_attending.clone(),
97                    conflicts: scored.conflicts.clone(),
98                    total_preference_score: scored.preference_score,
99                    score_breakdown: scored.breakdown.clone(),
100                };
101                (output, StopReason::Optimal)
102            }
103            None => {
104                // Build conflict info for why no slot works
105                let conflicts = self.build_global_conflicts(input, &required_ids);
106                let output = MeetingSchedulerOutput::no_solution(conflicts);
107                (output, StopReason::NoFeasible)
108            }
109        };
110
111        // Build solver report
112        let replay = ReplayEnvelope::minimal(seed);
113        let report = if output.selected_slot.is_some() {
114            SolverReport::optimal("greedy-v1", output.score_breakdown.total_score, replay)
115                .with_diagnostic(Diagnostic::performance(
116                    "scoring",
117                    elapsed_ms,
118                    input.slots.len(),
119                ))
120                .with_diagnostic(Diagnostic::scoring_breakdown(serde_json::json!({
121                    "required_score": output.score_breakdown.required_score,
122                    "optional_score": output.score_breakdown.optional_score,
123                    "preference_score": output.score_breakdown.preference_score,
124                })))
125        } else {
126            SolverReport::infeasible("greedy-v1", vec![], stop_reason, replay).with_diagnostic(
127                Diagnostic::new(
128                    DiagnosticKind::ConstraintAnalysis,
129                    format!(
130                        "No slot satisfies all required attendees. {} slots evaluated.",
131                        input.slots.len()
132                    ),
133                ),
134            )
135        };
136
137        Ok((output, report))
138    }
139
140    /// Score a single slot
141    fn score_slot(
142        &self,
143        slot: &TimeSlot,
144        input: &MeetingSchedulerInput,
145        required_ids: &[&str],
146    ) -> ScoredSlot {
147        let mut attending = Vec::new();
148        let mut not_attending = Vec::new();
149        let mut conflicts = Vec::new();
150        let mut required_count = 0;
151        let mut optional_count = 0;
152        let mut preference_score = 0.0;
153
154        for attendee in &input.attendees {
155            if attendee.can_attend(&slot.id) {
156                attending.push(attendee.id.clone());
157                preference_score += attendee.preference_for(&slot.id);
158
159                if attendee.required {
160                    required_count += 1;
161                } else {
162                    optional_count += 1;
163                }
164            } else {
165                not_attending.push(attendee.id.clone());
166                if attendee.required {
167                    conflicts.push(ConflictInfo::not_available(&attendee.id, &slot.id));
168                }
169            }
170        }
171
172        // Check if all required attendees can attend
173        let is_feasible = required_count == required_ids.len();
174
175        // Calculate score (heavily weight required attendance)
176        let required_score = required_count as f64 * 1000.0;
177        let optional_score = optional_count as f64 * 10.0;
178        let total_score = required_score + optional_score + preference_score;
179
180        ScoredSlot {
181            slot: slot.clone(),
182            score: total_score,
183            is_feasible,
184            attending,
185            not_attending,
186            conflicts,
187            preference_score,
188            breakdown: ScoreBreakdown::new(required_score, optional_score, preference_score),
189        }
190    }
191
192    /// Build conflict info explaining why no slot is feasible globally
193    fn build_global_conflicts(
194        &self,
195        input: &MeetingSchedulerInput,
196        required_ids: &[&str],
197    ) -> Vec<ConflictInfo> {
198        let mut conflicts = Vec::new();
199
200        for &required_id in required_ids {
201            if let Some(attendee) = input.get_attendee(required_id) {
202                if attendee.available_slots.is_empty() {
203                    conflicts.push(ConflictInfo::new(
204                        required_id,
205                        "Required attendee has no available slots",
206                    ));
207                }
208            }
209        }
210
211        // Check if any slot has all required attendees
212        let any_feasible = input.slots.iter().any(|slot| {
213            required_ids.iter().all(|id| {
214                input
215                    .get_attendee(id)
216                    .map(|a| a.can_attend(&slot.id))
217                    .unwrap_or(false)
218            })
219        });
220
221        if !any_feasible && conflicts.is_empty() {
222            conflicts.push(ConflictInfo::new(
223                "system",
224                "No slot has availability overlap for all required attendees",
225            ));
226        }
227
228        conflicts
229    }
230}
231
232impl PackSolver for GreedySolver {
233    fn id(&self) -> &'static str {
234        "greedy-v1"
235    }
236
237    fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
238        let input: MeetingSchedulerInput = spec.inputs_as()?;
239        let (output, report) = self.solve_meeting(&input, spec)?;
240        let json = serde_json::to_value(&output)
241            .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
242        Ok((json, report))
243    }
244
245    fn is_exact(&self) -> bool {
246        // Greedy is exact for meeting scheduling (we enumerate all slots)
247        true
248    }
249}
250
251/// Internal scored slot representation
252#[derive(Debug, Clone)]
253struct ScoredSlot {
254    slot: TimeSlot,
255    score: f64,
256    is_feasible: bool,
257    attending: Vec<String>,
258    not_attending: Vec<String>,
259    conflicts: Vec<ConflictInfo>,
260    preference_score: f64,
261    breakdown: ScoreBreakdown,
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use converge_pack::gate::{ObjectiveSpec, SolveBudgets};
268
269    fn create_test_input() -> MeetingSchedulerInput {
270        MeetingSchedulerInput {
271            slots: vec![
272                TimeSlot {
273                    id: "slot-a".to_string(),
274                    start: 1000,
275                    end: 1060,
276                    room: Some("Room A".to_string()),
277                    capacity: 10,
278                },
279                TimeSlot {
280                    id: "slot-b".to_string(),
281                    start: 1100,
282                    end: 1160,
283                    room: Some("Room B".to_string()),
284                    capacity: 5,
285                },
286            ],
287            attendees: vec![
288                Attendee {
289                    id: "alice".to_string(),
290                    name: "Alice".to_string(),
291                    required: true,
292                    available_slots: vec!["slot-a".to_string(), "slot-b".to_string()],
293                    preferences: vec![SlotPreference {
294                        slot_id: "slot-b".to_string(),
295                        score: 20.0,
296                    }],
297                },
298                Attendee {
299                    id: "bob".to_string(),
300                    name: "Bob".to_string(),
301                    required: false,
302                    available_slots: vec!["slot-a".to_string()],
303                    preferences: vec![SlotPreference {
304                        slot_id: "slot-a".to_string(),
305                        score: 5.0,
306                    }],
307                },
308            ],
309            requirements: MeetingRequirements {
310                duration_minutes: 60,
311                min_attendees: 1,
312                require_room: false,
313            },
314        }
315    }
316
317    fn create_spec(input: &MeetingSchedulerInput, seed: u64) -> ProblemSpec {
318        ProblemSpec::builder("test", "tenant")
319            .objective(ObjectiveSpec::maximize("attendance"))
320            .inputs(input)
321            .unwrap()
322            .budgets(SolveBudgets::with_time_limit(10))
323            .seed(seed)
324            .build()
325            .unwrap()
326    }
327
328    #[test]
329    fn test_greedy_solver_basic() {
330        let solver = GreedySolver;
331        let input = create_test_input();
332        let spec = create_spec(&input, 42);
333
334        let (output, report) = solver.solve_meeting(&input, &spec).unwrap();
335
336        assert!(output.selected_slot.is_some());
337        assert!(report.feasible);
338        assert_eq!(report.stop_reason, StopReason::Optimal);
339    }
340
341    #[test]
342    fn test_prefers_more_attendees() {
343        let solver = GreedySolver;
344
345        // Custom input where optional attendee outweighs preference
346        let input = MeetingSchedulerInput {
347            slots: vec![
348                TimeSlot {
349                    id: "slot-a".to_string(),
350                    start: 1000,
351                    end: 1060,
352                    room: Some("Room A".to_string()),
353                    capacity: 10,
354                },
355                TimeSlot {
356                    id: "slot-b".to_string(),
357                    start: 1100,
358                    end: 1160,
359                    room: Some("Room B".to_string()),
360                    capacity: 5,
361                },
362            ],
363            attendees: vec![
364                Attendee {
365                    id: "alice".to_string(),
366                    name: "Alice".to_string(),
367                    required: true,
368                    available_slots: vec!["slot-a".to_string(), "slot-b".to_string()],
369                    preferences: vec![SlotPreference {
370                        slot_id: "slot-b".to_string(),
371                        score: 5.0, // Small preference for slot-b
372                    }],
373                },
374                Attendee {
375                    id: "bob".to_string(),
376                    name: "Bob".to_string(),
377                    required: false,
378                    available_slots: vec!["slot-a".to_string()], // Only available for slot-a
379                    preferences: vec![],
380                },
381            ],
382            requirements: MeetingRequirements {
383                duration_minutes: 60,
384                min_attendees: 1,
385                require_room: false,
386            },
387        };
388
389        let spec = create_spec(&input, 42);
390        let (output, _) = solver.solve_meeting(&input, &spec).unwrap();
391
392        // slot-a: 1000 (required) + 10 (optional Bob) + 0 (no pref) = 1010
393        // slot-b: 1000 (required) + 0 + 5 (Alice pref) = 1005
394        // slot-a wins due to more attendees
395        assert_eq!(output.selected_slot.as_ref().unwrap().id, "slot-a");
396        assert!(output.attending.contains(&"alice".to_string()));
397        assert!(output.attending.contains(&"bob".to_string()));
398    }
399
400    #[test]
401    fn test_preference_breaks_ties() {
402        let solver = GreedySolver;
403
404        // Both slots have same attendance
405        let input = MeetingSchedulerInput {
406            slots: vec![
407                TimeSlot {
408                    id: "slot-a".to_string(),
409                    start: 1000,
410                    end: 1060,
411                    room: None,
412                    capacity: 10,
413                },
414                TimeSlot {
415                    id: "slot-b".to_string(),
416                    start: 1100,
417                    end: 1160,
418                    room: None,
419                    capacity: 10,
420                },
421            ],
422            attendees: vec![Attendee {
423                id: "alice".to_string(),
424                name: "Alice".to_string(),
425                required: true,
426                available_slots: vec!["slot-a".to_string(), "slot-b".to_string()],
427                preferences: vec![SlotPreference {
428                    slot_id: "slot-b".to_string(),
429                    score: 50.0,
430                }],
431            }],
432            requirements: MeetingRequirements {
433                duration_minutes: 60,
434                min_attendees: 1,
435                require_room: false,
436            },
437        };
438
439        let spec = create_spec(&input, 42);
440        let (output, _) = solver.solve_meeting(&input, &spec).unwrap();
441
442        // slot-b should win due to preference
443        assert_eq!(output.selected_slot.as_ref().unwrap().id, "slot-b");
444    }
445
446    #[test]
447    fn test_infeasible_no_overlap() {
448        let solver = GreedySolver;
449
450        let input = MeetingSchedulerInput {
451            slots: vec![
452                TimeSlot {
453                    id: "slot-a".to_string(),
454                    start: 1000,
455                    end: 1060,
456                    room: None,
457                    capacity: 10,
458                },
459                TimeSlot {
460                    id: "slot-b".to_string(),
461                    start: 1100,
462                    end: 1160,
463                    room: None,
464                    capacity: 10,
465                },
466            ],
467            attendees: vec![
468                Attendee {
469                    id: "alice".to_string(),
470                    name: "Alice".to_string(),
471                    required: true,
472                    available_slots: vec!["slot-a".to_string()], // only slot-a
473                    preferences: vec![],
474                },
475                Attendee {
476                    id: "bob".to_string(),
477                    name: "Bob".to_string(),
478                    required: true,
479                    available_slots: vec!["slot-b".to_string()], // only slot-b
480                    preferences: vec![],
481                },
482            ],
483            requirements: MeetingRequirements {
484                duration_minutes: 60,
485                min_attendees: 1,
486                require_room: false,
487            },
488        };
489
490        let spec = create_spec(&input, 42);
491        let (output, report) = solver.solve_meeting(&input, &spec).unwrap();
492
493        assert!(output.selected_slot.is_none());
494        assert!(!report.feasible);
495        assert_eq!(report.stop_reason, StopReason::NoFeasible);
496    }
497
498    #[test]
499    fn test_solver_determinism() {
500        let solver = GreedySolver;
501        let input = create_test_input();
502
503        // Run twice with same seed
504        let spec1 = create_spec(&input, 99999);
505        let spec2 = create_spec(&input, 99999);
506
507        let (output1, _) = solver.solve_meeting(&input, &spec1).unwrap();
508        let (output2, _) = solver.solve_meeting(&input, &spec2).unwrap();
509
510        assert_eq!(
511            output1.selected_slot.as_ref().map(|s| &s.id),
512            output2.selected_slot.as_ref().map(|s| &s.id)
513        );
514        assert_eq!(output1.attending, output2.attending);
515    }
516}