use good_lp::Solution as LpSolution;
use good_lp::solvers::coin_cbc::coin_cbc;
use good_lp::{
Expression, ProblemVariables, SolverModel, Variable, constraint, variable, variables,
};
pub use serde;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Serialize, Deserialize)]
pub struct Problem {
pub items: BTreeMap<String, ItemSpec>,
pub bins: BTreeMap<String, u32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Solution {
pub solution: BTreeMap<String, BTreeMap<String, u32>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ItemSpec {
pub quantity: u32,
#[serde(rename = "groupSize")]
pub group_size: Option<u32>,
pub affinity: Option<Affinity>,
#[serde(rename = "antiAffinity")]
pub anti_affinity: Option<AntiAffinity>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Affinity {
pub soft: Option<Vec<SoftRequirement>>,
pub hard: Option<HardRequirement>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AntiAffinity {
pub soft: Option<Vec<SoftRequirement>>,
pub hard: Option<HardRequirement>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct HardRequirement {
pub bins: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SoftRequirement {
#[serde(default = "default_weight")]
pub weight: f64,
pub bins: Vec<String>,
}
fn default_weight() -> f64 {
1.0
}
impl Problem {
pub fn solve(&self) -> Result<Solution, Box<dyn std::error::Error>> {
let items = &self.items;
let bins = &self.bins;
let (variables, quantity_map, group_count_map) = init_variables(items, bins);
let mut soft_requirement_weights = BTreeMap::new();
process_soft_requirements(
items,
bins,
|spec| spec.affinity.as_ref().map(|aff| &aff.soft),
1.0,
&mut soft_requirement_weights,
);
process_soft_requirements(
items,
bins,
|spec| spec.anti_affinity.as_ref().map(|anti| &anti.soft),
-1.0,
&mut soft_requirement_weights,
);
let objective = create_objective_function(&quantity_map, &soft_requirement_weights);
let model = create_model(variables, objective);
#[rustfmt::skip]
let model =
constrain_quantities_must_equal_desired_sizes(model, items, bins, &quantity_map);
let model = constrain_hard_placement_rules(model, items, bins, &quantity_map);
let model = constrain_bin_capacities(model, items, bins, &quantity_map);
let model = constrain_group_sizes(model, items, bins, &quantity_map, &group_count_map);
let solution = model.solve()?;
let solution_map = create_item_assignments(&solution, items, bins, &quantity_map);
Ok(Solution {
solution: solution_map,
})
}
}
type BinItemToVariableMap = BTreeMap<(String, String), Variable>;
fn init_variables(
items: &BTreeMap<String, ItemSpec>,
bins: &BTreeMap<String, u32>,
) -> (ProblemVariables, BinItemToVariableMap, BinItemToVariableMap) {
let mut problem_vars = variables!();
let mut quantity_map = BTreeMap::new();
let mut group_count_map = BTreeMap::new();
for (item, spec) in items.iter() {
for bin in bins.keys() {
let key = (item.clone(), bin.clone());
let quantity = problem_vars.add(variable().integer().min(0));
quantity_map.insert(key.clone(), quantity);
if spec.group_size.is_some() && spec.group_size.unwrap() > 0 {
let complete_groups = problem_vars.add(variable().integer().min(0));
group_count_map.insert(key, complete_groups);
}
}
}
(problem_vars, quantity_map, group_count_map)
}
fn process_soft_requirements(
items: &BTreeMap<String, ItemSpec>,
bins: &BTreeMap<String, u32>,
get_reqs: fn(&ItemSpec) -> Option<&Option<Vec<SoftRequirement>>>,
weight_factor: f64,
obj_coeffs: &mut BTreeMap<(String, String), f64>,
) {
for (item_name, item_spec) in items.iter() {
let soft_requirements = get_reqs(item_spec)
.and_then(|maybe_reqs| maybe_reqs.as_ref())
.into_iter()
.flatten();
for preference in soft_requirements {
for bin_name in &preference.bins {
if !bins.contains_key(bin_name) {
continue;
}
let key = (item_name.clone(), bin_name.clone());
let weighted_score = preference.weight * weight_factor;
*obj_coeffs.entry(key).or_insert(0.0) += weighted_score;
}
}
}
}
fn create_objective_function(
quantity_map: &BinItemToVariableMap,
soft_requirement_weights: &BTreeMap<(String, String), f64>,
) -> Expression {
soft_requirement_weights.iter().fold(
Expression::from(0.0),
|sum, ((item, bin), &soft_requirement_weight)| {
let key = (item.clone(), bin.clone());
let quantity_var = quantity_map[&key];
sum + quantity_var * soft_requirement_weight
},
)
}
fn create_model(variables: ProblemVariables, objective: Expression) -> impl SolverModel {
#[allow(unused_mut)]
let mut model = variables.maximise(objective).using(coin_cbc);
#[cfg(not(debug_assertions))]
model.set_parameter("loglevel", "0");
model
}
fn constrain_quantities_must_equal_desired_sizes<Model: SolverModel>(
model: Model,
items: &BTreeMap<String, ItemSpec>,
bins: &BTreeMap<String, u32>,
quantity_map: &BinItemToVariableMap,
) -> Model {
items.iter().fold(model, |m, (item, spec)| {
let zero = Expression::from(0.0);
let total_quantity_placed = bins
.keys()
.map(|bin| quantity_map[&(item.clone(), bin.clone())])
.fold(zero, |sum, quantity| sum + quantity);
let required_quantity = spec.quantity as f64;
let constraint = total_quantity_placed.eq(required_quantity);
m.with(constraint)
})
}
fn constrain_bin_capacities<Model: SolverModel>(
model: Model,
items: &BTreeMap<String, ItemSpec>,
bins: &BTreeMap<String, u32>,
quantity_map: &BinItemToVariableMap,
) -> Model {
bins.iter().fold(model, |m, (bin, &cap)| {
let zero = Expression::from(0.0);
let lhs = items
.keys()
.map(|item| quantity_map[&(item.clone(), bin.clone())])
.fold(zero, |sum, v| sum + v);
m.with(lhs.leq(cap as f64))
})
}
fn constrain_hard_placement_rules<Model: SolverModel>(
model: Model,
items: &BTreeMap<String, ItemSpec>,
bins: &BTreeMap<String, u32>,
quantity_map: &BinItemToVariableMap,
) -> Model {
items.iter().fold(model, |m, (w, spec)| {
let model = if let Some(valid_bins) = get_valid_bins_based_on_affinity(spec, bins) {
constrain_item_to_bins(m, w, &valid_bins, bins, quantity_map)
} else {
m
};
if let Some(forbidden_bins) = get_valid_bins_based_on_anti_affinity(spec, bins) {
constrain_item_from_bins(model, w, &forbidden_bins, quantity_map)
} else {
model
}
})
}
fn get_valid_bins_based_on_affinity(
spec: &ItemSpec,
bins: &BTreeMap<String, u32>,
) -> Option<Vec<String>> {
spec.affinity
.as_ref()
.and_then(|aff| aff.hard.as_ref())
.map(|hard| {
hard.bins
.iter()
.filter(|c| bins.contains_key(*c))
.cloned()
.collect()
})
.filter(|valid: &Vec<String>| !valid.is_empty())
}
fn get_valid_bins_based_on_anti_affinity(
spec: &ItemSpec,
bins: &BTreeMap<String, u32>,
) -> Option<Vec<String>> {
spec.anti_affinity
.as_ref()
.and_then(|anti| anti.hard.as_ref())
.map(|hard| {
hard.bins
.iter()
.filter(|c| bins.contains_key(*c))
.cloned()
.collect()
})
}
fn constrain_item_to_bins<Model: SolverModel>(
model: Model,
item: &str,
valid_bins: &[String],
all_bins: &BTreeMap<String, u32>,
quantity_map: &BinItemToVariableMap,
) -> Model {
all_bins.keys().fold(model, |m, c| {
if !valid_bins.contains(c) {
let v = quantity_map[&(item.to_owned(), c.to_owned())];
m.with(constraint!(v == 0.0))
} else {
m
}
})
}
fn constrain_item_from_bins<Model: SolverModel>(
model: Model,
item: &str,
forbidden_bins: &[String],
quantity_map: &BinItemToVariableMap,
) -> Model {
forbidden_bins.iter().fold(model, |m, c| {
let v = quantity_map[&(item.to_owned(), c.to_owned())];
m.with(constraint!(v == 0.0))
})
}
fn constrain_group_sizes<Model: SolverModel>(
model: Model,
items: &BTreeMap<String, ItemSpec>,
bins: &BTreeMap<String, u32>,
quantity_map: &BinItemToVariableMap,
group_count_map: &BinItemToVariableMap,
) -> Model {
items.iter().fold(model, |m, (item, spec)| {
if let Some(group_size) = spec.group_size {
if group_size > 0 {
return bins.keys().fold(m, |m2, bin| {
let key = (item.clone(), bin.clone());
let quantity_var = quantity_map[&key];
if let Some(&complete_groups_var) = group_count_map.get(&key) {
m2.with(constraint!(
quantity_var == complete_groups_var * (group_size as f32)
))
} else {
m2
}
});
}
}
m
})
}
fn create_item_assignments(
solution: &impl LpSolution,
items: &BTreeMap<String, ItemSpec>,
bins: &BTreeMap<String, u32>,
quantity_map: &BinItemToVariableMap,
) -> BTreeMap<String, BTreeMap<String, u32>> {
items
.keys()
.filter_map(|item| {
let bin_assignments = get_bin_assignments(solution, item, bins, quantity_map);
(!bin_assignments.is_empty()).then_some((item.clone(), bin_assignments))
})
.collect()
}
fn get_bin_assignments(
solution: &impl LpSolution,
item: &str,
bins: &BTreeMap<String, u32>,
quantity_map: &BinItemToVariableMap,
) -> BTreeMap<String, u32> {
bins.keys()
.filter_map(|bin| {
let key = (item.to_string(), bin.clone());
let quantity = solution.value(quantity_map[&key]).round() as u32;
let bin = key.1;
(quantity > 0).then_some((bin, quantity))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{read_dir, read_to_string};
use std::path::Path;
fn run_test_file(test_file: &Path) {
println!("Running test for file: {:?}", test_file);
let failure_message = format!("Failed to read test file: {}", test_file.display());
let yaml_content = read_to_string(test_file).expect(&failure_message);
let parts: Vec<&str> = yaml_content.split("solution:").collect();
let failure_message = format!("Failed to parse input YAML: {}", test_file.display());
let input_yaml = parts.first().expect("No input found in test file").trim();
let input: Problem = serde_yaml::from_str(input_yaml).expect(&failure_message);
let failure_message = format!("Failed to parse expected YAML: {}", test_file.display());
let expected_yaml = format!("solution:{}", parts.get(1).expect(&failure_message));
let failure_message = format!("Failed to solve test file: {}", test_file.display());
let solution = input.solve().expect(&failure_message);
let received_solution = serde_yaml::to_string(&solution).expect(&failure_message);
let failure_message = format!("Failed to parse expected YAML: {}", test_file.display());
let expected_unnormalized: Solution =
serde_yaml::from_str(&expected_yaml).expect(&failure_message);
let failure_message = format!("Failed to normalize expected YAML: {}", test_file.display());
let expected_solution =
serde_yaml::to_string(&expected_unnormalized).expect(&failure_message);
println!("expected: {}", expected_solution);
println!("received: {}", received_solution);
assert_eq!(
expected_solution.trim(),
received_solution.trim(),
"{}",
test_file.display()
);
}
#[test]
fn run_all_test_files() {
let test_data_dir = Path::new("test_data");
let mut entries: Vec<_> = read_dir(test_data_dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| {
path.is_file() && path.extension().map(|ext| ext == "yaml").unwrap_or(false)
})
.collect();
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
for path in entries {
run_test_file(&path);
}
}
}