1use 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
10pub struct GreedySolver;
21
22impl GreedySolver {
23 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 let required_ids: Vec<&str> = input.required_attendees().map(|a| a.id.as_str()).collect();
33
34 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 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 let tie_break = &spec.determinism.tie_break;
50 let seed = spec.seed();
51
52 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 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 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 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 let conflicts = self.build_global_conflicts(input, &required_ids);
106 let output = MeetingSchedulerOutput::no_solution(conflicts);
107 (output, StopReason::NoFeasible)
108 }
109 };
110
111 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 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 let is_feasible = required_count == required_ids.len();
174
175 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 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 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 true
248 }
249}
250
251#[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 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, }],
373 },
374 Attendee {
375 id: "bob".to_string(),
376 name: "Bob".to_string(),
377 required: false,
378 available_slots: vec!["slot-a".to_string()], 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 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 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 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()], 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()], 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 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}