converge_optimization/packs/pricing_guardrails/
mod.rs1mod 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
38pub 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 if output.guardrail_compliance.all_margins_met {
107 confidence += CONFIDENCE_STEP_MAJOR;
108 }
109
110 if output.guardrail_compliance.all_within_bounds {
112 confidence += CONFIDENCE_STEP_MAJOR;
113 }
114
115 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 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 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 for rec in &output.recommendations {
314 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; 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 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 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 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}