use std::fmt::Write;
use indexmap::IndexMap;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::error::{LpParseError, LpResult};
use crate::interner::NameId;
use crate::model::{Coefficient, ComparisonOp, Constraint, Objective, Sense, VariableType};
use crate::problem::LpProblem;
use crate::writer::write_number;
pub const EMPTY_OBJECTIVE_ROW_NAME: &str = "OBJ";
pub(crate) const SEMI_CONTINUOUS_SENTINEL_UPPER: f64 = 1e30;
const RHS_VECTOR_LABEL: &str = "RHS";
const BOUNDS_VECTOR_LABEL: &str = "BOUND";
#[derive(Debug, Clone)]
pub struct MpsWriterOptions {
pub decimal_precision: usize,
pub allow_multiple_objectives: bool,
}
impl Default for MpsWriterOptions {
fn default() -> Self {
Self { decimal_precision: 6, allow_multiple_objectives: false }
}
}
pub fn write_mps_string(problem: &LpProblem) -> LpResult<String> {
write_mps_string_with_options(problem, &MpsWriterOptions::default())
}
pub fn write_mps_string_with_options(problem: &LpProblem, options: &MpsWriterOptions) -> LpResult<String> {
let mut output = String::new();
build_mps(&mut output, problem, options)?;
Ok(output)
}
fn build_mps(output: &mut String, problem: &LpProblem, options: &MpsWriterOptions) -> LpResult<()> {
let objective = select_objective(problem, options)?;
let obj_row_name: &str = objective.map_or(EMPTY_OBJECTIVE_ROW_NAME, |o| problem.resolve(o.name));
let range_pairs = detect_range_pairs(problem);
write_name_line(output, problem).expect("fmt::Write to String is infallible");
if problem.sense == Sense::Maximize {
writeln!(output, "OBJSENSE").expect("fmt::Write to String is infallible");
writeln!(output, " MAX").expect("fmt::Write to String is infallible");
}
write_rows_section(output, problem, obj_row_name, &range_pairs)?;
let columns = build_columns(problem, objective, obj_row_name, &range_pairs);
write_columns_section(output, problem, &columns, options).expect("fmt::Write to String is infallible");
write_rhs_section(output, problem, objective, obj_row_name, options, &range_pairs).expect("fmt::Write to String is infallible");
write_ranges_section(output, problem, options, &range_pairs).expect("fmt::Write to String is infallible");
write_bounds_section(output, problem, options)?;
write_sos_section(output, problem, options).expect("fmt::Write to String is infallible");
writeln!(output, "ENDATA").expect("fmt::Write to String is infallible");
Ok(())
}
#[derive(Default)]
struct RangePairs {
ranges: FxHashMap<NameId, f64>,
skip: FxHashSet<NameId>,
}
fn coefficients_match(a: &[Coefficient], b: &[Coefficient]) -> bool {
if a.len() != b.len() {
return false;
}
let map: FxHashMap<NameId, f64> = a.iter().map(|c| (c.name, c.value)).collect();
b.iter().all(|c| map.get(&c.name) == Some(&c.value))
}
fn detect_range_pairs(problem: &LpProblem) -> RangePairs {
let mut pairs = RangePairs::default();
for (name_id, constraint) in &problem.constraints {
let Constraint::Standard { name, coefficients, operator: ComparisonOp::LTE, rhs: upper_rhs, .. } = constraint else {
continue;
};
let Some(base_name) = problem.resolve(*name).strip_suffix("_rng") else {
continue;
};
let Some(base_id) = problem.name_id(base_name) else {
continue;
};
let Some(Constraint::Standard { coefficients: base_coefficients, operator: ComparisonOp::GTE, rhs: lower_rhs, .. }) =
problem.constraints.get(&base_id)
else {
continue;
};
if !upper_rhs.is_finite() || !lower_rhs.is_finite() || upper_rhs < lower_rhs {
continue;
}
if !coefficients_match(base_coefficients, coefficients) {
continue;
}
pairs.ranges.insert(base_id, upper_rhs - lower_rhs);
pairs.skip.insert(*name_id);
}
debug_assert_eq!(pairs.ranges.len(), pairs.skip.len(), "every range entry must have exactly one skipped companion row");
pairs
}
fn select_objective<'p>(problem: &'p LpProblem, options: &MpsWriterOptions) -> LpResult<Option<&'p Objective>> {
match problem.objectives.len() {
0 => Ok(None),
1 => Ok(problem.objectives.values().next()),
count if options.allow_multiple_objectives => {
debug_assert!(count > 1, "count > 1 guaranteed by preceding match arms");
Ok(problem.objectives.values().next())
}
count => Err(LpParseError::validation_error(format!(
"MPS format supports a single objective, but the problem has {count} objectives; \
set MpsWriterOptions::allow_multiple_objectives to write only the first"
))),
}
}
fn write_name_line(output: &mut String, problem: &LpProblem) -> std::fmt::Result {
match problem.name() {
Some(name) => writeln!(output, "NAME {name}"),
None => writeln!(output, "NAME"),
}
}
fn row_type_letter(operator: ComparisonOp, constraint_name: &str) -> LpResult<char> {
match operator {
ComparisonOp::LTE => Ok('L'),
ComparisonOp::GTE => Ok('G'),
ComparisonOp::EQ => Ok('E'),
ComparisonOp::LT | ComparisonOp::GT => Err(LpParseError::validation_error(format!(
"constraint '{constraint_name}' uses strict inequality '{operator}', which MPS cannot represent"
))),
}
}
fn write_rows_section(output: &mut String, problem: &LpProblem, obj_row_name: &str, range_pairs: &RangePairs) -> LpResult<()> {
writeln!(output, "ROWS").expect("fmt::Write to String is infallible");
writeln!(output, " N {obj_row_name}").expect("fmt::Write to String is infallible");
for (name_id, constraint) in &problem.constraints {
if range_pairs.skip.contains(name_id) {
continue;
}
if let Constraint::Standard { name, operator, .. } = constraint {
let resolved_name = problem.resolve(*name);
let letter = row_type_letter(*operator, resolved_name)?;
writeln!(output, " {letter} {resolved_name}").expect("fmt::Write to String is infallible");
}
}
Ok(())
}
const fn needs_marker(var_type: &VariableType) -> bool {
matches!(var_type, VariableType::Integer | VariableType::General | VariableType::Binary)
}
type ColumnEntries<'p> = IndexMap<NameId, Vec<(&'p str, f64)>>;
fn build_columns<'p>(
problem: &'p LpProblem,
objective: Option<&'p Objective>,
obj_row_name: &'p str,
range_pairs: &RangePairs,
) -> ColumnEntries<'p> {
let mut columns: ColumnEntries<'p> = IndexMap::with_capacity(problem.variables.len());
for name_id in problem.variables.keys() {
columns.insert(*name_id, Vec::new());
}
if let Some(obj) = objective {
for coeff in &obj.coefficients {
debug_assert!(problem.variables.contains_key(&coeff.name), "objective coefficient must reference a registered variable");
columns.entry(coeff.name).or_default().push((obj_row_name, coeff.value));
}
}
for (constraint_id, constraint) in &problem.constraints {
if range_pairs.skip.contains(constraint_id) {
continue; }
if let Constraint::Standard { name, coefficients, .. } = constraint {
let row_name = problem.resolve(*name);
for coeff in coefficients {
debug_assert!(problem.variables.contains_key(&coeff.name), "constraint coefficient must reference a registered variable");
columns.entry(coeff.name).or_default().push((row_name, coeff.value));
}
}
}
for (name_id, variable) in &problem.variables {
if needs_marker(&variable.var_type) {
let entries = columns.entry(*name_id).or_default();
if entries.is_empty() {
entries.push((obj_row_name, 0.0));
}
}
}
columns
}
fn write_columns_section(
output: &mut String,
problem: &LpProblem,
columns: &ColumnEntries<'_>,
options: &MpsWriterOptions,
) -> std::fmt::Result {
writeln!(output, "COLUMNS")?;
for (name_id, variable) in &problem.variables {
let entries = columns.get(name_id).map_or([].as_slice(), Vec::as_slice);
if entries.is_empty() {
continue;
}
let var_name = problem.resolve(*name_id);
let wrap = needs_marker(&variable.var_type);
if wrap {
writeln!(output, " MARKER 'MARKER' 'INTORG'")?;
}
for &(row_name, value) in entries {
write!(output, " {var_name:<10} {row_name:<10} ")?;
write_number(output, value, options.decimal_precision)?;
writeln!(output)?;
}
if wrap {
writeln!(output, " MARKER 'MARKER' 'INTEND'")?;
}
}
Ok(())
}
fn write_rhs_section(
output: &mut String,
problem: &LpProblem,
objective: Option<&Objective>,
obj_row_name: &str,
options: &MpsWriterOptions,
range_pairs: &RangePairs,
) -> std::fmt::Result {
debug_assert!(!obj_row_name.is_empty(), "obj_row_name must not be empty");
writeln!(output, "RHS")?;
if let Some(obj) = objective {
if obj.constant != 0.0 {
write!(output, " {RHS_VECTOR_LABEL:<10} {obj_row_name:<10} ")?;
write_number(output, -obj.constant, options.decimal_precision)?;
writeln!(output)?;
}
}
for (constraint_id, constraint) in &problem.constraints {
if range_pairs.skip.contains(constraint_id) {
continue;
}
if let Constraint::Standard { name, rhs, .. } = constraint {
if *rhs == 0.0 {
continue;
}
let resolved_name = problem.resolve(*name);
write!(output, " {RHS_VECTOR_LABEL:<10} {resolved_name:<10} ")?;
write_number(output, *rhs, options.decimal_precision)?;
writeln!(output)?;
}
}
Ok(())
}
const RANGES_VECTOR_LABEL: &str = "RNG";
fn write_ranges_section(
output: &mut String,
problem: &LpProblem,
options: &MpsWriterOptions,
range_pairs: &RangePairs,
) -> std::fmt::Result {
if range_pairs.ranges.is_empty() {
return Ok(());
}
writeln!(output, "RANGES")?;
for constraint_id in problem.constraints.keys() {
if let Some(range_value) = range_pairs.ranges.get(constraint_id) {
let resolved_name = problem.resolve(*constraint_id);
write!(output, " {RANGES_VECTOR_LABEL:<10} {resolved_name:<10} ")?;
write_number(output, *range_value, options.decimal_precision)?;
writeln!(output)?;
}
}
Ok(())
}
fn write_bound_value(output: &mut String, bound_type: &str, var_name: &str, value: f64, precision: usize) -> std::fmt::Result {
write!(output, " {bound_type} {BOUNDS_VECTOR_LABEL:<9} {var_name:<10} ")?;
write_number(output, value, precision)?;
writeln!(output)
}
fn write_bound_flag(output: &mut String, bound_type: &str, var_name: &str) -> std::fmt::Result {
writeln!(output, " {bound_type} {BOUNDS_VECTOR_LABEL:<9} {var_name}")
}
fn invalid_bound_error(var_name: &str, message: &str) -> LpParseError {
LpParseError::validation_error(format!("variable '{var_name}' {message}"))
}
fn write_variable_bound(output: &mut String, var_name: &str, var_type: &VariableType, precision: usize) -> LpResult<()> {
match *var_type {
VariableType::Free => {
write_bound_flag(output, "FR", var_name).expect("fmt::Write to String is infallible");
Ok(())
}
VariableType::LowerBound(lb) => write_lower_bound(output, var_name, lb, precision),
VariableType::UpperBound(ub) => write_upper_bound(output, var_name, ub, precision),
VariableType::DoubleBound(lb, ub) => write_double_bound(output, var_name, lb, ub, precision),
VariableType::Binary => {
write_bound_flag(output, "BV", var_name).expect("fmt::Write to String is infallible");
Ok(())
}
VariableType::Integer | VariableType::General => {
write_bound_value(output, "LO", var_name, 0.0, precision).expect("fmt::Write to String is infallible");
Ok(())
}
VariableType::SemiContinuous => {
write_bound_value(output, "SC", var_name, SEMI_CONTINUOUS_SENTINEL_UPPER, precision)
.expect("fmt::Write to String is infallible");
Ok(())
}
VariableType::SOS => Ok(()),
}
}
fn write_lower_bound(output: &mut String, var_name: &str, lb: f64, precision: usize) -> LpResult<()> {
if lb.is_nan() {
return Err(invalid_bound_error(var_name, "has a NaN lower bound, which MPS cannot represent"));
}
if lb == f64::INFINITY {
return Err(invalid_bound_error(var_name, "has a lower bound of +inf, which MPS cannot represent"));
}
if lb == f64::NEG_INFINITY {
write_bound_flag(output, "MI", var_name).expect("fmt::Write to String is infallible");
return Ok(());
}
write_bound_value(output, "LO", var_name, lb, precision).expect("fmt::Write to String is infallible");
Ok(())
}
fn write_upper_bound(output: &mut String, var_name: &str, ub: f64, precision: usize) -> LpResult<()> {
if ub.is_nan() {
return Err(invalid_bound_error(var_name, "has a NaN upper bound, which MPS cannot represent"));
}
if ub == f64::NEG_INFINITY {
return Err(invalid_bound_error(var_name, "has an upper bound of -inf, which MPS cannot represent"));
}
if ub == f64::INFINITY {
write_bound_flag(output, "PL", var_name).expect("fmt::Write to String is infallible");
return Ok(());
}
if ub < 0.0 {
write_bound_value(output, "LO", var_name, 0.0, precision).expect("fmt::Write to String is infallible");
}
write_bound_value(output, "UP", var_name, ub, precision).expect("fmt::Write to String is infallible");
Ok(())
}
fn write_double_bound(output: &mut String, var_name: &str, lb: f64, ub: f64, precision: usize) -> LpResult<()> {
if lb.is_nan() || ub.is_nan() {
return Err(invalid_bound_error(var_name, "has a NaN double bound, which MPS cannot represent"));
}
if lb == f64::INFINITY || ub == f64::NEG_INFINITY {
return Err(invalid_bound_error(var_name, &format!("has a nonsensical double bound ({lb}, {ub}), which MPS cannot represent")));
}
#[allow(clippy::float_cmp)]
if lb == ub {
write_bound_value(output, "FX", var_name, lb, precision).expect("fmt::Write to String is infallible");
return Ok(());
}
match (lb.is_infinite() && lb < 0.0, ub.is_infinite() && ub > 0.0) {
(true, true) => write_bound_flag(output, "FR", var_name).expect("fmt::Write to String is infallible"),
(true, false) => {
write_bound_flag(output, "MI", var_name).expect("fmt::Write to String is infallible");
write_bound_value(output, "UP", var_name, ub, precision).expect("fmt::Write to String is infallible");
}
(false, true) => {
write_bound_value(output, "LO", var_name, lb, precision).expect("fmt::Write to String is infallible");
write_bound_flag(output, "PL", var_name).expect("fmt::Write to String is infallible");
}
(false, false) => {
write_bound_value(output, "LO", var_name, lb, precision).expect("fmt::Write to String is infallible");
write_bound_value(output, "UP", var_name, ub, precision).expect("fmt::Write to String is infallible");
}
}
Ok(())
}
fn write_bounds_section(output: &mut String, problem: &LpProblem, options: &MpsWriterOptions) -> LpResult<()> {
if problem.variables.is_empty() {
return Ok(());
}
writeln!(output, "BOUNDS").expect("fmt::Write to String is infallible");
for (name_id, variable) in &problem.variables {
let var_name = problem.resolve(*name_id);
write_variable_bound(output, var_name, &variable.var_type, options.decimal_precision)?;
}
Ok(())
}
fn write_sos_section(output: &mut String, problem: &LpProblem, options: &MpsWriterOptions) -> std::fmt::Result {
let has_sos = problem.constraints.values().any(|c| matches!(c, Constraint::SOS { .. }));
if !has_sos {
return Ok(());
}
writeln!(output, "SOS")?;
for constraint in problem.constraints.values() {
if let Constraint::SOS { name, sos_type, weights, .. } = constraint {
writeln!(output, " {sos_type} {}", problem.resolve(*name))?;
for weight in weights {
write!(output, " {:<10} ", problem.resolve(weight.name))?;
write_number(output, weight.value, options.decimal_precision)?;
writeln!(output)?;
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
use crate::model::{Coefficient, ComparisonOp, SOSType};
use crate::mps::parse_mps;
fn build_problem_with_bounds_and_sos() -> LpProblem {
let mut problem = LpProblem::new().with_problem_name(String::from("Sample")).with_sense(Sense::Maximize);
let profit_id = problem.intern("profit");
let x1_id = problem.intern("x1");
let x2_id = problem.intern("x2");
let x3_id = problem.intern("x3");
let capacity_id = problem.intern("capacity");
let sos1_id = problem.intern("sos1");
problem.add_objective(Objective {
name: profit_id,
coefficients: vec![
Coefficient { name: x1_id, value: 3.0 },
Coefficient { name: x2_id, value: 2.0 },
Coefficient { name: x3_id, value: 1.0 },
],
constant: 0.0,
byte_offset: None,
});
problem.add_constraint(Constraint::Standard {
name: capacity_id,
coefficients: vec![
Coefficient { name: x1_id, value: 1.0 },
Coefficient { name: x2_id, value: 1.0 },
Coefficient { name: x3_id, value: 1.0 },
],
operator: ComparisonOp::LTE,
rhs: 100.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::Integer).unwrap();
problem.update_variable_type("x2", VariableType::DoubleBound(0.0, 50.0)).unwrap();
problem.update_variable_type("x3", VariableType::Binary).unwrap();
problem.add_constraint(Constraint::SOS {
name: sos1_id,
sos_type: SOSType::S1,
weights: vec![Coefficient { name: x1_id, value: 1.0 }, Coefficient { name: x2_id, value: 2.0 }],
byte_offset: None,
});
problem
}
#[test]
fn test_write_empty_problem() {
let problem = LpProblem::new();
let result = write_mps_string(&problem).unwrap();
assert!(result.contains("NAME"));
assert!(result.contains(&format!(" N {EMPTY_OBJECTIVE_ROW_NAME}")));
assert!(result.contains("ENDATA"));
let reparsed = LpProblem::parse_mps(&result).unwrap();
assert_eq!(reparsed.objective_count(), 1);
}
#[test]
fn test_write_simple_problem_and_reparse() {
let mut problem = LpProblem::new().with_problem_name(String::from("Test Problem")).with_sense(Sense::Maximize);
let profit_id = problem.intern("profit");
let x1_id = problem.intern("x1");
let x2_id = problem.intern("x2");
let capacity_id = problem.intern("capacity");
problem.add_objective(Objective {
name: profit_id,
coefficients: vec![Coefficient { name: x1_id, value: 3.0 }, Coefficient { name: x2_id, value: 2.0 }],
constant: 0.0,
byte_offset: None,
});
problem.add_constraint(Constraint::Standard {
name: capacity_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }, Coefficient { name: x2_id, value: 1.0 }],
operator: ComparisonOp::LTE,
rhs: 100.0,
byte_offset: None,
});
let output = write_mps_string(&problem).unwrap();
assert!(output.contains("OBJSENSE"));
assert!(output.contains("MAX"));
assert!(output.contains(" N profit"));
assert!(output.contains(" L capacity"));
let reparsed = LpProblem::parse_mps(&output).unwrap();
assert_eq!(reparsed.sense, Sense::Maximize);
assert_eq!(reparsed.objective_count(), 1);
assert_eq!(reparsed.constraint_count(), 1);
assert_eq!(reparsed.variable_count(), 2);
let capacity = reparsed.constraints.get(&reparsed.name_id("capacity").unwrap()).unwrap();
if let Constraint::Standard { rhs, operator, .. } = capacity {
assert_eq!(*rhs, 100.0);
assert_eq!(*operator, ComparisonOp::LTE);
} else {
panic!("expected Standard constraint");
}
}
#[test]
fn test_write_bounds_and_integrality_round_trip() {
let problem = build_problem_with_bounds_and_sos();
let output = write_mps_string(&problem).unwrap();
assert!(output.contains("MARKER"));
assert!(output.contains("INTORG"));
assert!(output.contains("INTEND"));
assert!(output.contains("BV"));
assert!(output.contains("SOS"));
let reparsed = LpProblem::parse_mps(&output).unwrap();
assert_eq!(reparsed.variable_count(), 3);
assert_eq!(reparsed.constraint_count(), 2);
let x1 = &reparsed.variables[&reparsed.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::Integer);
let x2 = &reparsed.variables[&reparsed.name_id("x2").unwrap()];
assert_eq!(x2.var_type, VariableType::DoubleBound(0.0, 50.0));
let x3 = &reparsed.variables[&reparsed.name_id("x3").unwrap()];
assert_eq!(x3.var_type, VariableType::Binary);
let sos = reparsed.constraints.get(&reparsed.name_id("sos1").unwrap()).unwrap();
if let Constraint::SOS { sos_type, weights, .. } = sos {
assert_eq!(*sos_type, SOSType::S1);
assert_eq!(weights.len(), 2);
} else {
panic!("expected SOS constraint");
}
}
#[test]
fn test_double_bound_infinite_upper_round_trips_as_double_bound() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::DoubleBound(5.5, f64::INFINITY)).unwrap();
let output = write_mps_string(&problem).unwrap();
assert!(output.contains("PL"));
let reparsed = LpProblem::parse_mps(&output).unwrap();
let x1 = &reparsed.variables[&reparsed.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::DoubleBound(5.5, f64::INFINITY));
}
#[test]
fn test_negative_upper_bound_keeps_zero_lower_bound() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::UpperBound(-5.0)).unwrap();
let output = write_mps_string(&problem).unwrap();
let reparsed = LpProblem::parse_mps(&output).unwrap();
let x1 = &reparsed.variables[&reparsed.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::DoubleBound(0.0, -5.0));
}
#[test]
fn test_multiple_objectives_error_by_default() {
let mut problem = LpProblem::new();
let a = problem.intern("a");
let b = problem.intern("b");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: a,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.add_objective(Objective {
name: b,
coefficients: vec![Coefficient { name: x1_id, value: 2.0 }],
constant: 0.0,
byte_offset: None,
});
let err = write_mps_string(&problem).unwrap_err();
assert!(matches!(err, LpParseError::ValidationError { .. }));
}
#[test]
fn test_multiple_objectives_allowed_writes_first() {
let mut problem = LpProblem::new();
let a = problem.intern("a");
let b = problem.intern("b");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: a,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.add_objective(Objective {
name: b,
coefficients: vec![Coefficient { name: x1_id, value: 2.0 }],
constant: 0.0,
byte_offset: None,
});
let options = MpsWriterOptions { allow_multiple_objectives: true, ..MpsWriterOptions::default() };
let output = write_mps_string_with_options(&problem, &options).unwrap();
let reparsed = LpProblem::parse_mps(&output).unwrap();
assert_eq!(reparsed.objective_count(), 1);
assert!(reparsed.name_id("a").is_some());
}
#[test]
fn test_strict_inequality_returns_error() {
let mut problem = LpProblem::new();
let x1_id = problem.intern("x1");
let c1 = problem.intern("c1");
problem.add_constraint(Constraint::Standard {
name: c1,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
operator: ComparisonOp::LT,
rhs: 5.0,
byte_offset: None,
});
let err = write_mps_string(&problem).unwrap_err();
assert!(matches!(err, LpParseError::ValidationError { .. }));
}
#[test]
fn test_isolated_integer_variable_registers_as_column() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
problem.add_objective(Objective { name: obj_id, coefficients: vec![], constant: 0.0, byte_offset: None });
let x1_id = problem.intern("x1");
problem.add_variable(crate::model::Variable::new(x1_id).with_var_type(VariableType::General));
let output = write_mps_string(&problem).unwrap();
let reparsed = LpProblem::parse_mps(&output).unwrap();
let x1 = &reparsed.variables[&reparsed.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::Integer);
}
#[test]
fn test_ranges_round_trip() {
let input = "\
NAME rngtest
ROWS
N obj
G lim1
L lim2
COLUMNS
x1 obj 1
x1 lim1 1
x1 lim2 2
RHS
RHS lim1 2
RHS lim2 10
RANGES
RNG lim1 4
RNG lim2 3
ENDATA
";
let problem = LpProblem::parse_mps(input).unwrap();
assert_eq!(problem.constraint_count(), 4, "two ranged rows must flatten into four constraints");
let output = write_mps_string(&problem).unwrap();
assert!(output.contains("RANGES"), "RANGES section must be re-emitted:\n{output}");
assert!(!output.contains("lim1_rng"), "companion rows must fold back into the RANGES entry:\n{output}");
let reparsed = LpProblem::parse_mps(&output).unwrap();
assert_eq!(reparsed.constraint_count(), problem.constraint_count());
for (name, expected_op, expected_rhs) in [
("lim1", ComparisonOp::GTE, 2.0),
("lim1_rng", ComparisonOp::LTE, 6.0),
("lim2", ComparisonOp::GTE, 7.0),
("lim2_rng", ComparisonOp::LTE, 10.0),
] {
let id = reparsed.name_id(name).unwrap_or_else(|| panic!("constraint '{name}' missing after round trip"));
let Some(Constraint::Standard { operator, rhs, .. }) = reparsed.constraints.get(&id) else {
panic!("constraint '{name}' must be a standard constraint");
};
assert_eq!(*operator, expected_op, "operator mismatch for '{name}'");
assert_eq!(*rhs, expected_rhs, "rhs mismatch for '{name}'");
}
}
#[test]
fn test_user_authored_rng_suffix_not_merged_when_structurally_different() {
let input = "\
Minimize
obj: x + y
Subject To
c1: x + y >= 1
c1_rng: x + 2 y <= 5
End
";
let problem = LpProblem::parse(input).unwrap();
let output = write_mps_string(&problem).unwrap();
assert!(!output.contains("RANGES"), "structurally different pair must not merge:\n{output}");
assert!(output.contains("c1_rng"), "companion row must be written as an ordinary row:\n{output}");
}
#[test]
fn test_semi_continuous_round_trips() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
problem.add_objective(Objective { name: obj_id, coefficients: vec![], constant: 0.0, byte_offset: None });
let x1_id = problem.intern("x1");
problem.add_variable(crate::model::Variable::new(x1_id).with_var_type(VariableType::SemiContinuous));
let output = write_mps_string(&problem).unwrap();
assert!(output.contains("SC"));
let reparsed = LpProblem::parse_mps(&output).unwrap();
let x1 = &reparsed.variables[&reparsed.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::SemiContinuous);
}
#[test]
fn pl_only_bound_round_trips_as_upper_bound_infinity() {
let input = "\
NAME pltest
ROWS
N obj
L c1
COLUMNS
x1 obj 1
x1 c1 1
RHS
RHS_V c1 10
BOUNDS
PL BOUND x1
ENDATA
";
let problem = LpProblem::parse_mps(input).unwrap();
let x1 = &problem.variables[&problem.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::UpperBound(f64::INFINITY));
let output = write_mps_string(&problem).unwrap();
assert!(output.contains(" PL BOUND"));
assert!(!output.contains("inf"), "must not leak a raw `inf` literal into the bounds line");
let reparsed = LpProblem::parse_mps(&output).unwrap();
let x1 = &reparsed.variables[&reparsed.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::UpperBound(f64::INFINITY));
}
#[test]
fn mi_only_bound_round_trips_as_lower_bound_negative_infinity() {
let input = "\
NAME mitest
ROWS
N obj
L c1
COLUMNS
x1 obj 1
x1 c1 1
RHS
RHS_V c1 10
BOUNDS
MI BOUND x1
ENDATA
";
let problem = LpProblem::parse_mps(input).unwrap();
let x1 = &problem.variables[&problem.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::LowerBound(f64::NEG_INFINITY));
let output = write_mps_string(&problem).unwrap();
assert!(output.contains(" MI BOUND"));
assert!(!output.contains("inf"), "must not leak a raw `inf` literal into the bounds line");
let reparsed = LpProblem::parse_mps(&output).unwrap();
let x1 = &reparsed.variables[&reparsed.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::LowerBound(f64::NEG_INFINITY));
}
#[test]
fn nan_upper_bound_returns_validation_error() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::UpperBound(f64::NAN)).unwrap();
let err = write_mps_string(&problem).unwrap_err();
assert!(matches!(err, LpParseError::ValidationError { .. }));
}
#[test]
fn nan_lower_bound_returns_validation_error() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::LowerBound(f64::NAN)).unwrap();
let err = write_mps_string(&problem).unwrap_err();
assert!(matches!(err, LpParseError::ValidationError { .. }));
}
#[test]
fn nonsensical_upper_bound_negative_infinity_returns_validation_error() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::UpperBound(f64::NEG_INFINITY)).unwrap();
let err = write_mps_string(&problem).unwrap_err();
assert!(matches!(err, LpParseError::ValidationError { .. }));
}
#[test]
fn nonsensical_lower_bound_positive_infinity_returns_validation_error() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::LowerBound(f64::INFINITY)).unwrap();
let err = write_mps_string(&problem).unwrap_err();
assert!(matches!(err, LpParseError::ValidationError { .. }));
}
#[test]
fn nan_double_bound_returns_validation_error() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::DoubleBound(f64::NAN, 5.0)).unwrap();
let err = write_mps_string(&problem).unwrap_err();
assert!(matches!(err, LpParseError::ValidationError { .. }));
}
#[test]
fn nonsensical_double_bound_returns_validation_error() {
let mut problem = LpProblem::new();
let obj_id = problem.intern("obj");
let x1_id = problem.intern("x1");
problem.add_objective(Objective {
name: obj_id,
coefficients: vec![Coefficient { name: x1_id, value: 1.0 }],
constant: 0.0,
byte_offset: None,
});
problem.update_variable_type("x1", VariableType::DoubleBound(f64::INFINITY, 5.0)).unwrap();
let err = write_mps_string(&problem).unwrap_err();
assert!(matches!(err, LpParseError::ValidationError { .. }));
}
#[test]
fn mps_round_trip_preserves_mps_fixture() {
let input = "\
NAME test
ROWS
N obj
L c1
G c2
E c3
COLUMNS
x1 obj 1
x1 c1 2
x1 c2 1
x1 c3 1
x2 obj 2
x2 c1 1
RHS
RHS_V c1 10
RHS_V c2 1
RHS_V c3 4
BOUNDS
LO BOUND x1 0
UP BOUND x1 20
ENDATA
";
let original = parse_mps(input).unwrap();
let problem = LpProblem::parse_mps(input).unwrap();
let output = write_mps_string(&problem).unwrap();
let reparsed = LpProblem::parse_mps(&output).unwrap();
assert_eq!(reparsed.variable_count(), problem.variable_count());
assert_eq!(reparsed.constraint_count(), problem.constraint_count());
assert_eq!(reparsed.objective_count(), problem.objective_count());
assert_eq!(reparsed.sense, problem.sense);
assert_eq!(original.constraints.len(), reparsed.constraint_count());
let x1 = &reparsed.variables[&reparsed.name_id("x1").unwrap()];
assert_eq!(x1.var_type, VariableType::DoubleBound(0.0, 20.0));
}
#[test]
fn snapshot_representative_problem() {
let problem = build_problem_with_bounds_and_sos();
let output = write_mps_string(&problem).unwrap();
insta::assert_snapshot!(output);
}
}