Skip to main content

converge_optimization/packs/pricing_guardrails/
solver.rs

1//! Solver for Pricing Guardrails pack
2
3use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8/// Rule-based pricing solver that respects margins and guardrails
9///
10/// Algorithm:
11/// 1. For each product, calculate minimum price to meet margin requirement
12/// 2. Apply competitive strategy to adjust price within bounds
13/// 3. Ensure price stays within guardrails
14/// 4. Generate recommendations with compliance analysis
15pub struct GuardrailPricingSolver;
16
17impl GuardrailPricingSolver {
18    /// Solve the pricing guardrails problem
19    pub fn solve_pricing(
20        &self,
21        input: &PricingGuardrailsInput,
22        spec: &ProblemSpec,
23    ) -> Result<(PricingGuardrailsOutput, SolverReport)> {
24        let seed = spec.seed();
25        let margin_req = &input.margin_requirements;
26
27        let mut recommendations = Vec::new();
28        let mut violations = Vec::new();
29
30        for product in &input.products {
31            let recommendation = self.price_product(product, margin_req, &input.price_bounds)?;
32
33            // Track violations
34            if !recommendation.within_bounds {
35                violations.push(format!(
36                    "Product {} price ${:.2} outside bounds",
37                    product.product_id, recommendation.recommended_price
38                ));
39            }
40            if !recommendation.margin_target_met {
41                violations.push(format!(
42                    "Product {} margin {:.1}% below minimum {:.1}%",
43                    product.product_id, recommendation.margin_pct, margin_req.min_margin_pct
44                ));
45            }
46
47            recommendations.push(recommendation);
48        }
49
50        // Calculate margin analysis
51        let margin_analysis = self.calculate_margin_analysis(&recommendations, margin_req);
52
53        // Calculate guardrail compliance
54        let all_within_bounds = recommendations.iter().all(|r| r.within_bounds);
55        let all_margins_met = recommendations.iter().all(|r| r.margin_target_met);
56        let competitive_position_achieved =
57            self.check_competitive_position(&recommendations, &input.products, margin_req);
58
59        let guardrail_compliance = GuardrailCompliance {
60            all_within_bounds,
61            all_margins_met,
62            competitive_position_achieved,
63            violations,
64        };
65
66        let output = PricingGuardrailsOutput {
67            recommendations,
68            margin_analysis,
69            guardrail_compliance,
70        };
71
72        let replay = ReplayEnvelope::minimal(seed);
73        let is_feasible = all_within_bounds && all_margins_met;
74
75        let report = if is_feasible {
76            SolverReport::optimal(
77                "guardrail-pricing-v1",
78                output.margin_analysis.average_margin_pct,
79                replay,
80            )
81        } else {
82            SolverReport::feasible(
83                "guardrail-pricing-v1",
84                output.margin_analysis.average_margin_pct,
85                StopReason::Feasible,
86                replay,
87            )
88        };
89
90        Ok((output, report))
91    }
92
93    /// Price a single product according to rules
94    fn price_product(
95        &self,
96        product: &Product,
97        margin_req: &MarginRequirements,
98        global_bounds: &Option<PriceBounds>,
99    ) -> Result<PricingRecommendation> {
100        // Step 1: Calculate minimum price for required margin
101        // margin = (price - cost) / price
102        // margin * price = price - cost
103        // cost = price - margin * price = price * (1 - margin)
104        // price = cost / (1 - margin)
105        let min_margin_decimal = margin_req.min_margin_pct / 100.0;
106        let target_margin_decimal = margin_req.target_margin_pct / 100.0;
107
108        let min_price_for_margin = if min_margin_decimal < 1.0 {
109            product.unit_cost / (1.0 - min_margin_decimal)
110        } else {
111            f64::MAX // Can't achieve 100%+ margin
112        };
113
114        let target_price_for_margin = if target_margin_decimal < 1.0 {
115            product.unit_cost / (1.0 - target_margin_decimal)
116        } else {
117            min_price_for_margin * 1.5 // Reasonable fallback
118        };
119
120        // Step 2: Get effective bounds
121        let effective_bounds = product.effective_bounds(global_bounds);
122        let (bound_min, bound_max) = match &effective_bounds {
123            Some(b) => (b.min_price, b.max_price),
124            None => (0.0, f64::MAX),
125        };
126
127        // Step 3: Apply competitive strategy
128        let competitive_price = self.calculate_competitive_price(product, margin_req);
129
130        // Step 4: Determine recommended price
131        // Start with target margin price, then adjust for competition
132        let mut recommended_price = match competitive_price {
133            Some(comp_price) => {
134                match margin_req.competitive_strategy {
135                    CompetitiveStrategy::IgnoreCompetitors => target_price_for_margin,
136                    _ => {
137                        // Balance between target margin and competitive price
138                        // Weight toward target margin but consider competition
139                        (target_price_for_margin * 0.4 + comp_price * 0.6).max(min_price_for_margin)
140                    }
141                }
142            }
143            None => target_price_for_margin,
144        };
145
146        // Ensure minimum margin is maintained
147        recommended_price = recommended_price.max(min_price_for_margin);
148
149        // Check bounds compliance (we'll track if violated but still recommend best possible)
150        let within_bounds = recommended_price >= bound_min && recommended_price <= bound_max;
151
152        // Clamp to bounds if needed
153        recommended_price = recommended_price.max(bound_min).min(bound_max);
154
155        // Recalculate margin after clamping
156        let margin_pct = product.margin_at_price(recommended_price);
157        let markup_pct = product.markup_at_price(recommended_price);
158        let margin_target_met = margin_pct >= margin_req.min_margin_pct;
159
160        // Calculate price change from current
161        let (price_change, price_change_pct) = match product.current_price {
162            Some(current) if current > 0.0 => {
163                let change = recommended_price - current;
164                let change_pct = (change / current) * 100.0;
165                (Some(change), Some(change_pct))
166            }
167            _ => (None, None),
168        };
169
170        // Build competitive position
171        let competitive_position = self.build_competitive_position(product, recommended_price);
172
173        // Build rationale
174        let rationale = self.build_rationale(
175            product,
176            recommended_price,
177            margin_pct,
178            margin_req,
179            &competitive_position,
180            within_bounds,
181        );
182
183        Ok(PricingRecommendation {
184            product_id: product.product_id.clone(),
185            recommended_price,
186            previous_price: product.current_price,
187            price_change,
188            price_change_pct,
189            margin_pct,
190            markup_pct,
191            competitive_position,
192            within_bounds,
193            margin_target_met,
194            rationale,
195        })
196    }
197
198    /// Calculate competitive price based on strategy
199    fn calculate_competitive_price(
200        &self,
201        product: &Product,
202        margin_req: &MarginRequirements,
203    ) -> Option<f64> {
204        let avg_competitor = product.avg_competitor_price()?;
205
206        match margin_req.competitive_strategy {
207            CompetitiveStrategy::PriceToBeat => {
208                // Price 5% below market average
209                Some(avg_competitor * 0.95)
210            }
211            CompetitiveStrategy::MatchMarket => {
212                // Match market average
213                Some(avg_competitor)
214            }
215            CompetitiveStrategy::Premium => {
216                // Price 10% above market average
217                Some(avg_competitor * 1.10)
218            }
219            CompetitiveStrategy::IgnoreCompetitors => None,
220        }
221    }
222
223    /// Build competitive position analysis
224    fn build_competitive_position(&self, product: &Product, price: f64) -> CompetitivePosition {
225        let avg_competitor = product.avg_competitor_price();
226        let competitor_count = product.competitor_prices.len();
227
228        let position_vs_avg_pct = avg_competitor.map(|avg| {
229            if avg > 0.0 {
230                ((price - avg) / avg) * 100.0
231            } else {
232                0.0
233            }
234        });
235
236        let (lowest_in_market, highest_in_market) = match product.competitor_price_range() {
237            Some((min, max)) => (price < min, price > max),
238            None => (false, false),
239        };
240
241        CompetitivePosition {
242            avg_competitor_price: avg_competitor,
243            position_vs_avg_pct,
244            competitor_count,
245            lowest_in_market,
246            highest_in_market,
247        }
248    }
249
250    /// Build human-readable rationale
251    fn build_rationale(
252        &self,
253        _product: &Product,
254        _price: f64,
255        margin_pct: f64,
256        margin_req: &MarginRequirements,
257        competitive_position: &CompetitivePosition,
258        within_bounds: bool,
259    ) -> String {
260        let mut parts = Vec::new();
261
262        // Margin explanation
263        if margin_pct >= margin_req.target_margin_pct {
264            parts.push(format!("Achieves target margin of {:.1}%", margin_pct));
265        } else if margin_pct >= margin_req.min_margin_pct {
266            parts.push(format!(
267                "Margin {:.1}% meets minimum but below {:.1}% target",
268                margin_pct, margin_req.target_margin_pct
269            ));
270        } else {
271            parts.push(format!(
272                "Margin {:.1}% below minimum {:.1}% due to constraints",
273                margin_pct, margin_req.min_margin_pct
274            ));
275        }
276
277        // Competitive position
278        if let Some(pos_pct) = competitive_position.position_vs_avg_pct {
279            if pos_pct.abs() < 1.0 {
280                parts.push("Matches market average".to_string());
281            } else if pos_pct < 0.0 {
282                parts.push(format!("{:.1}% below market", pos_pct.abs()));
283            } else {
284                parts.push(format!("{:.1}% above market", pos_pct));
285            }
286        }
287
288        // Bounds compliance
289        if !within_bounds {
290            parts.push("Adjusted to fit guardrails".to_string());
291        }
292
293        parts.join(". ")
294    }
295
296    /// Calculate overall margin analysis
297    fn calculate_margin_analysis(
298        &self,
299        recommendations: &[PricingRecommendation],
300        margin_req: &MarginRequirements,
301    ) -> MarginAnalysis {
302        if recommendations.is_empty() {
303            return MarginAnalysis::default();
304        }
305
306        let total_products = recommendations.len();
307        let products_meeting_margin = recommendations
308            .iter()
309            .filter(|r| r.margin_pct >= margin_req.min_margin_pct)
310            .count();
311
312        let margins: Vec<f64> = recommendations.iter().map(|r| r.margin_pct).collect();
313        let average_margin_pct = margins.iter().sum::<f64>() / margins.len() as f64;
314        let min_margin_pct = margins.iter().cloned().fold(f64::INFINITY, f64::min);
315        let max_margin_pct = margins.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
316
317        MarginAnalysis {
318            total_products,
319            products_meeting_margin,
320            average_margin_pct,
321            min_margin_pct,
322            max_margin_pct,
323        }
324    }
325
326    /// Check if competitive positioning strategy was achieved
327    fn check_competitive_position(
328        &self,
329        recommendations: &[PricingRecommendation],
330        products: &[Product],
331        margin_req: &MarginRequirements,
332    ) -> bool {
333        if margin_req.competitive_strategy == CompetitiveStrategy::IgnoreCompetitors {
334            return true;
335        }
336
337        // For products with competitor data, check if strategy was achieved
338        let mut achieved = 0;
339        let mut applicable = 0;
340
341        for (rec, prod) in recommendations.iter().zip(products.iter()) {
342            if prod.competitor_prices.is_empty() {
343                continue;
344            }
345            applicable += 1;
346
347            if let Some(pos_pct) = rec.competitive_position.position_vs_avg_pct {
348                let strategy_achieved = match margin_req.competitive_strategy {
349                    CompetitiveStrategy::PriceToBeat => pos_pct <= -3.0, // At least 3% below
350                    CompetitiveStrategy::MatchMarket => pos_pct.abs() <= 5.0, // Within 5%
351                    CompetitiveStrategy::Premium => pos_pct >= 5.0,      // At least 5% above
352                    CompetitiveStrategy::IgnoreCompetitors => true,
353                };
354                if strategy_achieved {
355                    achieved += 1;
356                }
357            }
358        }
359
360        if applicable == 0 {
361            true // No products to evaluate
362        } else {
363            achieved as f64 / applicable as f64 >= 0.8 // 80% threshold
364        }
365    }
366}
367
368impl PackSolver for GuardrailPricingSolver {
369    fn id(&self) -> &'static str {
370        "guardrail-pricing-v1"
371    }
372
373    fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
374        let input: PricingGuardrailsInput = spec.inputs_as()?;
375        let (output, report) = self.solve_pricing(&input, spec)?;
376        let json = serde_json::to_value(&output)
377            .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
378        Ok((json, report))
379    }
380
381    fn is_exact(&self) -> bool {
382        true // Rule-based, deterministic
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use converge_pack::gate::ObjectiveSpec;
390
391    fn create_test_input() -> PricingGuardrailsInput {
392        PricingGuardrailsInput {
393            products: vec![
394                Product {
395                    product_id: "SKU-001".to_string(),
396                    name: "Widget A".to_string(),
397                    unit_cost: 80.0,
398                    current_price: Some(100.0),
399                    price_bounds: Some(PriceBounds {
400                        min_price: 90.0,
401                        max_price: 150.0,
402                    }),
403                    competitor_prices: vec![
404                        CompetitorPrice {
405                            competitor_id: "comp1".to_string(),
406                            price: 110.0,
407                            as_of_date: None,
408                        },
409                        CompetitorPrice {
410                            competitor_id: "comp2".to_string(),
411                            price: 105.0,
412                            as_of_date: None,
413                        },
414                    ],
415                    category: Some("widgets".to_string()),
416                },
417                Product {
418                    product_id: "SKU-002".to_string(),
419                    name: "Widget B".to_string(),
420                    unit_cost: 50.0,
421                    current_price: None,
422                    price_bounds: None,
423                    competitor_prices: vec![],
424                    category: Some("widgets".to_string()),
425                },
426            ],
427            margin_requirements: MarginRequirements {
428                min_margin_pct: 20.0,
429                target_margin_pct: 30.0,
430                competitive_strategy: CompetitiveStrategy::MatchMarket,
431            },
432            price_bounds: Some(PriceBounds {
433                min_price: 10.0,
434                max_price: 1000.0,
435            }),
436        }
437    }
438
439    fn create_spec(input: &PricingGuardrailsInput, seed: u64) -> ProblemSpec {
440        ProblemSpec::builder("test", "tenant")
441            .objective(ObjectiveSpec::maximize("margin"))
442            .inputs(input)
443            .unwrap()
444            .seed(seed)
445            .build()
446            .unwrap()
447    }
448
449    #[test]
450    fn test_basic_pricing() {
451        let solver = GuardrailPricingSolver;
452        let input = create_test_input();
453        let spec = create_spec(&input, 42);
454
455        let (output, report) = solver.solve_pricing(&input, &spec).unwrap();
456
457        assert_eq!(output.recommendations.len(), 2);
458        assert!(report.feasible);
459
460        // Check first product
461        let rec1 = &output.recommendations[0];
462        assert_eq!(rec1.product_id, "SKU-001");
463        assert!(rec1.margin_pct >= 20.0); // Meets minimum margin
464        assert!(rec1.within_bounds);
465    }
466
467    #[test]
468    fn test_margin_calculation() {
469        let solver = GuardrailPricingSolver;
470        let mut input = create_test_input();
471        input.products = vec![Product {
472            product_id: "test".to_string(),
473            name: "Test".to_string(),
474            unit_cost: 80.0,
475            current_price: None,
476            price_bounds: None,
477            competitor_prices: vec![],
478            category: None,
479        }];
480        input.margin_requirements.min_margin_pct = 20.0;
481        input.margin_requirements.target_margin_pct = 25.0;
482        input.margin_requirements.competitive_strategy = CompetitiveStrategy::IgnoreCompetitors;
483
484        let spec = create_spec(&input, 42);
485        let (output, _) = solver.solve_pricing(&input, &spec).unwrap();
486
487        let rec = &output.recommendations[0];
488        // For 25% target margin with $80 cost: price = 80 / (1 - 0.25) = 106.67
489        assert!(rec.margin_pct >= 25.0 - 0.1);
490        assert!(rec.margin_target_met);
491    }
492
493    #[test]
494    fn test_price_bounds_enforced() {
495        let solver = GuardrailPricingSolver;
496        let mut input = create_test_input();
497        input.products = vec![Product {
498            product_id: "constrained".to_string(),
499            name: "Constrained".to_string(),
500            unit_cost: 80.0,
501            current_price: None,
502            price_bounds: Some(PriceBounds {
503                min_price: 85.0,
504                max_price: 90.0, // Very tight bounds
505            }),
506            competitor_prices: vec![],
507            category: None,
508        }];
509
510        let spec = create_spec(&input, 42);
511        let (output, _) = solver.solve_pricing(&input, &spec).unwrap();
512
513        let rec = &output.recommendations[0];
514        assert!(rec.recommended_price >= 85.0);
515        assert!(rec.recommended_price <= 90.0);
516        // With $80 cost and max $90 price, margin = (90-80)/90 = 11.1%
517        // This is below minimum, so margin_target_met should be false
518        assert!(!rec.margin_target_met);
519    }
520
521    #[test]
522    fn test_competitive_strategy_price_to_beat() {
523        let solver = GuardrailPricingSolver;
524        let mut input = create_test_input();
525        input.margin_requirements.competitive_strategy = CompetitiveStrategy::PriceToBeat;
526        input.margin_requirements.min_margin_pct = 5.0; // Low margin to allow competitive pricing
527
528        let spec = create_spec(&input, 42);
529        let (output, _) = solver.solve_pricing(&input, &spec).unwrap();
530
531        let rec1 = &output.recommendations[0];
532        // Should be priced below market average of 107.5
533        if let Some(pos) = rec1.competitive_position.position_vs_avg_pct {
534            // Should be at or below market
535            assert!(pos <= 0.0 || rec1.margin_pct >= input.margin_requirements.min_margin_pct);
536        }
537    }
538
539    #[test]
540    fn test_competitive_strategy_premium() {
541        let solver = GuardrailPricingSolver;
542        let mut input = create_test_input();
543        input.margin_requirements.competitive_strategy = CompetitiveStrategy::Premium;
544        input.margin_requirements.min_margin_pct = 20.0;
545
546        let spec = create_spec(&input, 42);
547        let (output, _) = solver.solve_pricing(&input, &spec).unwrap();
548
549        let rec1 = &output.recommendations[0];
550        // Premium strategy should price above market
551        if let Some(pos) = rec1.competitive_position.position_vs_avg_pct {
552            // Should be above market or margin prevents it
553            assert!(pos > 0.0 || rec1.margin_pct >= input.margin_requirements.min_margin_pct);
554        }
555    }
556
557    #[test]
558    fn test_determinism() {
559        let solver = GuardrailPricingSolver;
560        let input = create_test_input();
561
562        let spec1 = create_spec(&input, 12345);
563        let spec2 = create_spec(&input, 12345);
564
565        let (output1, _) = solver.solve_pricing(&input, &spec1).unwrap();
566        let (output2, _) = solver.solve_pricing(&input, &spec2).unwrap();
567
568        assert_eq!(output1.recommendations.len(), output2.recommendations.len());
569        for (r1, r2) in output1
570            .recommendations
571            .iter()
572            .zip(output2.recommendations.iter())
573        {
574            assert_eq!(r1.product_id, r2.product_id);
575            assert!((r1.recommended_price - r2.recommended_price).abs() < 0.01);
576        }
577    }
578
579    #[test]
580    fn test_margin_analysis() {
581        let solver = GuardrailPricingSolver;
582        let input = create_test_input();
583        let spec = create_spec(&input, 42);
584
585        let (output, _) = solver.solve_pricing(&input, &spec).unwrap();
586
587        assert_eq!(output.margin_analysis.total_products, 2);
588        assert!(output.margin_analysis.average_margin_pct > 0.0);
589        assert!(output.margin_analysis.min_margin_pct <= output.margin_analysis.max_margin_pct);
590    }
591
592    #[test]
593    fn test_guardrail_compliance_tracking() {
594        let solver = GuardrailPricingSolver;
595        let input = create_test_input();
596        let spec = create_spec(&input, 42);
597
598        let (output, _) = solver.solve_pricing(&input, &spec).unwrap();
599
600        // Should have compliance flags set
601        // With reasonable inputs, most guardrails should be met
602        assert!(
603            output.guardrail_compliance.all_within_bounds
604                || !output.guardrail_compliance.violations.is_empty()
605        );
606    }
607}