Skip to main content

converge_optimization/packs/lead_routing/
solver.rs

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