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