1use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8pub struct GuardrailPricingSolver;
16
17impl GuardrailPricingSolver {
18 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 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 let margin_analysis = self.calculate_margin_analysis(&recommendations, margin_req);
52
53 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 fn price_product(
95 &self,
96 product: &Product,
97 margin_req: &MarginRequirements,
98 global_bounds: &Option<PriceBounds>,
99 ) -> Result<PricingRecommendation> {
100 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 };
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 };
119
120 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 let competitive_price = self.calculate_competitive_price(product, margin_req);
129
130 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 (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 recommended_price = recommended_price.max(min_price_for_margin);
148
149 let within_bounds = recommended_price >= bound_min && recommended_price <= bound_max;
151
152 recommended_price = recommended_price.max(bound_min).min(bound_max);
154
155 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 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 let competitive_position = self.build_competitive_position(product, recommended_price);
172
173 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 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 Some(avg_competitor * 0.95)
210 }
211 CompetitiveStrategy::MatchMarket => {
212 Some(avg_competitor)
214 }
215 CompetitiveStrategy::Premium => {
216 Some(avg_competitor * 1.10)
218 }
219 CompetitiveStrategy::IgnoreCompetitors => None,
220 }
221 }
222
223 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 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 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 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 if !within_bounds {
290 parts.push("Adjusted to fit guardrails".to_string());
291 }
292
293 parts.join(". ")
294 }
295
296 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 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 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, CompetitiveStrategy::MatchMarket => pos_pct.abs() <= 5.0, CompetitiveStrategy::Premium => pos_pct >= 5.0, CompetitiveStrategy::IgnoreCompetitors => true,
353 };
354 if strategy_achieved {
355 achieved += 1;
356 }
357 }
358 }
359
360 if applicable == 0 {
361 true } else {
363 achieved as f64 / applicable as f64 >= 0.8 }
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 }
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 let rec1 = &output.recommendations[0];
462 assert_eq!(rec1.product_id, "SKU-001");
463 assert!(rec1.margin_pct >= 20.0); 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 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, }),
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 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; let spec = create_spec(&input, 42);
529 let (output, _) = solver.solve_pricing(&input, &spec).unwrap();
530
531 let rec1 = &output.recommendations[0];
532 if let Some(pos) = rec1.competitive_position.position_vs_avg_pct {
534 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 if let Some(pos) = rec1.competitive_position.position_vs_avg_pct {
552 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 assert!(
603 output.guardrail_compliance.all_within_bounds
604 || !output.guardrail_compliance.violations.is_empty()
605 );
606 }
607}