use super::Instance;
use crate::{
constraint::Equality, ATol, Bound, Bounds, Coefficient, ConstraintID, Evaluate,
InfeasibleDetected, Kind, Linear, LinearMonomial, VariableID,
};
use anyhow::{bail, Context, Result};
use num::traits::Inv;
fn validate_integer_slack_atol(atol: ATol) -> Result<()> {
if atol >= 0.5 {
bail!(
"Integer slack introduction requires atol < 0.5: got {}",
atol.into_inner()
);
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct ExactIntegerSlackUnavailable(ExactIntegerSlackFailure);
#[derive(Debug, thiserror::Error)]
enum ExactIntegerSlackFailure {
#[error("Cannot normalize the coefficients to integers: constraint={id:?}")]
CoefficientsNotNormalizable { id: ConstraintID },
#[error(
"The range of the slack variable exceeds the limit: evaluated({evaluated}) > limit({limit})"
)]
RangeTooLarge {
id: ConstraintID,
evaluated: f64,
limit: u64,
},
}
impl ExactIntegerSlackUnavailable {
fn coefficients_not_normalizable(id: ConstraintID) -> Self {
Self(ExactIntegerSlackFailure::CoefficientsNotNormalizable { id })
}
fn range_too_large(id: ConstraintID, evaluated: f64, limit: u64) -> Self {
Self(ExactIntegerSlackFailure::RangeTooLarge {
id,
evaluated,
limit,
})
}
}
impl Instance {
pub fn convert_inequality_to_equality_with_integer_slack(
&mut self,
constraint_id: u64,
max_integer_range: u64,
atol: ATol,
) -> Result<()> {
validate_integer_slack_atol(atol)?;
let constraint_id = ConstraintID::from(constraint_id);
let bounds = self.bounds();
let kinds = self.kinds();
let (function, equality) = {
let constraint = self
.constraint_collection
.active()
.get(&constraint_id)
.with_context(|| format!("Constraint ID {constraint_id:?} not found"))?;
(constraint.function().clone(), constraint.equality)
};
if equality != Equality::LessThanOrEqualToZero {
bail!("The constraint is not inequality: ID={constraint_id:?}");
}
for id in function.required_ids() {
let kind = kinds
.get(&id)
.with_context(|| format!("Decision variable ID {id:?} not found"))?;
if !matches!(kind, Kind::Binary | Kind::Integer) {
bail!("The constraint contains continuous decision variables: ID={id:?}");
}
}
let f_bound = function.evaluate_bound(&bounds, atol)?;
if !Equality::LessThanOrEqualToZero.is_satisfied(f_bound.lower(), atol) {
bail!(InfeasibleDetected::InequalityConstraintBound {
id: constraint_id,
bound: f_bound,
});
}
if Equality::LessThanOrEqualToZero.is_satisfied(f_bound.upper(), atol) {
self.relax_constraint(
constraint_id,
"ommx.Instance.convert_inequality_to_equality_with_integer_slack".to_string(),
[],
)?;
return Ok(());
}
let a = function.content_factor().map_err(|source| {
source.context(ExactIntegerSlackUnavailable::coefficients_not_normalizable(
constraint_id,
))
})?;
let af = (function.clone() * a)?;
let af_bound = af.evaluate_bound(&bounds, atol)?;
let af_bound = af_bound.as_integer_bound(atol).ok_or(
InfeasibleDetected::InequalityConstraintBound {
id: constraint_id,
bound: af_bound,
},
)?;
let slack_bound = Bound::new(0.0, (-af_bound.lower()).max(0.0)).unwrap();
if slack_bound.width() > max_integer_range as f64 {
return Err(ExactIntegerSlackUnavailable::range_too_large(
constraint_id,
slack_bound.width(),
max_integer_range,
)
.into());
}
let slack_id = self.new_integer(
slack_bound,
crate::ModelingLabel {
name: Some("ommx.slack".to_string()),
subscripts: vec![constraint_id.into_inner() as i64],
..Default::default()
},
None,
atol,
)?;
let slack_term = Linear::single_term(LinearMonomial::Variable(slack_id), a.inv()?);
let new_function = (function + slack_term)?;
let mut constraint = self
.constraint_collection
.active()
.get(&constraint_id)
.cloned()
.expect("constraint presence was verified above");
*constraint.function_mut() = new_function;
constraint.equality = Equality::EqualToZero;
self.constraint_collection
.replace_active_row(constraint_id, constraint)?;
Ok(())
}
pub fn add_integer_slack_to_inequality(
&mut self,
constraint_id: u64,
slack_upper_bound: u64,
atol: ATol,
) -> Result<Option<f64>> {
validate_integer_slack_atol(atol)?;
let constraint_id = ConstraintID::from(constraint_id);
let bounds = self.bounds();
let kinds = self.kinds();
let (function, equality) = {
let constraint = self
.constraint_collection
.active()
.get(&constraint_id)
.with_context(|| format!("Constraint ID {constraint_id:?} not found"))?;
(constraint.function().clone(), constraint.equality)
};
if equality != Equality::LessThanOrEqualToZero {
bail!("The constraint is not inequality: ID={constraint_id:?}");
}
for id in function.required_ids() {
let kind = kinds
.get(&id)
.with_context(|| format!("Decision variable ID {id:?} not found"))?;
if !matches!(kind, Kind::Binary | Kind::Integer) {
bail!("The constraint contains continuous decision variables: ID={id:?}");
}
}
let f_bound = function.evaluate_bound(&bounds, atol)?;
if !Equality::LessThanOrEqualToZero.is_satisfied(f_bound.lower(), atol) {
bail!(InfeasibleDetected::InequalityConstraintBound {
id: constraint_id,
bound: f_bound,
});
}
if Equality::LessThanOrEqualToZero.is_satisfied(f_bound.upper(), atol) {
self.relax_constraint(
constraint_id,
"add_integer_slack_to_inequality".to_string(),
[],
)?;
return Ok(None);
}
let slack_magnitude = (-f_bound.lower()).max(0.0);
let b = if slack_magnitude == 0.0 {
0.0
} else {
slack_magnitude / slack_upper_bound as f64
};
let slack_bound = Bound::new(0.0, slack_upper_bound as f64).unwrap();
let b_coeff = match Coefficient::try_from(b) {
Ok(c) => Some(c),
Err(crate::CoefficientError::Zero) => None,
Err(e) => return Err(e).context("Slack coefficient must be finite"),
};
let slack_id = self.new_integer(
slack_bound,
crate::ModelingLabel {
name: Some("ommx.slack".to_string()),
subscripts: vec![constraint_id.into_inner() as i64],
..Default::default()
},
None,
atol,
)?;
let new_function = match b_coeff {
Some(c) => {
let slack_term = Linear::single_term(LinearMonomial::Variable(slack_id), c);
(function + slack_term)?
}
None => function,
};
let mut constraint = self
.constraint_collection
.active()
.get(&constraint_id)
.cloned()
.expect("constraint presence was verified above");
*constraint.function_mut() = new_function;
self.constraint_collection
.replace_active_row(constraint_id, constraint)?;
Ok(Some(b))
}
fn bounds(&self) -> Bounds {
self.decision_variables
.iter()
.map(|(id, dv)| (*id, dv.bound()))
.collect()
}
fn kinds(&self) -> fnv::FnvHashMap<VariableID, Kind> {
self.decision_variables
.iter()
.map(|(id, dv)| (*id, dv.kind()))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{coeff, linear, ConstraintID, DecisionVariable, Function, Sense, VariableID};
use maplit::btreemap;
use std::collections::BTreeMap;
fn tolerance_boundary_instance() -> Instance {
let id = VariableID::from(1);
let function = ((coeff!(1.0) * linear!(id)).unwrap() + coeff!(0.25)).unwrap();
Instance::new(
Sense::Minimize,
Function::Zero,
btreemap! { id => DecisionVariable::binary() },
btreemap! {
ConstraintID::from(0) =>
crate::Constraint::less_than_or_equal_to_zero(Function::from(function)),
},
)
.unwrap()
}
#[test]
fn converts_integer_inequality_to_equality_with_slack() {
let dv = btreemap! {
VariableID::from(1) => DecisionVariable::new(
Kind::Integer,
Bound::new(0.0, 3.0).unwrap(),
ATol::default(),
).unwrap(),
VariableID::from(2) => DecisionVariable::new(
Kind::Integer,
Bound::new(0.0, 3.0).unwrap(),
ATol::default(),
).unwrap(),
};
let objective = (Function::from(linear!(1)) + Function::from(linear!(2))).unwrap();
let constraint_fn = ((Function::from(linear!(1)) + Function::from(linear!(2))).unwrap()
+ coeff!(-4.0))
.unwrap();
let constraints = btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(constraint_fn,
),
};
let mut instance = Instance::new(Sense::Minimize, objective, dv, constraints).unwrap();
instance
.convert_inequality_to_equality_with_integer_slack(0, 32, ATol::default())
.unwrap();
let constraint = instance
.constraints()
.get(&ConstraintID::from(0))
.expect("constraint should still be present");
assert_eq!(constraint.equality, Equality::EqualToZero);
let store = instance.variable_labels();
assert!(instance
.decision_variables
.keys()
.any(|id| store.name(*id) == Some("ommx.slack")));
}
#[test]
fn exact_integer_slack_uses_constraint_atol_at_a_positive_lower_bound() {
let atol = ATol::new(0.25).unwrap();
let mut instance = tolerance_boundary_instance();
instance
.convert_inequality_to_equality_with_integer_slack(0, 1, atol)
.unwrap();
let slack_id = instance
.decision_variables()
.keys()
.copied()
.find(|id| instance.variable_labels().name(*id) == Some("ommx.slack"))
.unwrap();
let constraint = &instance.constraints()[&ConstraintID::from(0)];
let feasible = constraint
.evaluate(
&crate::v1::State::from_iter([(1, 0.0), (slack_id.into_inner(), 0.0)]),
atol,
)
.unwrap();
let infeasible = constraint
.evaluate(
&crate::v1::State::from_iter([(1, 1.0), (slack_id.into_inner(), 0.0)]),
atol,
)
.unwrap();
assert!(feasible.is_feasible_with_tolerance(atol));
assert!(!infeasible.is_feasible_with_tolerance(atol));
}
#[test]
fn inequality_preserving_slack_uses_constraint_atol_at_a_positive_lower_bound() {
let atol = ATol::new(0.25).unwrap();
let mut instance = tolerance_boundary_instance();
let coefficient = instance
.add_integer_slack_to_inequality(0, 1, atol)
.unwrap();
assert_eq!(coefficient, Some(0.0));
let constraint = &instance.constraints()[&ConstraintID::from(0)];
let feasible = constraint
.evaluate(&crate::v1::State::from_iter([(1, 0.0)]), atol)
.unwrap();
let infeasible = constraint
.evaluate(&crate::v1::State::from_iter([(1, 1.0)]), atol)
.unwrap();
assert!(feasible.is_feasible_with_tolerance(atol));
assert!(!infeasible.is_feasible_with_tolerance(atol));
}
#[test]
fn slack_conversion_relaxes_a_positive_but_tolerance_feasible_constraint() {
let atol = ATol::new(0.25).unwrap();
for exact in [true, false] {
let mut instance = Instance::new(
Sense::Minimize,
Function::Zero,
BTreeMap::new(),
btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(
Function::try_from(0.25).unwrap()
),
},
)
.unwrap();
if exact {
instance
.convert_inequality_to_equality_with_integer_slack(0, 1, atol)
.unwrap();
} else {
assert_eq!(
instance
.add_integer_slack_to_inequality(0, 1, atol)
.unwrap(),
None
);
}
assert!(instance.constraints().is_empty());
assert!(instance
.removed_constraints()
.contains_key(&ConstraintID::from(0)));
}
}
#[test]
fn slack_conversion_rejects_a_constant_just_outside_atol() {
let atol = ATol::new(0.25).unwrap();
let outside = f64::from_bits(0.25_f64.to_bits() + 1);
for exact in [true, false] {
let mut instance = Instance::new(
Sense::Minimize,
Function::Zero,
BTreeMap::new(),
btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(
Function::try_from(outside).unwrap()
),
},
)
.unwrap();
let before = instance.clone();
let err = if exact {
instance
.convert_inequality_to_equality_with_integer_slack(0, 1, atol)
.unwrap_err()
} else {
instance
.add_integer_slack_to_inequality(0, 1, atol)
.unwrap_err()
};
assert!(err.is::<InfeasibleDetected>());
assert_eq!(instance, before);
}
}
#[test]
fn exact_integer_slack_rejects_large_atol_without_mutating_instance() {
for value in [0.5, 1.0] {
let mut instance = tolerance_boundary_instance();
let before = instance.clone();
let err = instance
.convert_inequality_to_equality_with_integer_slack(0, 1, ATol::new(value).unwrap())
.unwrap_err();
assert!(err.to_string().contains("requires atol < 0.5"));
assert!(!err.is::<ExactIntegerSlackUnavailable>());
assert_eq!(instance, before);
}
}
#[test]
fn inequality_preserving_slack_rejects_large_atol_without_mutating_instance() {
for value in [0.5, 1.0] {
let mut instance = tolerance_boundary_instance();
let before = instance.clone();
let err = instance
.add_integer_slack_to_inequality(0, 1, ATol::new(value).unwrap())
.unwrap_err();
assert!(err.to_string().contains("requires atol < 0.5"));
assert_eq!(instance, before);
}
}
#[test]
fn exact_integer_slack_range_limit_is_a_recoverable_signal() {
let id = VariableID::from(1);
let dv = btreemap! {
id => DecisionVariable::new(
Kind::Integer,
Bound::new(0.0, 3.0).unwrap(),
ATol::default(),
).unwrap(),
};
let objective = Function::from(linear!(1));
let constraint_fn = (Function::from(linear!(1)) + coeff!(-2.0)).unwrap();
let constraints = btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(constraint_fn),
};
let mut instance = Instance::new(Sense::Minimize, objective, dv, constraints).unwrap();
let before = instance.clone();
let err = instance
.convert_inequality_to_equality_with_integer_slack(0, 1, ATol::default())
.unwrap_err();
assert!(err.is::<ExactIntegerSlackUnavailable>());
assert_eq!(instance, before);
}
#[test]
fn exact_integer_slack_preserves_content_factor_signal() {
let id = VariableID::from(1);
let dv = btreemap! {
id => DecisionVariable::new(
Kind::Integer,
Bound::new(0.0, 1.0).unwrap(),
ATol::default(),
).unwrap(),
};
let constraint_fn = Function::from(Linear::single_term(
LinearMonomial::Variable(id),
Coefficient::try_from(f64::MAX).unwrap(),
));
let constraints = btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(constraint_fn),
};
let mut instance = Instance::new(Sense::Minimize, Function::Zero, dv, constraints).unwrap();
let err = instance
.convert_inequality_to_equality_with_integer_slack(0, 32, ATol::default())
.unwrap_err();
assert!(err.is::<ExactIntegerSlackUnavailable>());
assert!(matches!(
err.downcast_ref::<crate::ContentFactorError>(),
Some(crate::ContentFactorError::CannotApproximateCoefficient)
));
assert_eq!(instance.decision_variables().len(), 1);
assert!(instance.constraints().contains_key(&ConstraintID::from(0)));
}
#[test]
fn add_integer_slack_updates_function_but_keeps_inequality() {
let dv = btreemap! {
VariableID::from(1) => DecisionVariable::new(
Kind::Integer,
Bound::new(0.0, 3.0).unwrap(),
ATol::default(),
).unwrap(),
};
let objective = Function::from(linear!(1));
let constraint_fn = (Function::from(linear!(1)) + coeff!(-2.0)).unwrap();
let constraints = btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(constraint_fn,
),
};
let mut instance = Instance::new(Sense::Minimize, objective, dv, constraints).unwrap();
let b = instance
.add_integer_slack_to_inequality(0, 2, ATol::default())
.unwrap()
.expect("constraint should still be active");
assert!(b > 0.0);
let constraint = instance
.constraints()
.get(&ConstraintID::from(0))
.expect("constraint should still be present");
assert_eq!(constraint.equality, Equality::LessThanOrEqualToZero);
let store = instance.variable_labels();
assert!(instance
.decision_variables
.keys()
.any(|id| store.name(*id) == Some("ommx.slack")));
}
#[test]
fn always_satisfied_inequality_is_relaxed() {
let dv = btreemap! {
VariableID::from(1) => DecisionVariable::new(
Kind::Integer,
Bound::new(0.0, 3.0).unwrap(),
ATol::default(),
).unwrap(),
};
let objective = Function::from(linear!(1));
let constraint_fn = (Function::from(linear!(1)) + coeff!(-10.0)).unwrap();
let constraints = btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(constraint_fn,
),
};
let mut instance = Instance::new(Sense::Minimize, objective, dv, constraints).unwrap();
let result = instance
.add_integer_slack_to_inequality(0, 2, ATol::default())
.unwrap();
assert!(result.is_none());
assert!(instance.constraints().is_empty());
assert_eq!(instance.removed_constraints().len(), 1);
}
#[test]
fn rejects_zero_slack_upper_bound_without_mutating_instance() {
let dv = btreemap! {
VariableID::from(1) => DecisionVariable::new(
Kind::Integer,
Bound::new(0.0, 3.0).unwrap(),
ATol::default(),
).unwrap(),
};
let objective = Function::from(linear!(1));
let constraint_fn = (Function::from(linear!(1)) + coeff!(-2.0)).unwrap();
let constraints = btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(constraint_fn,
),
};
let mut instance = Instance::new(Sense::Minimize, objective, dv, constraints).unwrap();
let before = instance.decision_variables.len();
let err = instance
.add_integer_slack_to_inequality(0, 0, ATol::default())
.unwrap_err();
assert!(err.to_string().to_lowercase().contains("finite"));
assert_eq!(instance.decision_variables.len(), before);
let constraint = instance.constraints().get(&ConstraintID::from(0)).unwrap();
assert_eq!(constraint.equality, Equality::LessThanOrEqualToZero);
}
#[test]
fn convert_inequality_rejects_equality_constraint() {
let dv = btreemap! {
VariableID::from(1) => DecisionVariable::new(
Kind::Integer,
Bound::new(0.0, 3.0).unwrap(),
ATol::default(),
).unwrap(),
};
let objective = Function::from(linear!(1));
let constraint_fn = (Function::from(linear!(1)) + coeff!(-2.0)).unwrap();
let constraints = btreemap! {
ConstraintID::from(0) => crate::Constraint::equal_to_zero(constraint_fn,
),
};
let mut instance = Instance::new(Sense::Minimize, objective, dv, constraints).unwrap();
let err = instance
.convert_inequality_to_equality_with_integer_slack(0, 32, ATol::default())
.unwrap_err();
assert!(!err.is::<ExactIntegerSlackUnavailable>());
assert!(err.to_string().contains("not inequality"));
}
#[test]
fn rejects_constraint_with_continuous_variable() {
let dv = btreemap! {
VariableID::from(1) => DecisionVariable::continuous(),
};
let objective = Function::from(linear!(1));
let constraint_fn = (Function::from(linear!(1)) + coeff!(-2.0)).unwrap();
let constraints = btreemap! {
ConstraintID::from(0) => crate::Constraint::less_than_or_equal_to_zero(constraint_fn,
),
};
let mut instance = Instance::new(Sense::Minimize, objective, dv, constraints).unwrap();
let err = instance
.convert_inequality_to_equality_with_integer_slack(0, 32, ATol::default())
.unwrap_err();
assert!(!err.is::<ExactIntegerSlackUnavailable>());
assert!(err.to_string().contains("continuous decision variables"));
}
}