Skip to main content

converge_optimization/packs/pricing_guardrails/
mod.rs

1//! Pricing Guardrails Pack
2//!
3//! JTBD: "Set pricing within guardrails ensuring margin targets and competitive position"
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Products with cost data
9//! - Competitor prices for market positioning
10//! - Margin requirements (minimum and target)
11//! - Price bounds (guardrails)
12//!
13//! Find:
14//! - Recommended prices that respect margins and guardrails
15//! - Margin analysis and compliance reporting
16//!
17//! ## Solver
18//!
19//! Uses rule-based pricing:
20//! 1. Calculate minimum price to meet margin requirement
21//! 2. Apply competitive strategy to adjust price
22//! 3. Enforce price bounds (guardrails)
23//! 4. Generate recommendations with compliance analysis
24
25mod invariants;
26mod solver;
27mod types;
28
29pub use invariants::*;
30pub use solver::*;
31pub use types::*;
32
33use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
34use converge_pack::gate::GateResult as Result;
35use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
36use converge_pack::{CONFIDENCE_STEP_MAJOR, CONFIDENCE_STEP_MINOR};
37
38/// Pricing Guardrails Pack
39pub struct PricingGuardrailsPack;
40
41impl Pack for PricingGuardrailsPack {
42    fn name(&self) -> &'static str {
43        "pricing-guardrails"
44    }
45
46    fn version(&self) -> &'static str {
47        "1.0.0"
48    }
49
50    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
51        let input: PricingGuardrailsInput =
52            serde_json::from_value(inputs.clone()).map_err(|e| {
53                converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
54            })?;
55        input.validate()
56    }
57
58    fn invariants(&self) -> &[InvariantDef] {
59        INVARIANTS
60    }
61
62    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
63        let input: PricingGuardrailsInput = spec.inputs_as()?;
64        input.validate()?;
65
66        let solver = GuardrailPricingSolver;
67        let (output, report) = solver.solve_pricing(&input, spec)?;
68
69        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
70        let confidence = calculate_confidence(&output);
71
72        let plan = ProposedPlan::from_payload(
73            format!("plan-{}", spec.problem_id),
74            self.name(),
75            output.summary(),
76            &output,
77            confidence,
78            trace,
79        )?;
80
81        Ok(PackSolveResult::new(plan, report))
82    }
83
84    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
85        let output: PricingGuardrailsOutput = plan.plan_as()?;
86        Ok(check_all_invariants(&output))
87    }
88
89    fn evaluate_gate(
90        &self,
91        _plan: &ProposedPlan,
92        invariant_results: &[InvariantResult],
93    ) -> PromotionGate {
94        default_gate_evaluation(invariant_results, self.invariants())
95    }
96}
97
98fn calculate_confidence(output: &PricingGuardrailsOutput) -> f64 {
99    if output.recommendations.is_empty() {
100        return 0.0;
101    }
102
103    let mut confidence: f64 = 0.5;
104
105    // Bonus for all margins met
106    if output.guardrail_compliance.all_margins_met {
107        confidence += CONFIDENCE_STEP_MAJOR;
108    }
109
110    // Bonus for all within bounds
111    if output.guardrail_compliance.all_within_bounds {
112        confidence += CONFIDENCE_STEP_MAJOR;
113    }
114
115    // Bonus for competitive position achieved
116    if output.guardrail_compliance.competitive_position_achieved {
117        confidence += CONFIDENCE_STEP_MINOR;
118    }
119
120    confidence.min(1.0)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use converge_pack::gate::ObjectiveSpec;
127
128    fn create_test_input() -> PricingGuardrailsInput {
129        PricingGuardrailsInput {
130            products: vec![
131                Product {
132                    product_id: "SKU-001".to_string(),
133                    name: "Widget A".to_string(),
134                    unit_cost: 80.0,
135                    current_price: Some(100.0),
136                    price_bounds: Some(PriceBounds {
137                        min_price: 90.0,
138                        max_price: 150.0,
139                    }),
140                    competitor_prices: vec![CompetitorPrice {
141                        competitor_id: "comp1".to_string(),
142                        price: 110.0,
143                        as_of_date: None,
144                    }],
145                    category: Some("widgets".to_string()),
146                },
147                Product {
148                    product_id: "SKU-002".to_string(),
149                    name: "Widget B".to_string(),
150                    unit_cost: 50.0,
151                    current_price: None,
152                    price_bounds: None,
153                    competitor_prices: vec![],
154                    category: Some("widgets".to_string()),
155                },
156            ],
157            margin_requirements: MarginRequirements {
158                min_margin_pct: 20.0,
159                target_margin_pct: 30.0,
160                competitive_strategy: CompetitiveStrategy::MatchMarket,
161            },
162            price_bounds: Some(PriceBounds {
163                min_price: 10.0,
164                max_price: 1000.0,
165            }),
166        }
167    }
168
169    #[test]
170    fn test_pack_name() {
171        let pack = PricingGuardrailsPack;
172        assert_eq!(pack.name(), "pricing-guardrails");
173        assert_eq!(pack.version(), "1.0.0");
174    }
175
176    #[test]
177    fn test_validate_inputs() {
178        let pack = PricingGuardrailsPack;
179        let input = create_test_input();
180        let json = serde_json::to_value(&input).unwrap();
181        assert!(pack.validate_inputs(&json).is_ok());
182    }
183
184    #[test]
185    fn test_validate_inputs_rejects_invalid() {
186        let pack = PricingGuardrailsPack;
187        let mut input = create_test_input();
188        input.products[0].unit_cost = -10.0;
189        let json = serde_json::to_value(&input).unwrap();
190        assert!(pack.validate_inputs(&json).is_err());
191    }
192
193    #[test]
194    fn test_solve_basic() {
195        let pack = PricingGuardrailsPack;
196        let input = create_test_input();
197
198        let spec = ProblemSpec::builder("test-001", "test-tenant")
199            .objective(ObjectiveSpec::maximize("margin"))
200            .inputs(&input)
201            .unwrap()
202            .seed(42)
203            .build()
204            .unwrap();
205
206        let result = pack.solve(&spec).unwrap();
207        assert!(result.is_feasible());
208
209        let output: PricingGuardrailsOutput = result.plan.plan_as().unwrap();
210        assert_eq!(output.recommendations.len(), 2);
211        assert!(output.margin_analysis.average_margin_pct > 0.0);
212    }
213
214    #[test]
215    fn test_check_invariants() {
216        let pack = PricingGuardrailsPack;
217        let input = create_test_input();
218
219        let spec = ProblemSpec::builder("test-002", "test-tenant")
220            .objective(ObjectiveSpec::maximize("margin"))
221            .inputs(&input)
222            .unwrap()
223            .seed(42)
224            .build()
225            .unwrap();
226
227        let result = pack.solve(&spec).unwrap();
228        let invariants = pack.check_invariants(&result.plan).unwrap();
229
230        // Should have all 4 invariants checked
231        assert_eq!(invariants.len(), 4);
232    }
233
234    #[test]
235    fn test_gate_promotes_valid() {
236        let pack = PricingGuardrailsPack;
237        let input = create_test_input();
238
239        let spec = ProblemSpec::builder("test-003", "test-tenant")
240            .objective(ObjectiveSpec::maximize("margin"))
241            .inputs(&input)
242            .unwrap()
243            .seed(42)
244            .build()
245            .unwrap();
246
247        let result = pack.solve(&spec).unwrap();
248        let invariants = pack.check_invariants(&result.plan).unwrap();
249        let gate = pack.evaluate_gate(&result.plan, &invariants);
250
251        // With valid input, should either promote or require review
252        assert!(!gate.is_rejected());
253    }
254
255    #[test]
256    fn test_determinism() {
257        let pack = PricingGuardrailsPack;
258        let input = create_test_input();
259
260        let spec1 = ProblemSpec::builder("test-a", "tenant")
261            .objective(ObjectiveSpec::maximize("margin"))
262            .inputs(&input)
263            .unwrap()
264            .seed(99999)
265            .build()
266            .unwrap();
267
268        let spec2 = ProblemSpec::builder("test-b", "tenant")
269            .objective(ObjectiveSpec::maximize("margin"))
270            .inputs(&input)
271            .unwrap()
272            .seed(99999)
273            .build()
274            .unwrap();
275
276        let result1 = pack.solve(&spec1).unwrap();
277        let result2 = pack.solve(&spec2).unwrap();
278
279        let output1: PricingGuardrailsOutput = result1.plan.plan_as().unwrap();
280        let output2: PricingGuardrailsOutput = result2.plan.plan_as().unwrap();
281
282        assert_eq!(output1.recommendations.len(), output2.recommendations.len());
283        for (r1, r2) in output1
284            .recommendations
285            .iter()
286            .zip(output2.recommendations.iter())
287        {
288            assert_eq!(r1.product_id, r2.product_id);
289            assert!((r1.recommended_price - r2.recommended_price).abs() < 0.01);
290        }
291    }
292
293    #[test]
294    fn test_margin_enforcement() {
295        let pack = PricingGuardrailsPack;
296        let mut input = create_test_input();
297        input.margin_requirements.min_margin_pct = 30.0;
298        input.margin_requirements.target_margin_pct = 35.0;
299        input.margin_requirements.competitive_strategy = CompetitiveStrategy::IgnoreCompetitors;
300
301        let spec = ProblemSpec::builder("test-margin", "test-tenant")
302            .objective(ObjectiveSpec::maximize("margin"))
303            .inputs(&input)
304            .unwrap()
305            .seed(42)
306            .build()
307            .unwrap();
308
309        let result = pack.solve(&spec).unwrap();
310        let output: PricingGuardrailsOutput = result.plan.plan_as().unwrap();
311
312        // All products should meet minimum margin (or be constrained by bounds)
313        for rec in &output.recommendations {
314            // Either meets target or is constrained by bounds
315            assert!(rec.margin_pct >= 30.0 || !rec.within_bounds);
316        }
317    }
318
319    #[test]
320    fn test_price_bounds_enforcement() {
321        let pack = PricingGuardrailsPack;
322        let mut input = create_test_input();
323        input.products = vec![Product {
324            product_id: "bounded".to_string(),
325            name: "Bounded Product".to_string(),
326            unit_cost: 80.0,
327            current_price: None,
328            price_bounds: Some(PriceBounds {
329                min_price: 95.0,
330                max_price: 105.0,
331            }),
332            competitor_prices: vec![],
333            category: None,
334        }];
335
336        let spec = ProblemSpec::builder("test-bounds", "test-tenant")
337            .objective(ObjectiveSpec::maximize("margin"))
338            .inputs(&input)
339            .unwrap()
340            .seed(42)
341            .build()
342            .unwrap();
343
344        let result = pack.solve(&spec).unwrap();
345        let output: PricingGuardrailsOutput = result.plan.plan_as().unwrap();
346
347        let rec = &output.recommendations[0];
348        assert!(rec.recommended_price >= 95.0);
349        assert!(rec.recommended_price <= 105.0);
350    }
351
352    #[test]
353    fn test_competitive_pricing() {
354        let pack = PricingGuardrailsPack;
355        let mut input = create_test_input();
356        input.margin_requirements.competitive_strategy = CompetitiveStrategy::PriceToBeat;
357        input.margin_requirements.min_margin_pct = 10.0; // Lower margin to allow competitive pricing
358
359        let spec = ProblemSpec::builder("test-competitive", "test-tenant")
360            .objective(ObjectiveSpec::maximize("margin"))
361            .inputs(&input)
362            .unwrap()
363            .seed(42)
364            .build()
365            .unwrap();
366
367        let result = pack.solve(&spec).unwrap();
368        let output: PricingGuardrailsOutput = result.plan.plan_as().unwrap();
369
370        // First product has competitor data
371        let rec = &output.recommendations[0];
372        assert!(rec.competitive_position.competitor_count > 0);
373        assert!(rec.competitive_position.avg_competitor_price.is_some());
374    }
375
376    #[test]
377    fn test_calculate_confidence() {
378        // Full compliance should give high confidence
379        let output = PricingGuardrailsOutput {
380            recommendations: vec![PricingRecommendation {
381                product_id: "test".to_string(),
382                recommended_price: 100.0,
383                previous_price: None,
384                price_change: None,
385                price_change_pct: None,
386                margin_pct: 25.0,
387                markup_pct: 33.0,
388                competitive_position: CompetitivePosition::default(),
389                within_bounds: true,
390                margin_target_met: true,
391                rationale: "Test".to_string(),
392            }],
393            margin_analysis: MarginAnalysis::default(),
394            guardrail_compliance: GuardrailCompliance {
395                all_within_bounds: true,
396                all_margins_met: true,
397                competitive_position_achieved: true,
398                violations: vec![],
399            },
400        };
401
402        let confidence = calculate_confidence(&output);
403        assert!(confidence >= 0.9);
404
405        // Empty recommendations should give 0 confidence
406        let empty_output = PricingGuardrailsOutput::no_valid_pricing("No products");
407        let empty_confidence = calculate_confidence(&empty_output);
408        assert_eq!(empty_confidence, 0.0);
409    }
410}