Skip to main content

converge_optimization/packs/meeting_scheduler/
mod.rs

1//! Meeting Scheduler Pack
2//!
3//! Selects meeting time slots with hard/soft constraints on attendee availability.
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Available time slots
9//! - Attendees with availability and preferences
10//! - Meeting requirements (duration, minimum attendees)
11//!
12//! Find:
13//! - Best slot that maximizes attendance and preferences
14//!
15//! ## Solver
16//!
17//! Uses greedy scoring:
18//! 1. Score = (required_available * 1000) + (optional_available * 10) + sum(preferences)
19//! 2. Filter slots where all required attendees available
20//! 3. Apply tie-breaking per DeterminismSpec
21//! 4. Return highest-scoring feasible slot
22
23mod invariants;
24mod solver;
25mod types;
26
27pub use invariants::*;
28pub use solver::*;
29pub use types::*;
30
31use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
32use converge_pack::CONFIDENCE_STEP_MINOR;
33use converge_pack::gate::GateResult as Result;
34use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
35
36/// Meeting Scheduler Pack
37pub struct MeetingSchedulerPack;
38
39impl Pack for MeetingSchedulerPack {
40    fn name(&self) -> &'static str {
41        "meeting-scheduler"
42    }
43
44    fn version(&self) -> &'static str {
45        "1.0.0"
46    }
47
48    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
49        let input: MeetingSchedulerInput = serde_json::from_value(inputs.clone()).map_err(|e| {
50            converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
51        })?;
52        input.validate()
53    }
54
55    fn invariants(&self) -> &[InvariantDef] {
56        INVARIANTS
57    }
58
59    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
60        let input: MeetingSchedulerInput = spec.inputs_as()?;
61        input.validate()?;
62
63        let solver = GreedySolver;
64        let (output, report) = solver.solve_meeting(&input, spec)?;
65
66        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
67        let confidence = calculate_confidence(&output, &input);
68
69        let plan = ProposedPlan::from_payload(
70            format!("plan-{}", spec.problem_id),
71            self.name(),
72            output.summary(),
73            &output,
74            confidence,
75            trace,
76        )?;
77
78        Ok(PackSolveResult::new(plan, report))
79    }
80
81    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
82        let output: MeetingSchedulerOutput = plan.plan_as()?;
83        Ok(check_all_invariants(&output))
84    }
85
86    fn evaluate_gate(
87        &self,
88        plan: &ProposedPlan,
89        invariant_results: &[InvariantResult],
90    ) -> PromotionGate {
91        // Special case: if no slot was selected, reject
92        if let Ok(output) = plan.plan_as::<MeetingSchedulerOutput>() {
93            if output.selected_slot.is_none() {
94                return PromotionGate::reject("No feasible slot found");
95            }
96        }
97
98        default_gate_evaluation(invariant_results, self.invariants())
99    }
100}
101
102/// Calculate confidence score based on output quality
103fn calculate_confidence(output: &MeetingSchedulerOutput, input: &MeetingSchedulerInput) -> f64 {
104    if output.selected_slot.is_none() {
105        return 0.0;
106    }
107
108    let total_attendees = input.attendees.len();
109    if total_attendees == 0 {
110        return 0.5;
111    }
112
113    let attending = output.attending.len();
114    let attendance_ratio = attending as f64 / total_attendees as f64;
115
116    // Base confidence from attendance
117    let mut confidence = 0.5 + (attendance_ratio * 0.3);
118
119    // Bonus for no conflicts
120    if output.conflicts.is_empty() {
121        confidence += CONFIDENCE_STEP_MINOR;
122    }
123
124    // Bonus for high preference score
125    if output.total_preference_score > 0.0 {
126        confidence += 0.1_f64.min(output.total_preference_score / 100.0);
127    }
128
129    confidence.min(1.0)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use converge_pack::gate::{ObjectiveSpec, SolveBudgets};
136
137    fn create_test_input() -> MeetingSchedulerInput {
138        MeetingSchedulerInput {
139            slots: vec![
140                TimeSlot {
141                    id: "slot-1".to_string(),
142                    start: 1000,
143                    end: 1060,
144                    room: Some("Room A".to_string()),
145                    capacity: 10,
146                },
147                TimeSlot {
148                    id: "slot-2".to_string(),
149                    start: 1100,
150                    end: 1160,
151                    room: Some("Room B".to_string()),
152                    capacity: 5,
153                },
154            ],
155            attendees: vec![
156                Attendee {
157                    id: "alice".to_string(),
158                    name: "Alice".to_string(),
159                    required: true,
160                    available_slots: vec!["slot-1".to_string(), "slot-2".to_string()],
161                    preferences: vec![SlotPreference {
162                        slot_id: "slot-1".to_string(),
163                        score: 10.0,
164                    }],
165                },
166                Attendee {
167                    id: "bob".to_string(),
168                    name: "Bob".to_string(),
169                    required: false,
170                    available_slots: vec!["slot-1".to_string()],
171                    preferences: vec![],
172                },
173            ],
174            requirements: MeetingRequirements {
175                duration_minutes: 60,
176                min_attendees: 1,
177                require_room: false,
178            },
179        }
180    }
181
182    #[test]
183    fn test_pack_name() {
184        let pack = MeetingSchedulerPack;
185        assert_eq!(pack.name(), "meeting-scheduler");
186    }
187
188    #[test]
189    fn test_validate_inputs() {
190        let pack = MeetingSchedulerPack;
191        let input = create_test_input();
192        let json = serde_json::to_value(&input).unwrap();
193        assert!(pack.validate_inputs(&json).is_ok());
194    }
195
196    #[test]
197    fn test_solve_basic() {
198        let pack = MeetingSchedulerPack;
199        let input = create_test_input();
200
201        let spec = ProblemSpec::builder("test-001", "test-tenant")
202            .objective(ObjectiveSpec::maximize("attendance"))
203            .inputs(&input)
204            .unwrap()
205            .budgets(SolveBudgets::with_time_limit(10))
206            .seed(42)
207            .build()
208            .unwrap();
209
210        let result = pack.solve(&spec).unwrap();
211        assert!(result.is_feasible());
212
213        let output: MeetingSchedulerOutput = result.plan.plan_as().unwrap();
214        assert!(output.selected_slot.is_some());
215        // Should select slot-1 because both Alice and Bob can attend
216        assert_eq!(output.selected_slot.as_ref().unwrap().id, "slot-1");
217    }
218
219    #[test]
220    fn test_check_invariants() {
221        let pack = MeetingSchedulerPack;
222        let input = create_test_input();
223
224        let spec = ProblemSpec::builder("test-002", "test-tenant")
225            .objective(ObjectiveSpec::maximize("attendance"))
226            .inputs(&input)
227            .unwrap()
228            .seed(42)
229            .build()
230            .unwrap();
231
232        let result = pack.solve(&spec).unwrap();
233        let invariants = pack.check_invariants(&result.plan).unwrap();
234
235        // All invariants should pass for valid solution
236        let critical_passes = invariants
237            .iter()
238            .filter(|r| r.invariant == "all_required_attend")
239            .all(|r| r.passed);
240        assert!(critical_passes);
241    }
242
243    #[test]
244    fn test_evaluate_gate() {
245        let pack = MeetingSchedulerPack;
246        let input = create_test_input();
247
248        let spec = ProblemSpec::builder("test-003", "test-tenant")
249            .objective(ObjectiveSpec::maximize("attendance"))
250            .inputs(&input)
251            .unwrap()
252            .seed(42)
253            .build()
254            .unwrap();
255
256        let result = pack.solve(&spec).unwrap();
257        let invariants = pack.check_invariants(&result.plan).unwrap();
258        let gate = pack.evaluate_gate(&result.plan, &invariants);
259
260        assert!(gate.is_promoted());
261    }
262
263    #[test]
264    fn test_no_feasible_slot() {
265        let pack = MeetingSchedulerPack;
266
267        // Create input where required attendee can't attend any slot
268        let input = MeetingSchedulerInput {
269            slots: vec![TimeSlot {
270                id: "slot-1".to_string(),
271                start: 1000,
272                end: 1060,
273                room: None,
274                capacity: 10,
275            }],
276            attendees: vec![Attendee {
277                id: "alice".to_string(),
278                name: "Alice".to_string(),
279                required: true,
280                available_slots: vec![], // Can't attend any slot
281                preferences: vec![],
282            }],
283            requirements: MeetingRequirements {
284                duration_minutes: 60,
285                min_attendees: 1,
286                require_room: false,
287            },
288        };
289
290        let spec = ProblemSpec::builder("test-004", "test-tenant")
291            .objective(ObjectiveSpec::maximize("attendance"))
292            .inputs(&input)
293            .unwrap()
294            .seed(42)
295            .build()
296            .unwrap();
297
298        let result = pack.solve(&spec).unwrap();
299        let output: MeetingSchedulerOutput = result.plan.plan_as().unwrap();
300
301        // No slot should be selected
302        assert!(output.selected_slot.is_none());
303
304        // Gate should reject
305        let invariants = pack.check_invariants(&result.plan).unwrap();
306        let gate = pack.evaluate_gate(&result.plan, &invariants);
307        assert!(gate.is_rejected());
308    }
309
310    #[test]
311    fn test_determinism() {
312        let pack = MeetingSchedulerPack;
313        let input = create_test_input();
314
315        // Run twice with same seed
316        let spec1 = ProblemSpec::builder("test-005a", "test-tenant")
317            .objective(ObjectiveSpec::maximize("attendance"))
318            .inputs(&input)
319            .unwrap()
320            .seed(12345)
321            .build()
322            .unwrap();
323
324        let spec2 = ProblemSpec::builder("test-005b", "test-tenant")
325            .objective(ObjectiveSpec::maximize("attendance"))
326            .inputs(&input)
327            .unwrap()
328            .seed(12345)
329            .build()
330            .unwrap();
331
332        let result1 = pack.solve(&spec1).unwrap();
333        let result2 = pack.solve(&spec2).unwrap();
334
335        let output1: MeetingSchedulerOutput = result1.plan.plan_as().unwrap();
336        let output2: MeetingSchedulerOutput = result2.plan.plan_as().unwrap();
337
338        assert_eq!(
339            output1.selected_slot.as_ref().map(|s| &s.id),
340            output2.selected_slot.as_ref().map(|s| &s.id)
341        );
342    }
343}