mod invariants;
mod solver;
mod types;
pub use invariants::*;
pub use solver::*;
pub use types::*;
use crate::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
use crate::packs::{default_gate_evaluation, InvariantDef, InvariantResult, Pack, PackSolveResult};
use crate::Result;
pub struct InventoryReplenishmentPack;
impl Pack for InventoryReplenishmentPack {
fn name(&self) -> &'static str {
"inventory-replenishment"
}
fn version(&self) -> &'static str {
"1.0.0"
}
fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
let input: InventoryReplenishmentInput = serde_json::from_value(inputs.clone())
.map_err(|e| crate::Error::invalid_input(format!("Invalid input: {}", e)))?;
input.validate()
}
fn invariants(&self) -> &[InvariantDef] {
INVARIANTS
}
fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
let input: InventoryReplenishmentInput = spec.inputs_as()?;
input.validate()?;
let solver = EoqSolver;
let (output, report) = solver.solve_replenishment(&input, spec)?;
let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
let confidence = calculate_confidence(&output, &input);
let plan = ProposedPlan::from_payload(
format!("plan-{}", spec.problem_id),
self.name(),
output.summary(),
&output,
confidence,
trace,
)?;
Ok(PackSolveResult::new(plan, report))
}
fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
let output: InventoryReplenishmentOutput = plan.plan_as()?;
let input = InventoryReplenishmentInput::default();
Ok(check_all_invariants(&output, &input))
}
fn evaluate_gate(
&self,
_plan: &ProposedPlan,
invariant_results: &[InvariantResult],
) -> PromotionGate {
default_gate_evaluation(invariant_results, self.invariants())
}
}
fn calculate_confidence(output: &InventoryReplenishmentOutput, input: &InventoryReplenishmentInput) -> f64 {
let mut confidence: f64 = 0.5;
if output.orders.is_empty() {
if input.products.iter().all(|p| !p.needs_reorder()) {
return 0.9; }
return 0.3; }
if output.stats.projected_service_level >= input.constraints.target_service_level {
confidence += 0.25;
} else if output.stats.projected_service_level >= input.constraints.target_service_level * 0.9 {
confidence += 0.15;
}
if output.stats.budget_utilization > 0.1 && output.stats.budget_utilization < 0.9 {
confidence += 0.1;
}
let has_stockout_risk = output
.projections
.iter()
.any(|p| p.stockout_probability > 0.3);
if !has_stockout_risk {
confidence += 0.15;
}
confidence.min(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gate::ObjectiveSpec;
fn create_test_product(id: &str, inventory: i64, demand: f64) -> Product {
Product {
id: id.to_string(),
name: format!("Product {}", id),
current_inventory: inventory,
demand_forecast: DemandForecast {
average_daily: demand,
std_deviation: demand * 0.2,
forecast_days: 30,
},
lead_time_days: 7,
unit_cost: 10.0,
ordering_cost: 50.0,
holding_cost_per_day: 0.02,
stockout_cost: 25.0,
}
}
fn create_test_input() -> InventoryReplenishmentInput {
InventoryReplenishmentInput {
products: vec![
create_test_product("p1", 50, 10.0),
create_test_product("p2", 200, 5.0),
],
constraints: ReplenishmentConstraints {
budget: 10000.0,
target_service_level: 0.95,
planning_horizon_days: 30,
max_orders: None,
min_order_quantity: None,
},
}
}
#[test]
fn test_pack_name() {
let pack = InventoryReplenishmentPack;
assert_eq!(pack.name(), "inventory-replenishment");
assert_eq!(pack.version(), "1.0.0");
}
#[test]
fn test_validate_inputs() {
let pack = InventoryReplenishmentPack;
let input = create_test_input();
let json = serde_json::to_value(&input).unwrap();
assert!(pack.validate_inputs(&json).is_ok());
}
#[test]
fn test_validate_inputs_invalid_budget() {
let pack = InventoryReplenishmentPack;
let mut input = create_test_input();
input.constraints.budget = -100.0;
let json = serde_json::to_value(&input).unwrap();
assert!(pack.validate_inputs(&json).is_err());
}
#[test]
fn test_validate_inputs_invalid_service_level() {
let pack = InventoryReplenishmentPack;
let mut input = create_test_input();
input.constraints.target_service_level = 1.5;
let json = serde_json::to_value(&input).unwrap();
assert!(pack.validate_inputs(&json).is_err());
}
#[test]
fn test_solve_basic() {
let pack = InventoryReplenishmentPack;
let input = create_test_input();
let spec = ProblemSpec::builder("test-001", "test-tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(42)
.build()
.unwrap();
let result = pack.solve(&spec).unwrap();
assert!(result.is_feasible());
let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
assert!(!output.orders.is_empty() || !output.not_ordered.is_empty());
}
#[test]
fn test_solve_with_sufficient_inventory() {
let pack = InventoryReplenishmentPack;
let input = InventoryReplenishmentInput {
products: vec![create_test_product("p1", 1000, 5.0)], constraints: ReplenishmentConstraints::default(),
};
let spec = ProblemSpec::builder("test-002", "test-tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(42)
.build()
.unwrap();
let result = pack.solve(&spec).unwrap();
assert!(result.is_feasible());
}
#[test]
fn test_check_invariants() {
let pack = InventoryReplenishmentPack;
let input = create_test_input();
let spec = ProblemSpec::builder("test-003", "test-tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(42)
.build()
.unwrap();
let result = pack.solve(&spec).unwrap();
let invariants = pack.check_invariants(&result.plan).unwrap();
assert!(!invariants.is_empty());
}
#[test]
fn test_gate_evaluation() {
let pack = InventoryReplenishmentPack;
let input = create_test_input();
let spec = ProblemSpec::builder("test-004", "test-tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(42)
.build()
.unwrap();
let result = pack.solve(&spec).unwrap();
let invariants = pack.check_invariants(&result.plan).unwrap();
let gate = pack.evaluate_gate(&result.plan, &invariants);
assert!(gate.is_promoted() || !gate.is_promoted());
}
#[test]
fn test_determinism() {
let pack = InventoryReplenishmentPack;
let input = create_test_input();
let spec1 = ProblemSpec::builder("test-a", "tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(99999)
.build()
.unwrap();
let spec2 = ProblemSpec::builder("test-b", "tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(99999)
.build()
.unwrap();
let result1 = pack.solve(&spec1).unwrap();
let result2 = pack.solve(&spec2).unwrap();
let output1: InventoryReplenishmentOutput = result1.plan.plan_as().unwrap();
let output2: InventoryReplenishmentOutput = result2.plan.plan_as().unwrap();
assert_eq!(output1.orders.len(), output2.orders.len());
assert_eq!(
output1.stats.total_order_cost,
output2.stats.total_order_cost
);
for (a, b) in output1.orders.iter().zip(output2.orders.iter()) {
assert_eq!(a.product_id, b.product_id);
assert_eq!(a.quantity, b.quantity);
}
}
#[test]
fn test_budget_constraint() {
let pack = InventoryReplenishmentPack;
let mut input = create_test_input();
input.constraints.budget = 500.0;
let spec = ProblemSpec::builder("test-005", "test-tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(42)
.build()
.unwrap();
let result = pack.solve(&spec).unwrap();
let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
assert!(output.stats.total_order_cost <= 500.0);
}
#[test]
fn test_max_orders_constraint() {
let pack = InventoryReplenishmentPack;
let mut input = create_test_input();
input.constraints.max_orders = Some(1);
let spec = ProblemSpec::builder("test-006", "test-tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(42)
.build()
.unwrap();
let result = pack.solve(&spec).unwrap();
let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
assert!(output.orders.len() <= 1);
}
#[test]
fn test_output_summary() {
let pack = InventoryReplenishmentPack;
let input = create_test_input();
let spec = ProblemSpec::builder("test-007", "test-tenant")
.objective(ObjectiveSpec::minimize("cost"))
.inputs(&input)
.unwrap()
.seed(42)
.build()
.unwrap();
let result = pack.solve(&spec).unwrap();
let output: InventoryReplenishmentOutput = result.plan.plan_as().unwrap();
let summary = output.summary();
assert!(!summary.is_empty());
assert!(summary.contains("units") || summary.contains("products"));
}
}