Skip to main content

converge_optimization/packs/lead_routing/
solver.rs

1//! Solver for Lead Routing pack
2
3use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7use std::collections::HashMap;
8
9/// Scoring-based assignment solver for lead routing
10///
11/// Algorithm:
12/// 1. Sort leads by priority (higher priority first) and score (higher first)
13/// 2. For each lead, calculate fit scores with all available reps
14/// 3. Filter reps by territory requirement if configured
15/// 4. Assign lead to best-scoring rep with available capacity
16/// 5. Update rep loads and continue
17pub struct ScoreBasedRoutingSolver;
18
19impl ScoreBasedRoutingSolver {
20    /// Solve the lead routing problem
21    pub fn solve_routing(
22        &self,
23        input: &LeadRoutingInput,
24        spec: &ProblemSpec,
25    ) -> Result<(LeadRoutingOutput, SolverReport)> {
26        let seed = spec.seed();
27        let config = &input.config;
28
29        // Track rep loads (mutable copy)
30        let mut rep_loads: HashMap<String, i64> = input
31            .reps
32            .iter()
33            .map(|r| (r.id.clone(), r.current_load))
34            .collect();
35
36        let mut rep_new_assignments: HashMap<String, i64> =
37            input.reps.iter().map(|r| (r.id.clone(), 0)).collect();
38
39        // Sort leads by priority (ascending = higher priority) then by score (descending)
40        let mut sorted_leads: Vec<&Lead> = input.leads.iter().collect();
41        sorted_leads.sort_by(|a, b| {
42            a.priority
43                .cmp(&b.priority)
44                .then_with(|| b.score.total_cmp(&a.score))
45                .then_with(|| a.id.cmp(&b.id))
46        });
47
48        let mut assignments = Vec::new();
49        let mut unassigned = Vec::new();
50        let mut total_fit_score = 0.0;
51        let mut total_value = 0.0;
52
53        // Process each lead
54        for lead in sorted_leads {
55            let assignment_result = self.find_best_rep(lead, input, config, &rep_loads, spec);
56
57            match assignment_result {
58                Some((rep, fit_score, rationale)) => {
59                    // Update rep load
60                    *rep_loads.get_mut(&rep.id).unwrap() += 1;
61                    *rep_new_assignments.get_mut(&rep.id).unwrap() += 1;
62
63                    assignments.push(LeadAssignment {
64                        lead_id: lead.id.clone(),
65                        rep_id: rep.id.clone(),
66                        rep_name: rep.name.clone(),
67                        fit_score,
68                        scoring_rationale: rationale,
69                    });
70
71                    total_fit_score += fit_score;
72                    total_value += lead.estimated_value;
73                }
74                None => {
75                    let reason = self.determine_unassigned_reason(lead, input, config, &rep_loads);
76                    unassigned.push(UnassignedLead {
77                        lead_id: lead.id.clone(),
78                        reason,
79                    });
80                }
81            }
82        }
83
84        // Build rep utilization
85        let rep_utilization: Vec<RepUtilization> = input
86            .reps
87            .iter()
88            .filter(|r| rep_new_assignments.get(&r.id).copied().unwrap_or(0) > 0)
89            .map(|r| {
90                let new_assignments = rep_new_assignments.get(&r.id).copied().unwrap_or(0);
91                let total_load = rep_loads.get(&r.id).copied().unwrap_or(r.current_load);
92                RepUtilization {
93                    rep_id: r.id.clone(),
94                    rep_name: r.name.clone(),
95                    new_assignments,
96                    total_load,
97                    capacity: r.capacity,
98                    utilization_pct: (total_load as f64 / r.capacity as f64) * 100.0,
99                }
100            })
101            .collect();
102
103        let avg_fit = if !assignments.is_empty() {
104            total_fit_score / assignments.len() as f64
105        } else {
106            0.0
107        };
108
109        let stats = RoutingStats {
110            total_leads: input.leads.len(),
111            assigned_leads: assignments.len(),
112            unassigned_leads: unassigned.len(),
113            average_fit_score: avg_fit,
114            total_estimated_value: total_value,
115            summary: if unassigned.is_empty() {
116                format!("All {} leads assigned successfully", assignments.len())
117            } else {
118                format!(
119                    "Assigned {} leads, {} could not be assigned",
120                    assignments.len(),
121                    unassigned.len()
122                )
123            },
124        };
125
126        let output = LeadRoutingOutput {
127            assignments,
128            unassigned,
129            rep_utilization,
130            stats,
131        };
132
133        let replay = ReplayEnvelope::minimal(seed);
134        let report = if output.stats.assigned_leads > 0 {
135            SolverReport::optimal("score-routing-v1", avg_fit, replay)
136        } else {
137            SolverReport::infeasible("score-routing-v1", vec![], StopReason::NoFeasible, replay)
138        };
139
140        Ok((output, report))
141    }
142
143    /// Find the best rep for a lead
144    fn find_best_rep<'a>(
145        &self,
146        lead: &Lead,
147        input: &'a LeadRoutingInput,
148        config: &RoutingConfig,
149        rep_loads: &HashMap<String, i64>,
150        spec: &ProblemSpec,
151    ) -> Option<(&'a SalesRep, f64, ScoringRationale)> {
152        let tie_break = &spec.determinism.tie_break;
153        let seed = spec.seed();
154
155        // Find candidates with available capacity
156        let mut candidates: Vec<(&SalesRep, f64, ScoringRationale)> = input
157            .reps
158            .iter()
159            .filter_map(|rep| {
160                // Check capacity
161                let current_load = rep_loads.get(&rep.id).copied().unwrap_or(rep.current_load);
162                if current_load >= rep.capacity {
163                    return None;
164                }
165
166                // Check territory requirement
167                if config.require_territory_match && !rep.covers_territory(&lead.territory) {
168                    return None;
169                }
170
171                // Calculate detailed scoring
172                let (fit_score, rationale) =
173                    self.calculate_detailed_score(lead, rep, current_load, config);
174
175                // Require minimum fit score
176                if fit_score < 10.0 {
177                    return None;
178                }
179
180                Some((rep, fit_score, rationale))
181            })
182            .collect();
183
184        if candidates.is_empty() {
185            return None;
186        }
187
188        // Sort by fit score descending, then by rep ID for determinism
189        candidates.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.id.cmp(&b.0.id)));
190
191        // Handle ties deterministically using tie_break
192        let best_score = candidates[0].1;
193        let tied: Vec<_> = candidates
194            .iter()
195            .filter(|(_, score, _)| (score - best_score).abs() < 0.01)
196            .collect();
197
198        if tied.len() == 1 {
199            let (rep, score, rationale) = candidates.remove(0);
200            Some((rep, score, rationale))
201        } else {
202            // Select from tied candidates using deterministic tie-breaking
203            let selected = tie_break.select_by(&tied, seed, |a, b| a.0.id.cmp(&b.0.id));
204
205            if let Some(&(rep, score, rationale)) = selected {
206                Some((rep, *score, rationale.clone()))
207            } else {
208                // Fallback: take first (already sorted by ID)
209                let (rep, score, rationale) = candidates.remove(0);
210                Some((rep, score, rationale))
211            }
212        }
213    }
214
215    /// Calculate detailed fit score with rationale
216    fn calculate_detailed_score(
217        &self,
218        lead: &Lead,
219        rep: &SalesRep,
220        current_load: i64,
221        config: &RoutingConfig,
222    ) -> (f64, ScoringRationale) {
223        // Base scores (each out of 100 for normalization)
224        let territory_score = if rep.covers_territory(&lead.territory) {
225            100.0
226        } else {
227            0.0
228        };
229
230        let segment_score = if rep.segments.contains(&lead.segment) {
231            100.0
232        } else if rep.segments.is_empty() {
233            50.0 // Generalist gets partial credit
234        } else {
235            0.0
236        };
237
238        let skills_score = if lead.required_skills.is_empty() {
239            100.0
240        } else {
241            let matched = lead
242                .required_skills
243                .iter()
244                .filter(|s| rep.skills.contains(*s))
245                .count();
246            (matched as f64 / lead.required_skills.len() as f64) * 100.0
247        };
248
249        let performance_score = rep.performance_score;
250
251        // Capacity factor (penalize high utilization if load balancing enabled)
252        let capacity_factor = if config.balance_load {
253            let utilization = current_load as f64 / rep.capacity as f64;
254            1.0 - (utilization * 0.4) // Max 40% penalty at full load
255        } else {
256            1.0
257        };
258
259        // Weighted combination
260        let raw_score = (territory_score * config.territory_weight)
261            + (segment_score * config.expertise_weight * 0.5)
262            + (skills_score * config.expertise_weight * 0.5)
263            + (performance_score * 0.1);
264
265        let final_score = raw_score * capacity_factor;
266
267        // Build explanation
268        let mut explanation_parts = Vec::new();
269        if territory_score > 0.0 {
270            explanation_parts.push("territory match".to_string());
271        }
272        if segment_score >= 100.0 {
273            explanation_parts.push("segment match".to_string());
274        }
275        if skills_score >= 100.0 && !lead.required_skills.is_empty() {
276            explanation_parts.push("full skills match".to_string());
277        } else if skills_score > 0.0 && skills_score < 100.0 {
278            explanation_parts.push("partial skills match".to_string());
279        }
280        if capacity_factor < 1.0 {
281            explanation_parts.push(format!("load factor {:.0}%", capacity_factor * 100.0));
282        }
283
284        let explanation = if explanation_parts.is_empty() {
285            "baseline score".to_string()
286        } else {
287            explanation_parts.join(", ")
288        };
289
290        let rationale = ScoringRationale {
291            territory_score,
292            segment_score,
293            skills_score,
294            performance_score,
295            capacity_factor,
296            explanation,
297        };
298
299        (final_score, rationale)
300    }
301
302    /// Determine why a lead could not be assigned
303    fn determine_unassigned_reason(
304        &self,
305        lead: &Lead,
306        input: &LeadRoutingInput,
307        config: &RoutingConfig,
308        rep_loads: &HashMap<String, i64>,
309    ) -> String {
310        // Check if any rep has capacity
311        let any_capacity = input.reps.iter().any(|r| {
312            let load = rep_loads.get(&r.id).copied().unwrap_or(r.current_load);
313            load < r.capacity
314        });
315
316        if !any_capacity {
317            return "All reps are at full capacity".to_string();
318        }
319
320        // Check territory constraint
321        if config.require_territory_match {
322            let territory_reps: Vec<_> = input
323                .reps
324                .iter()
325                .filter(|r| r.covers_territory(&lead.territory))
326                .collect();
327
328            if territory_reps.is_empty() {
329                return format!("No reps cover territory '{}'", lead.territory);
330            }
331
332            let available_territory_reps: Vec<_> = territory_reps
333                .iter()
334                .filter(|r| {
335                    let load = rep_loads.get(&r.id).copied().unwrap_or(r.current_load);
336                    load < r.capacity
337                })
338                .collect();
339
340            if available_territory_reps.is_empty() {
341                return format!(
342                    "All reps covering territory '{}' are at capacity",
343                    lead.territory
344                );
345            }
346        }
347
348        // Generic fallback
349        "No suitable rep found with available capacity".to_string()
350    }
351}
352
353impl PackSolver for ScoreBasedRoutingSolver {
354    fn id(&self) -> &'static str {
355        "score-routing-v1"
356    }
357
358    fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
359        let input: LeadRoutingInput = spec.inputs_as()?;
360        let (output, report) = self.solve_routing(&input, spec)?;
361        let json = serde_json::to_value(&output)
362            .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
363        Ok((json, report))
364    }
365
366    fn is_exact(&self) -> bool {
367        false // Greedy heuristic, not globally optimal
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use converge_pack::gate::ObjectiveSpec;
375
376    fn create_test_input() -> LeadRoutingInput {
377        LeadRoutingInput {
378            leads: vec![
379                Lead {
380                    id: "lead-1".to_string(),
381                    score: 85.0,
382                    territory: "west".to_string(),
383                    segment: "enterprise".to_string(),
384                    required_skills: vec!["cloud".to_string()],
385                    estimated_value: 100000.0,
386                    priority: 1,
387                },
388                Lead {
389                    id: "lead-2".to_string(),
390                    score: 70.0,
391                    territory: "east".to_string(),
392                    segment: "smb".to_string(),
393                    required_skills: vec![],
394                    estimated_value: 25000.0,
395                    priority: 3,
396                },
397                Lead {
398                    id: "lead-3".to_string(),
399                    score: 90.0,
400                    territory: "west".to_string(),
401                    segment: "enterprise".to_string(),
402                    required_skills: vec!["cloud".to_string(), "ai".to_string()],
403                    estimated_value: 200000.0,
404                    priority: 1,
405                },
406            ],
407            reps: vec![
408                SalesRep {
409                    id: "rep-1".to_string(),
410                    name: "Alice Johnson".to_string(),
411                    capacity: 10,
412                    current_load: 7,
413                    territories: vec!["west".to_string()],
414                    segments: vec!["enterprise".to_string()],
415                    skills: vec!["cloud".to_string(), "ai".to_string()],
416                    performance_score: 92.0,
417                },
418                SalesRep {
419                    id: "rep-2".to_string(),
420                    name: "Bob Smith".to_string(),
421                    capacity: 8,
422                    current_load: 3,
423                    territories: vec!["east".to_string(), "midwest".to_string()],
424                    segments: vec!["smb".to_string(), "mid-market".to_string()],
425                    skills: vec!["demos".to_string()],
426                    performance_score: 78.0,
427                },
428                SalesRep {
429                    id: "rep-3".to_string(),
430                    name: "Carol Davis".to_string(),
431                    capacity: 12,
432                    current_load: 5,
433                    territories: vec!["west".to_string(), "east".to_string()],
434                    segments: vec!["enterprise".to_string(), "smb".to_string()],
435                    skills: vec!["cloud".to_string()],
436                    performance_score: 85.0,
437                },
438            ],
439            config: RoutingConfig::default(),
440        }
441    }
442
443    fn create_spec(input: &LeadRoutingInput, seed: u64) -> ProblemSpec {
444        ProblemSpec::builder("test", "tenant")
445            .objective(ObjectiveSpec::maximize("conversion"))
446            .inputs(input)
447            .unwrap()
448            .seed(seed)
449            .build()
450            .unwrap()
451    }
452
453    #[test]
454    fn test_basic_routing() {
455        let solver = ScoreBasedRoutingSolver;
456        let input = create_test_input();
457        let spec = create_spec(&input, 42);
458
459        let (output, report) = solver.solve_routing(&input, &spec).unwrap();
460
461        assert!(report.feasible);
462        assert_eq!(output.stats.total_leads, 3);
463        assert_eq!(output.stats.assigned_leads, 3);
464        assert!(output.unassigned.is_empty());
465    }
466
467    #[test]
468    fn test_territory_matching() {
469        let solver = ScoreBasedRoutingSolver;
470        let input = create_test_input();
471        let spec = create_spec(&input, 42);
472
473        let (output, _) = solver.solve_routing(&input, &spec).unwrap();
474
475        // Check that west leads go to reps covering west
476        for assignment in &output.assignments {
477            let lead = input
478                .leads
479                .iter()
480                .find(|l| l.id == assignment.lead_id)
481                .unwrap();
482            let rep = input
483                .reps
484                .iter()
485                .find(|r| r.id == assignment.rep_id)
486                .unwrap();
487
488            // Either territory matches or it's not required
489            assert!(
490                rep.covers_territory(&lead.territory) || !input.config.require_territory_match,
491                "Lead {} in {} assigned to rep {} covering {:?}",
492                lead.id,
493                lead.territory,
494                rep.id,
495                rep.territories
496            );
497        }
498    }
499
500    #[test]
501    fn test_capacity_constraint() {
502        let solver = ScoreBasedRoutingSolver;
503        let mut input = create_test_input();
504
505        // Set all reps to near capacity
506        for rep in &mut input.reps {
507            rep.current_load = rep.capacity - 1;
508        }
509
510        // Add more leads than available capacity
511        for i in 0..10 {
512            input.leads.push(Lead {
513                id: format!("overflow-{}", i),
514                score: 50.0,
515                territory: "west".to_string(),
516                segment: "smb".to_string(),
517                required_skills: vec![],
518                estimated_value: 10000.0,
519                priority: 5,
520            });
521        }
522
523        let spec = create_spec(&input, 42);
524        let (output, _) = solver.solve_routing(&input, &spec).unwrap();
525
526        // Should have some unassigned due to capacity
527        assert!(!output.unassigned.is_empty());
528
529        // Total load should not exceed capacity for any rep
530        for util in &output.rep_utilization {
531            assert!(util.total_load <= util.capacity);
532        }
533    }
534
535    #[test]
536    fn test_priority_ordering() {
537        let solver = ScoreBasedRoutingSolver;
538        let input = LeadRoutingInput {
539            leads: vec![
540                Lead {
541                    id: "low-priority".to_string(),
542                    score: 95.0,
543                    territory: "west".to_string(),
544                    segment: "enterprise".to_string(),
545                    required_skills: vec![],
546                    estimated_value: 500000.0,
547                    priority: 5, // Low priority
548                },
549                Lead {
550                    id: "high-priority".to_string(),
551                    score: 60.0,
552                    territory: "west".to_string(),
553                    segment: "enterprise".to_string(),
554                    required_skills: vec![],
555                    estimated_value: 50000.0,
556                    priority: 1, // High priority
557                },
558            ],
559            reps: vec![SalesRep {
560                id: "rep-1".to_string(),
561                name: "Only Rep".to_string(),
562                capacity: 1, // Can only take one lead
563                current_load: 0,
564                territories: vec!["west".to_string()],
565                segments: vec!["enterprise".to_string()],
566                skills: vec![],
567                performance_score: 80.0,
568            }],
569            config: RoutingConfig::default(),
570        };
571
572        let spec = create_spec(&input, 42);
573        let (output, _) = solver.solve_routing(&input, &spec).unwrap();
574
575        // High priority lead should be assigned (despite lower score)
576        assert_eq!(output.assignments.len(), 1);
577        assert_eq!(output.assignments[0].lead_id, "high-priority");
578        assert_eq!(output.unassigned.len(), 1);
579        assert_eq!(output.unassigned[0].lead_id, "low-priority");
580    }
581
582    #[test]
583    fn test_required_territory_match() {
584        let solver = ScoreBasedRoutingSolver;
585        let input = LeadRoutingInput {
586            leads: vec![Lead {
587                id: "lead-1".to_string(),
588                score: 80.0,
589                territory: "south".to_string(), // No rep covers this
590                segment: "enterprise".to_string(),
591                required_skills: vec![],
592                estimated_value: 50000.0,
593                priority: 1,
594            }],
595            reps: vec![SalesRep {
596                id: "rep-1".to_string(),
597                name: "Rep 1".to_string(),
598                capacity: 10,
599                current_load: 0,
600                territories: vec!["west".to_string(), "east".to_string()],
601                segments: vec!["enterprise".to_string()],
602                skills: vec![],
603                performance_score: 80.0,
604            }],
605            config: RoutingConfig {
606                require_territory_match: true,
607                ..Default::default()
608            },
609        };
610
611        let spec = create_spec(&input, 42);
612        let (output, _) = solver.solve_routing(&input, &spec).unwrap();
613
614        // Lead should be unassigned due to territory requirement
615        assert!(output.assignments.is_empty());
616        assert_eq!(output.unassigned.len(), 1);
617        assert!(output.unassigned[0].reason.contains("territory"));
618    }
619
620    #[test]
621    fn test_scoring_rationale() {
622        let solver = ScoreBasedRoutingSolver;
623        let input = create_test_input();
624        let spec = create_spec(&input, 42);
625
626        let (output, _) = solver.solve_routing(&input, &spec).unwrap();
627
628        for assignment in &output.assignments {
629            // Each assignment should have scoring rationale
630            let rationale = &assignment.scoring_rationale;
631            assert!(!rationale.explanation.is_empty());
632            assert!(rationale.capacity_factor > 0.0);
633            assert!(rationale.capacity_factor <= 1.0);
634        }
635    }
636
637    #[test]
638    fn test_determinism() {
639        let solver = ScoreBasedRoutingSolver;
640        let input = create_test_input();
641
642        let spec1 = create_spec(&input, 12345);
643        let spec2 = create_spec(&input, 12345);
644
645        let (output1, _) = solver.solve_routing(&input, &spec1).unwrap();
646        let (output2, _) = solver.solve_routing(&input, &spec2).unwrap();
647
648        assert_eq!(output1.assignments.len(), output2.assignments.len());
649        for (a1, a2) in output1.assignments.iter().zip(output2.assignments.iter()) {
650            assert_eq!(a1.lead_id, a2.lead_id);
651            assert_eq!(a1.rep_id, a2.rep_id);
652        }
653    }
654
655    #[test]
656    fn test_rep_utilization_output() {
657        let solver = ScoreBasedRoutingSolver;
658        let input = create_test_input();
659        let spec = create_spec(&input, 42);
660
661        let (output, _) = solver.solve_routing(&input, &spec).unwrap();
662
663        // Check utilization stats
664        for util in &output.rep_utilization {
665            assert!(util.utilization_pct >= 0.0);
666            assert!(util.utilization_pct <= 100.0);
667            assert!(util.new_assignments > 0);
668        }
669    }
670}