Skip to main content

converge_optimization/packs/lead_routing/
mod.rs

1//! Lead Routing Pack
2//!
3//! JTBD: "Route sales leads to reps based on territory, expertise, and capacity."
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Incoming leads with scores, territories, segments, and required skills
9//! - Sales reps with territories, expertise, and capacity constraints
10//! - Routing configuration (territory requirements, load balancing preferences)
11//!
12//! Find:
13//! - Optimal lead-to-rep assignment maximizing fit while respecting constraints
14//!
15//! ## Solver
16//!
17//! Uses score-based assignment:
18//! 1. Sort leads by priority and score
19//! 2. For each lead, calculate fit scores with all available reps
20//! 3. Filter reps by territory requirement if configured
21//! 4. Assign lead to best-scoring rep with available capacity
22//! 5. Track rep utilization and provide detailed scoring rationale
23//!
24//! ## Invariants
25//!
26//! Critical:
27//! - all_leads_assigned: All leads must be assigned to a rep
28//! - capacity_not_exceeded: No rep should exceed their capacity
29//! - territory_respected: Leads should be assigned to reps covering their territory (when required)
30//!
31//! Advisory:
32//! - load_balanced: Lead assignments should be reasonably balanced across reps
33//! - fit_score_acceptable: Average fit score should meet minimum threshold
34
35mod invariants;
36mod solver;
37mod types;
38
39pub use invariants::*;
40pub use solver::*;
41pub use types::*;
42
43use crate::Result;
44use crate::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
45use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
46
47/// Lead Routing Pack
48pub struct LeadRoutingPack;
49
50impl Pack for LeadRoutingPack {
51    fn name(&self) -> &'static str {
52        "lead-routing"
53    }
54
55    fn version(&self) -> &'static str {
56        "1.0.0"
57    }
58
59    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
60        let input: LeadRoutingInput = serde_json::from_value(inputs.clone())
61            .map_err(|e| crate::Error::invalid_input(format!("Invalid input: {}", e)))?;
62        input.validate()
63    }
64
65    fn invariants(&self) -> &[InvariantDef] {
66        INVARIANTS
67    }
68
69    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
70        let input: LeadRoutingInput = spec.inputs_as()?;
71        input.validate()?;
72
73        let solver = ScoreBasedRoutingSolver;
74        let (output, report) = solver.solve_routing(&input, spec)?;
75
76        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
77        let confidence = calculate_confidence(&output, &input);
78
79        let plan = ProposedPlan::from_payload(
80            format!("plan-{}", spec.problem_id),
81            self.name(),
82            output.summary(),
83            &output,
84            confidence,
85            trace,
86        )?;
87
88        Ok(PackSolveResult::new(plan, report))
89    }
90
91    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
92        let output: LeadRoutingOutput = plan.plan_as()?;
93
94        // We need the input for some invariant checks
95        // For now, create a minimal input for invariant checking
96        // In a full implementation, the input would be stored in the plan or spec
97        let input = LeadRoutingInput {
98            leads: output
99                .assignments
100                .iter()
101                .map(|a| Lead {
102                    id: a.lead_id.clone(),
103                    score: 0.0,
104                    territory: String::new(),
105                    segment: String::new(),
106                    required_skills: vec![],
107                    estimated_value: 0.0,
108                    priority: 5,
109                })
110                .chain(output.unassigned.iter().map(|u| Lead {
111                    id: u.lead_id.clone(),
112                    score: 0.0,
113                    territory: String::new(),
114                    segment: String::new(),
115                    required_skills: vec![],
116                    estimated_value: 0.0,
117                    priority: 5,
118                }))
119                .collect(),
120            reps: output
121                .rep_utilization
122                .iter()
123                .map(|r| SalesRep {
124                    id: r.rep_id.clone(),
125                    name: r.rep_name.clone(),
126                    capacity: r.capacity,
127                    current_load: r.total_load - r.new_assignments,
128                    territories: vec![],
129                    segments: vec![],
130                    skills: vec![],
131                    performance_score: 50.0,
132                })
133                .collect(),
134            config: RoutingConfig::default(),
135        };
136
137        Ok(check_all_invariants(&output, &input))
138    }
139
140    fn evaluate_gate(
141        &self,
142        _plan: &ProposedPlan,
143        invariant_results: &[InvariantResult],
144    ) -> PromotionGate {
145        default_gate_evaluation(invariant_results, &get_invariants())
146    }
147}
148
149fn calculate_confidence(output: &LeadRoutingOutput, _input: &LeadRoutingInput) -> f64 {
150    if output.assignments.is_empty() {
151        return 0.0;
152    }
153
154    let mut confidence = 0.5;
155
156    // Higher confidence if all leads assigned
157    if output.unassigned.is_empty() {
158        confidence += 0.25;
159    } else {
160        // Partial credit based on assignment rate
161        let assignment_rate = output.stats.assigned_leads as f64 / output.stats.total_leads as f64;
162        confidence += 0.25 * assignment_rate;
163    }
164
165    // Higher confidence if fit score is good
166    if output.stats.average_fit_score >= 70.0 {
167        confidence += 0.15;
168    } else if output.stats.average_fit_score >= 50.0 {
169        confidence += 0.1;
170    }
171
172    // Higher confidence if load is balanced (check utilization variance)
173    if output.rep_utilization.len() >= 2 {
174        let utilizations: Vec<f64> = output
175            .rep_utilization
176            .iter()
177            .map(|u| u.utilization_pct)
178            .collect();
179        let avg: f64 = utilizations.iter().sum::<f64>() / utilizations.len() as f64;
180        let variance: f64 =
181            utilizations.iter().map(|u| (u - avg).powi(2)).sum::<f64>() / utilizations.len() as f64;
182        let std_dev = variance.sqrt();
183
184        if std_dev <= 15.0 {
185            confidence += 0.1;
186        }
187    } else {
188        confidence += 0.05;
189    }
190
191    confidence.min(1.0)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::gate::ObjectiveSpec;
198
199    fn create_test_input() -> LeadRoutingInput {
200        LeadRoutingInput {
201            leads: vec![
202                Lead {
203                    id: "lead-1".to_string(),
204                    score: 85.0,
205                    territory: "west".to_string(),
206                    segment: "enterprise".to_string(),
207                    required_skills: vec!["cloud".to_string()],
208                    estimated_value: 100000.0,
209                    priority: 1,
210                },
211                Lead {
212                    id: "lead-2".to_string(),
213                    score: 70.0,
214                    territory: "east".to_string(),
215                    segment: "smb".to_string(),
216                    required_skills: vec![],
217                    estimated_value: 25000.0,
218                    priority: 3,
219                },
220            ],
221            reps: vec![
222                SalesRep {
223                    id: "rep-1".to_string(),
224                    name: "Alice Johnson".to_string(),
225                    capacity: 10,
226                    current_load: 5,
227                    territories: vec!["west".to_string()],
228                    segments: vec!["enterprise".to_string()],
229                    skills: vec!["cloud".to_string()],
230                    performance_score: 92.0,
231                },
232                SalesRep {
233                    id: "rep-2".to_string(),
234                    name: "Bob Smith".to_string(),
235                    capacity: 8,
236                    current_load: 3,
237                    territories: vec!["east".to_string()],
238                    segments: vec!["smb".to_string()],
239                    skills: vec!["demos".to_string()],
240                    performance_score: 78.0,
241                },
242            ],
243            config: RoutingConfig::default(),
244        }
245    }
246
247    #[test]
248    fn test_pack_name() {
249        let pack = LeadRoutingPack;
250        assert_eq!(pack.name(), "lead-routing");
251        assert_eq!(pack.version(), "1.0.0");
252    }
253
254    #[test]
255    fn test_validate_inputs() {
256        let pack = LeadRoutingPack;
257        let input = create_test_input();
258        let json = serde_json::to_value(&input).unwrap();
259        assert!(pack.validate_inputs(&json).is_ok());
260    }
261
262    #[test]
263    fn test_validate_inputs_empty_leads() {
264        let pack = LeadRoutingPack;
265        let input = LeadRoutingInput {
266            leads: vec![],
267            reps: vec![SalesRep {
268                id: "rep-1".to_string(),
269                name: "Test".to_string(),
270                capacity: 10,
271                current_load: 0,
272                territories: vec![],
273                segments: vec![],
274                skills: vec![],
275                performance_score: 50.0,
276            }],
277            config: RoutingConfig::default(),
278        };
279        let json = serde_json::to_value(&input).unwrap();
280        assert!(pack.validate_inputs(&json).is_err());
281    }
282
283    #[test]
284    fn test_solve_basic() {
285        let pack = LeadRoutingPack;
286        let input = create_test_input();
287
288        let spec = ProblemSpec::builder("test-001", "test-tenant")
289            .objective(ObjectiveSpec::maximize("conversion"))
290            .inputs(&input)
291            .unwrap()
292            .seed(42)
293            .build()
294            .unwrap();
295
296        let result = pack.solve(&spec).unwrap();
297        assert!(result.is_feasible());
298
299        let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
300        assert_eq!(output.stats.total_leads, 2);
301        assert_eq!(output.stats.assigned_leads, 2);
302        assert!(output.unassigned.is_empty());
303    }
304
305    #[test]
306    fn test_solve_with_territory_routing() {
307        let pack = LeadRoutingPack;
308        let input = create_test_input();
309
310        let spec = ProblemSpec::builder("test-002", "test-tenant")
311            .objective(ObjectiveSpec::maximize("conversion"))
312            .inputs(&input)
313            .unwrap()
314            .seed(42)
315            .build()
316            .unwrap();
317
318        let result = pack.solve(&spec).unwrap();
319        let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
320
321        // Check that leads are routed to appropriate territory reps
322        for assignment in &output.assignments {
323            if assignment.lead_id == "lead-1" {
324                // West territory lead should go to rep-1 (west territory)
325                assert_eq!(assignment.rep_id, "rep-1");
326            } else if assignment.lead_id == "lead-2" {
327                // East territory lead should go to rep-2 (east territory)
328                assert_eq!(assignment.rep_id, "rep-2");
329            }
330        }
331    }
332
333    #[test]
334    fn test_check_invariants() {
335        let pack = LeadRoutingPack;
336        let input = create_test_input();
337
338        let spec = ProblemSpec::builder("test-003", "test-tenant")
339            .objective(ObjectiveSpec::maximize("conversion"))
340            .inputs(&input)
341            .unwrap()
342            .seed(42)
343            .build()
344            .unwrap();
345
346        let result = pack.solve(&spec).unwrap();
347        let invariants = pack.check_invariants(&result.plan).unwrap();
348
349        // All invariants should pass for a valid solution
350        let critical_pass = invariants
351            .iter()
352            .filter(|r| {
353                r.invariant == "all_leads_assigned" || r.invariant == "capacity_not_exceeded"
354            })
355            .all(|r| r.passed);
356        assert!(critical_pass);
357    }
358
359    #[test]
360    fn test_gate_promotes() {
361        let pack = LeadRoutingPack;
362        let input = create_test_input();
363
364        let spec = ProblemSpec::builder("test-004", "test-tenant")
365            .objective(ObjectiveSpec::maximize("conversion"))
366            .inputs(&input)
367            .unwrap()
368            .seed(42)
369            .build()
370            .unwrap();
371
372        let result = pack.solve(&spec).unwrap();
373        let invariants = pack.check_invariants(&result.plan).unwrap();
374        let gate = pack.evaluate_gate(&result.plan, &invariants);
375
376        // Should promote or require review (not reject)
377        assert!(!gate.is_rejected());
378    }
379
380    #[test]
381    fn test_determinism() {
382        let pack = LeadRoutingPack;
383        let input = create_test_input();
384
385        let spec1 = ProblemSpec::builder("test-a", "tenant")
386            .objective(ObjectiveSpec::maximize("conversion"))
387            .inputs(&input)
388            .unwrap()
389            .seed(99999)
390            .build()
391            .unwrap();
392
393        let spec2 = ProblemSpec::builder("test-b", "tenant")
394            .objective(ObjectiveSpec::maximize("conversion"))
395            .inputs(&input)
396            .unwrap()
397            .seed(99999)
398            .build()
399            .unwrap();
400
401        let result1 = pack.solve(&spec1).unwrap();
402        let result2 = pack.solve(&spec2).unwrap();
403
404        let output1: LeadRoutingOutput = result1.plan.plan_as().unwrap();
405        let output2: LeadRoutingOutput = result2.plan.plan_as().unwrap();
406
407        assert_eq!(output1.assignments.len(), output2.assignments.len());
408        for (a1, a2) in output1.assignments.iter().zip(output2.assignments.iter()) {
409            assert_eq!(a1.lead_id, a2.lead_id);
410            assert_eq!(a1.rep_id, a2.rep_id);
411        }
412    }
413
414    #[test]
415    fn test_capacity_constraint() {
416        let pack = LeadRoutingPack;
417        let mut input = create_test_input();
418
419        // Add many more leads than capacity
420        for i in 0..20 {
421            input.leads.push(Lead {
422                id: format!("lead-extra-{}", i),
423                score: 60.0,
424                territory: "west".to_string(),
425                segment: "enterprise".to_string(),
426                required_skills: vec![],
427                estimated_value: 30000.0,
428                priority: 5,
429            });
430        }
431
432        let spec = ProblemSpec::builder("test-005", "test-tenant")
433            .objective(ObjectiveSpec::maximize("conversion"))
434            .inputs(&input)
435            .unwrap()
436            .seed(42)
437            .build()
438            .unwrap();
439
440        let result = pack.solve(&spec).unwrap();
441        let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
442
443        // Check that capacity is respected
444        for util in &output.rep_utilization {
445            assert!(
446                util.total_load <= util.capacity,
447                "Rep {} has load {} but capacity {}",
448                util.rep_name,
449                util.total_load,
450                util.capacity
451            );
452        }
453
454        // Some leads should be unassigned
455        assert!(!output.unassigned.is_empty());
456    }
457
458    #[test]
459    fn test_scoring_rationale_included() {
460        let pack = LeadRoutingPack;
461        let input = create_test_input();
462
463        let spec = ProblemSpec::builder("test-006", "test-tenant")
464            .objective(ObjectiveSpec::maximize("conversion"))
465            .inputs(&input)
466            .unwrap()
467            .seed(42)
468            .build()
469            .unwrap();
470
471        let result = pack.solve(&spec).unwrap();
472        let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
473
474        for assignment in &output.assignments {
475            assert!(assignment.fit_score > 0.0);
476            assert!(!assignment.scoring_rationale.explanation.is_empty());
477        }
478    }
479
480    #[test]
481    fn test_rep_utilization_output() {
482        let pack = LeadRoutingPack;
483        let input = create_test_input();
484
485        let spec = ProblemSpec::builder("test-007", "test-tenant")
486            .objective(ObjectiveSpec::maximize("conversion"))
487            .inputs(&input)
488            .unwrap()
489            .seed(42)
490            .build()
491            .unwrap();
492
493        let result = pack.solve(&spec).unwrap();
494        let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
495
496        // Should have utilization for reps that received assignments
497        assert!(!output.rep_utilization.is_empty());
498
499        for util in &output.rep_utilization {
500            assert!(util.new_assignments > 0);
501            assert!(util.utilization_pct >= 0.0);
502            assert!(util.utilization_pct <= 100.0);
503        }
504    }
505}