Skip to main content

oximo_io/
mps.rs

1//! MPS file format import and export.
2//!
3//! MPS is a widely supported text format for linear and quadratic optimization
4//! problems. It is a common lingua franca for exchanging models between tools.
5//!
6//! [`read_mps`] and [`read_mps_file`] import whitespace-delimited MPS files.
7//! [`write_mps`] exports linear and quadratic models to any `std::io::Write`.
8//!
9//! References:
10//! - "MPS file format," lp_solve. <https://lpsolve.sourceforge.net/5.5/mps-format.htm> (accessed May 09, 2026).
11//! - Gurobi, "Model File Formats." <https://docs.gurobi.com/projects/optimizer/en/current/reference/fileformats/modelformats.html>
12//! - IBM ILOG CPLEX, "Quadratically constrained programs (QCP) in MPS files." <https://www.ibm.com/docs/en/cofz/12.9.0?topic=extensions-quadratically-constrained-programs-qcp-in-mps-files>
13
14use std::collections::HashSet;
15use std::fs::File;
16use std::io::{BufRead, BufReader, Read, Write};
17use std::path::Path;
18
19use oximo_core::{
20    Constraint, Domain, Model, ModelKind, ObjectiveSense, Relate, Sense, SosConstraint, SosType,
21    var_name,
22};
23use oximo_expr::{Expr, QuadraticTerms, VarId, describe_nonlinear_term, extract_quadratic};
24use rustc_hash::FxHashMap;
25
26use crate::error::IoError;
27
28/// Coefficient convention used by quadratic-constraint MPS sections.
29///
30/// Objective sections always use the conventional `0.5 * x' Q x` scaling.
31/// Gurobi-style `QCMATRIX` and constraint `QSECTION` records instead encode
32/// polynomial coefficients directly, whereas CPLEX-style records use the
33/// objective/Hessian convention.
34#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
35pub enum MpsQuadraticFormat {
36    /// Gurobi-compatible quadratic-constraint scaling.
37    #[default]
38    Gurobi,
39    /// CPLEX-compatible quadratic-constraint scaling.
40    Cplex,
41    /// MOSEK-compatible `QSECTION` layout and scaling.
42    Mosek,
43}
44
45/// Options controlling MPS import.
46#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
47pub struct MpsReadOptions {
48    /// How ambiguous quadratic-constraint matrix coefficients are scaled.
49    pub quadratic_format: MpsQuadraticFormat,
50}
51
52/// Options controlling MPS export.
53#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
54pub struct MpsWriteOptions {
55    /// Solver-compatible layout and scaling for quadratic sections.
56    pub quadratic_format: MpsQuadraticFormat,
57}
58
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60enum RowKind {
61    Free,
62    Greater,
63    Less,
64    Equal,
65}
66
67struct ParsedRow {
68    name: String,
69    kind: RowKind,
70    lower: f64,
71    upper: f64,
72    linear: FxHashMap<usize, f64>,
73    quadratic: FxHashMap<(usize, usize), f64>,
74}
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77enum ColumnKind {
78    Continuous,
79    Integer,
80    Binary,
81    SemiContinuous,
82    SemiInteger,
83}
84
85struct ParsedColumn {
86    name: String,
87    lower: f64,
88    upper: f64,
89    kind: ColumnKind,
90    default_bounds: bool,
91    lower_explicit: bool,
92    marker_placement: MarkerPlacement,
93}
94
95#[derive(Default)]
96struct QuadraticTriangle {
97    upper: Option<f64>,
98    lower: Option<f64>,
99}
100
101#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
102enum MarkerPlacement {
103    #[default]
104    Unseen,
105    Outside,
106    Inside,
107    Mixed,
108}
109
110#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
111enum SectionRank {
112    Name,
113    ObjSense,
114    Rows,
115    Columns,
116    Rhs,
117    Ranges,
118    Bounds,
119    Quadratic,
120    Sos,
121    Indicators,
122    End,
123}
124
125#[derive(Clone, Debug)]
126enum Section {
127    Name,
128    ObjSense,
129    Rows,
130    Columns,
131    Rhs,
132    Ranges,
133    Bounds,
134    QuadObj,
135    QMatrix,
136    QcMatrix(String),
137    QSec(String),
138    Sos,
139    Indicators,
140    End,
141}
142
143impl Section {
144    fn rank(&self) -> SectionRank {
145        match self {
146            Self::Name => SectionRank::Name,
147            Self::ObjSense => SectionRank::ObjSense,
148            Self::Rows => SectionRank::Rows,
149            Self::Columns => SectionRank::Columns,
150            Self::Rhs => SectionRank::Rhs,
151            Self::Ranges => SectionRank::Ranges,
152            Self::Bounds => SectionRank::Bounds,
153            Self::QuadObj | Self::QMatrix | Self::QcMatrix(_) | Self::QSec(_) => {
154                SectionRank::Quadratic
155            }
156            Self::Sos => SectionRank::Sos,
157            Self::Indicators => SectionRank::Indicators,
158            Self::End => SectionRank::End,
159        }
160    }
161
162    fn name(&self) -> &'static str {
163        match self {
164            Self::Name => "NAME",
165            Self::ObjSense => "OBJSENSE",
166            Self::Rows => "ROWS",
167            Self::Columns => "COLUMNS",
168            Self::Rhs => "RHS",
169            Self::Ranges => "RANGES",
170            Self::Bounds => "BOUNDS",
171            Self::QuadObj => "QUADOBJ",
172            Self::QMatrix => "QMATRIX",
173            Self::QcMatrix(_) => "QCMATRIX",
174            Self::QSec(_) => "QSECTION",
175            Self::Sos => "SOS",
176            Self::Indicators => "INDICATORS",
177            Self::End => "ENDATA",
178        }
179    }
180}
181
182struct Field<'a> {
183    text: &'a str,
184    column: usize,
185}
186
187struct ParsedMps {
188    name: String,
189    objective_row: Option<String>,
190    sense: Option<ObjectiveSense>,
191    legacy_sense: Option<ObjectiveSense>,
192    rows: Vec<ParsedRow>,
193    row_index: FxHashMap<String, usize>,
194    columns: Vec<ParsedColumn>,
195    column_index: FxHashMap<String, usize>,
196    objective_linear: Vec<f64>,
197    objective_quadratic: FxHashMap<(usize, usize), f64>,
198    quadratic_triangles: FxHashMap<(Option<usize>, usize, usize), QuadraticTriangle>,
199    objective_constant: f64,
200    intorg: bool,
201    rhs_vector: Option<String>,
202    range_vector: Option<String>,
203    bounds_vector: Option<String>,
204    objective_quadratic_source: Option<&'static str>,
205    quadratic_rows: HashSet<String>,
206    sos: Vec<ParsedSos>,
207    current_sos: Option<usize>,
208    indicators: Vec<(String, String, bool)>,
209    seen_sections: u8,
210}
211
212struct ParsedSos {
213    name: String,
214    sos_type: SosType,
215    members: Vec<(String, f64)>,
216}
217
218const MPS_INFINITY_SENTINEL: f64 = 1e30;
219const SEEN_ROWS: u8 = 1;
220const SEEN_COLUMNS: u8 = 2;
221const SEEN_END: u8 = 4;
222
223impl ParsedMps {
224    fn new(fallback_name: &str) -> Self {
225        Self {
226            name: fallback_name.to_owned(),
227            objective_row: None,
228            sense: None,
229            legacy_sense: None,
230            rows: Vec::new(),
231            row_index: FxHashMap::default(),
232            columns: Vec::new(),
233            column_index: FxHashMap::default(),
234            objective_linear: Vec::new(),
235            objective_quadratic: FxHashMap::default(),
236            quadratic_triangles: FxHashMap::default(),
237            objective_constant: 0.0,
238            intorg: false,
239            rhs_vector: None,
240            range_vector: None,
241            bounds_vector: None,
242            objective_quadratic_source: None,
243            quadratic_rows: HashSet::new(),
244            sos: Vec::new(),
245            current_sos: None,
246            indicators: Vec::new(),
247            seen_sections: 0,
248        }
249    }
250
251    fn add_column(&mut self, name: &str, inside_marker: bool) -> usize {
252        let index = if let Some(index) = self.column_index.get(name) {
253            *index
254        } else {
255            let index = self.columns.len();
256            self.columns.push(ParsedColumn {
257                name: name.to_owned(),
258                lower: 0.0,
259                upper: f64::INFINITY,
260                kind: ColumnKind::Continuous,
261                default_bounds: true,
262                lower_explicit: false,
263                marker_placement: MarkerPlacement::Unseen,
264            });
265            self.column_index.insert(name.to_owned(), index);
266            self.objective_linear.push(0.0);
267            index
268        };
269        let column = &mut self.columns[index];
270        if inside_marker {
271            column.marker_placement = match column.marker_placement {
272                MarkerPlacement::Unseen | MarkerPlacement::Inside => MarkerPlacement::Inside,
273                MarkerPlacement::Outside | MarkerPlacement::Mixed => MarkerPlacement::Mixed,
274            };
275            column.kind = ColumnKind::Integer;
276            if column.default_bounds {
277                column.upper = 1.0;
278            }
279        } else {
280            column.marker_placement = match column.marker_placement {
281                MarkerPlacement::Unseen | MarkerPlacement::Outside => MarkerPlacement::Outside,
282                MarkerPlacement::Inside | MarkerPlacement::Mixed => MarkerPlacement::Mixed,
283            };
284        }
285        index
286    }
287}
288
289fn invalid_mps(line: usize, column: usize, message: impl Into<String>) -> IoError {
290    IoError::InvalidMps { line, column, message: message.into() }
291}
292
293fn fields(line: &str) -> Vec<Field<'_>> {
294    let mut out = Vec::new();
295    let mut start = None;
296    for (offset, ch) in line.char_indices() {
297        if ch.is_whitespace() {
298            if let Some(begin) = start.take() {
299                out.push(Field { text: &line[begin..offset], column: begin + 1 });
300            }
301        } else if start.is_none() {
302            start = Some(offset);
303        }
304    }
305    if let Some(begin) = start {
306        out.push(Field { text: &line[begin..], column: begin + 1 });
307    }
308    out
309}
310
311fn parse_number(field: &Field<'_>, line: usize) -> Result<f64, IoError> {
312    let normalized;
313    let text = if field.text.contains(['d', 'D']) {
314        normalized = field.text.replace(['d', 'D'], "E");
315        normalized.as_str()
316    } else {
317        field.text
318    };
319    let value = text
320        .parse::<f64>()
321        .map_err(|_| invalid_mps(line, field.column, format!("invalid number {:?}", field.text)))?;
322    if !value.is_finite() {
323        return Err(invalid_mps(line, field.column, "numeric fields must be finite"));
324    }
325    Ok(value)
326}
327
328fn looks_like_number(text: &str) -> bool {
329    text.replace(['d', 'D'], "E").parse::<f64>().is_ok()
330}
331
332fn objective_sense(field: &Field<'_>, line: usize) -> Result<ObjectiveSense, IoError> {
333    match field.text.to_ascii_uppercase().as_str() {
334        "MIN" | "MINIMIZE" => Ok(ObjectiveSense::Minimize),
335        "MAX" | "MAXIMIZE" => Ok(ObjectiveSense::Maximize),
336        _ => Err(invalid_mps(line, field.column, "objective sense must be MIN or MAX")),
337    }
338}
339
340fn parse_legacy_sense(line: &str) -> Option<ObjectiveSense> {
341    let comment = line.trim_start_matches('*').trim();
342    let value = comment.strip_prefix("sense:").or_else(|| comment.strip_prefix("SENSE:"))?;
343    match value.trim().to_ascii_lowercase().as_str() {
344        "minimize" | "min" => Some(ObjectiveSense::Minimize),
345        "maximize" | "max" => Some(ObjectiveSense::Maximize),
346        _ => None,
347    }
348}
349
350fn header(items: &[Field<'_>], current: &Section) -> Option<Section> {
351    let first = items.first()?.text.to_ascii_uppercase();
352    match (first.as_str(), items.len()) {
353        ("NAME", _) if matches!(current, Section::Name) => Some(Section::Name),
354        ("OBJSENSE", 1 | 2) => Some(Section::ObjSense),
355        ("ROWS", 1) => Some(Section::Rows),
356        ("COLUMNS", 1) => Some(Section::Columns),
357        ("RHS", 1) => Some(Section::Rhs),
358        ("RANGES", 1) => Some(Section::Ranges),
359        ("BOUNDS", 1) => Some(Section::Bounds),
360        ("QUADOBJ", 1) => Some(Section::QuadObj),
361        ("QMATRIX", 1) => Some(Section::QMatrix),
362        ("QCMATRIX", 2) => Some(Section::QcMatrix(items[1].text.to_owned())),
363        ("QSECTION", 2) => Some(Section::QSec(items[1].text.to_owned())),
364        ("SOS", 1) => Some(Section::Sos),
365        ("INDICATORS", 1) => Some(Section::Indicators),
366        ("ENDATA", 1) => Some(Section::End),
367        _ => None,
368    }
369}
370
371fn select_vector(
372    selected: &mut Option<String>,
373    candidate: Option<&Field<'_>>,
374    section: &str,
375) -> Result<(), IoError> {
376    let Some(candidate) = candidate else { return Ok(()) };
377    if let Some(existing) = selected {
378        if existing != candidate.text {
379            return Err(IoError::UnsupportedMps {
380                section: section.into(),
381                feature: format!(
382                    "multiple data vectors ({existing:?} and {:?}) are not supported",
383                    candidate.text
384                ),
385            });
386        }
387    } else {
388        *selected = Some(candidate.text.to_owned());
389    }
390    Ok(())
391}
392
393fn parse_row(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
394    if items.len() < 2 {
395        return Err(invalid_mps(line, 1, "ROWS records require a sense and row name"));
396    }
397    let name = items[1].text;
398    if data.objective_row.as_deref() == Some(name) || data.row_index.contains_key(name) {
399        return Err(invalid_mps(line, items[1].column, format!("duplicate row name {name:?}")));
400    }
401    let kind = match items[0].text.to_ascii_uppercase().as_str() {
402        "N" => RowKind::Free,
403        "G" => RowKind::Greater,
404        "L" => RowKind::Less,
405        "E" => RowKind::Equal,
406        _ => {
407            return Err(invalid_mps(line, items[0].column, "row sense must be N, G, L, or E"));
408        }
409    };
410    if kind == RowKind::Free && data.objective_row.is_none() {
411        data.objective_row = Some(name.to_owned());
412        return Ok(());
413    }
414    let (lower, upper) = match kind {
415        RowKind::Free => (f64::NEG_INFINITY, f64::INFINITY),
416        RowKind::Greater => (0.0, f64::INFINITY),
417        RowKind::Less => (f64::NEG_INFINITY, 0.0),
418        RowKind::Equal => (0.0, 0.0),
419    };
420    let index = data.rows.len();
421    data.rows.push(ParsedRow {
422        name: name.to_owned(),
423        kind,
424        lower,
425        upper,
426        linear: FxHashMap::default(),
427        quadratic: FxHashMap::default(),
428    });
429    data.row_index.insert(name.to_owned(), index);
430    Ok(())
431}
432
433fn parse_coefficient(
434    data: &mut ParsedMps,
435    column: usize,
436    row: &Field<'_>,
437    value: &Field<'_>,
438    line: usize,
439) -> Result<(), IoError> {
440    let value = parse_number(value, line)?;
441    if data.objective_row.as_deref() == Some(row.text) {
442        data.objective_linear[column] += value;
443        return Ok(());
444    }
445    let row_index = data.row_index.get(row.text).copied().ok_or_else(|| {
446        invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
447    })?;
448    *data.rows[row_index].linear.entry(column).or_insert(0.0) += value;
449    Ok(())
450}
451
452fn unquote_marker(value: &str) -> String {
453    value.trim_matches(|c| c == '\'' || c == '"').to_ascii_uppercase()
454}
455
456fn parse_columns(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
457    if items.len() == 3 && unquote_marker(items[1].text) == "MARKER" {
458        match unquote_marker(items[2].text).as_str() {
459            "INTORG" if !data.intorg => data.intorg = true,
460            "INTEND" if data.intorg => data.intorg = false,
461            "INTORG" => return Err(invalid_mps(line, items[2].column, "nested INTORG marker")),
462            "INTEND" => {
463                return Err(invalid_mps(line, items[2].column, "INTEND without INTORG"));
464            }
465            _ => return Err(invalid_mps(line, items[2].column, "unknown MARKER value")),
466        }
467        return Ok(());
468    }
469    if items.len() != 3 && items.len() != 5 {
470        return Err(invalid_mps(line, 1, "COLUMNS records require three or five fields"));
471    }
472    let column = data.add_column(items[0].text, data.intorg);
473    let column_data = &data.columns[column];
474    if column_data.marker_placement == MarkerPlacement::Mixed {
475        return Err(invalid_mps(
476            line,
477            items[0].column,
478            format!("integer column {:?} also appears outside INTORG/INTEND", items[0].text),
479        ));
480    }
481    parse_coefficient(data, column, &items[1], &items[2], line)?;
482    if items.len() == 5 {
483        parse_coefficient(data, column, &items[3], &items[4], line)?;
484    }
485    Ok(())
486}
487
488fn parse_rhs_value(
489    data: &mut ParsedMps,
490    row: &Field<'_>,
491    value: &Field<'_>,
492    line: usize,
493) -> Result<(), IoError> {
494    let value = parse_number(value, line)?;
495    if data.objective_row.as_deref() == Some(row.text) {
496        data.objective_constant = -value;
497        return Ok(());
498    }
499    let index = data.row_index.get(row.text).copied().ok_or_else(|| {
500        invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
501    })?;
502    let parsed_row = &mut data.rows[index];
503    match parsed_row.kind {
504        RowKind::Greater => parsed_row.lower = value,
505        RowKind::Less => parsed_row.upper = value,
506        RowKind::Equal => {
507            parsed_row.lower = value;
508            parsed_row.upper = value;
509        }
510        RowKind::Free => {
511            return Err(invalid_mps(line, row.column, "a free N row cannot have an RHS"));
512        }
513    }
514    Ok(())
515}
516
517fn parse_rhs(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
518    let (vector, pairs): (Option<&Field<'_>>, &[Field<'_>]) = match items.len() {
519        2 | 4 => (None, items),
520        3 | 5 => (Some(&items[0]), &items[1..]),
521        _ => return Err(invalid_mps(line, 1, "RHS records require two to five fields")),
522    };
523    select_vector(&mut data.rhs_vector, vector, "RHS")?;
524    for pair in pairs.as_chunks::<2>().0 {
525        parse_rhs_value(data, &pair[0], &pair[1], line)?;
526    }
527    Ok(())
528}
529
530fn parse_range_value(
531    data: &mut ParsedMps,
532    row: &Field<'_>,
533    value: &Field<'_>,
534    line: usize,
535) -> Result<(), IoError> {
536    let value = parse_number(value, line)?;
537    let index = data.row_index.get(row.text).copied().ok_or_else(|| {
538        invalid_mps(line, row.column, format!("unknown ROWS name {:?}", row.text))
539    })?;
540    let parsed_row = &mut data.rows[index];
541    match parsed_row.kind {
542        RowKind::Greater => parsed_row.upper = parsed_row.lower + value.abs(),
543        RowKind::Less => parsed_row.lower = parsed_row.upper - value.abs(),
544        RowKind::Equal if value >= 0.0 => parsed_row.upper = parsed_row.lower + value,
545        RowKind::Equal => parsed_row.lower = parsed_row.upper + value,
546        RowKind::Free => {
547            return Err(invalid_mps(line, row.column, "a free N row cannot have a range"));
548        }
549    }
550    Ok(())
551}
552
553fn parse_ranges(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
554    let (vector, pairs): (Option<&Field<'_>>, &[Field<'_>]) = match items.len() {
555        2 | 4 => (None, items),
556        3 | 5 => (Some(&items[0]), &items[1..]),
557        _ => return Err(invalid_mps(line, 1, "RANGES records require two to five fields")),
558    };
559    select_vector(&mut data.range_vector, vector, "RANGES")?;
560    for pair in pairs.as_chunks::<2>().0 {
561        parse_range_value(data, &pair[0], &pair[1], line)?;
562    }
563    Ok(())
564}
565
566fn parse_bound(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
567    if !(2..=4).contains(&items.len()) {
568        return Err(invalid_mps(line, 1, "BOUNDS records require two to four fields"));
569    }
570    let bound_type = items[0].text.to_ascii_uppercase();
571    let requires_value =
572        matches!(bound_type.as_str(), "FX" | "UP" | "LO" | "LI" | "UI" | "SC" | "SI");
573    let (vector, column_field, value_field) = match (items.len(), requires_value) {
574        (2, _) => (None, &items[1], None),
575        (3, true) => (None, &items[1], Some(&items[2])),
576        (3, false) => (Some(&items[1]), &items[2], None),
577        (4, _) => (Some(&items[1]), &items[2], Some(&items[3])),
578        _ => unreachable!(),
579    };
580    select_vector(&mut data.bounds_vector, vector, "BOUNDS")?;
581    let column_index = data.column_index.get(column_field.text).copied().ok_or_else(|| {
582        invalid_mps(line, column_field.column, format!("unknown column {:?}", column_field.text))
583    })?;
584    let value = value_field
585        .map(|field| {
586            parse_number(field, line)
587                .map(|value| if value >= MPS_INFINITY_SENTINEL { f64::INFINITY } else { value })
588        })
589        .transpose()?;
590    let column = &mut data.columns[column_index];
591    if column.default_bounds && column.kind == ColumnKind::Integer {
592        column.upper = f64::INFINITY;
593    }
594    column.default_bounds = false;
595    match (bound_type.as_str(), value) {
596        ("PL", None) => column.upper = f64::INFINITY,
597        ("MI", None) => {
598            column.lower = f64::NEG_INFINITY;
599            column.lower_explicit = true;
600        }
601        ("FR", None | Some(_)) => {
602            column.lower = f64::NEG_INFINITY;
603            column.upper = f64::INFINITY;
604            column.lower_explicit = true;
605        }
606        ("BV", None | Some(_)) => {
607            column.lower = 0.0;
608            column.upper = 1.0;
609            column.kind = ColumnKind::Binary;
610            column.lower_explicit = true;
611        }
612        ("FX", Some(value)) => {
613            column.lower = value;
614            column.upper = value;
615            column.lower_explicit = true;
616        }
617        ("UP", Some(value)) => {
618            if value < 0.0 && !column.lower_explicit {
619                column.lower = f64::NEG_INFINITY;
620            }
621            column.upper = value;
622        }
623        ("LO", Some(value)) => {
624            column.lower = value;
625            column.lower_explicit = true;
626        }
627        ("LI", Some(value)) => {
628            column.lower = value;
629            column.kind = ColumnKind::Integer;
630            column.lower_explicit = true;
631        }
632        ("UI", Some(value)) => {
633            column.upper = value;
634            column.kind = ColumnKind::Integer;
635        }
636        ("SC", Some(value)) => {
637            if !column.lower_explicit {
638                column.lower = 1.0;
639            }
640            column.upper = value;
641            column.kind = ColumnKind::SemiContinuous;
642        }
643        ("SI", Some(value)) => {
644            if !column.lower_explicit {
645                column.lower = 1.0;
646            }
647            column.upper = value;
648            column.kind = ColumnKind::SemiInteger;
649        }
650        _ => {
651            return Err(invalid_mps(
652                line,
653                items[0].column,
654                format!("invalid {bound_type} bound record"),
655            ));
656        }
657    }
658    Ok(())
659}
660
661fn quadratic_coefficient(
662    format: MpsQuadraticFormat,
663    diagonal: bool,
664    objective: bool,
665    value: f64,
666) -> f64 {
667    if objective || format != MpsQuadraticFormat::Gurobi {
668        if diagonal { value / 2.0 } else { value }
669    } else if diagonal {
670        value
671    } else {
672        2.0 * value
673    }
674}
675
676fn qsection_targets_objective(data: &ParsedMps, name: &str, line: usize) -> Result<bool, IoError> {
677    if name == "OBJ" {
678        if data.objective_row.as_deref() != Some("OBJ") && data.row_index.contains_key("OBJ") {
679            return Err(invalid_mps(
680                line,
681                1,
682                "QSECTION OBJ is ambiguous because OBJ is also a constraint row",
683            ));
684        }
685        return Ok(true);
686    }
687    Ok(data.objective_row.as_deref() == Some(name))
688}
689
690fn parse_quadratic_record(
691    data: &mut ParsedMps,
692    section: &Section,
693    items: &[Field<'_>],
694    line: usize,
695    options: MpsReadOptions,
696) -> Result<(), IoError> {
697    if items.len() != 3 {
698        return Err(invalid_mps(line, 1, "quadratic records require three fields"));
699    }
700    let left = data.column_index.get(items[0].text).copied().ok_or_else(|| {
701        invalid_mps(line, items[0].column, format!("unknown column {:?}", items[0].text))
702    })?;
703    let right = data.column_index.get(items[1].text).copied().ok_or_else(|| {
704        invalid_mps(line, items[1].column, format!("unknown column {:?}", items[1].text))
705    })?;
706    let pair = if left <= right { (left, right) } else { (right, left) };
707    let value = parse_number(&items[2], line)?;
708    let objective = match section {
709        Section::QuadObj | Section::QMatrix => true,
710        Section::QSec(name) => qsection_targets_objective(data, name, line)?,
711        Section::QcMatrix(_) => false,
712        _ => unreachable!("quadratic parser called outside quadratic section"),
713    };
714    let row = if objective {
715        None
716    } else {
717        let (Section::QcMatrix(row_name) | Section::QSec(row_name)) = section else {
718            unreachable!()
719        };
720        Some(data.row_index.get(row_name).copied().ok_or_else(|| {
721            invalid_mps(line, 1, format!("quadratic section names unknown row {row_name:?}"))
722        })?)
723    };
724    let coefficient =
725        quadratic_coefficient(options.quadratic_format, left == right, objective, value);
726    let mut store_coefficient = true;
727    if matches!(section, Section::QMatrix | Section::QcMatrix(_)) && left != right {
728        let triangle = data.quadratic_triangles.entry((row, pair.0, pair.1)).or_default();
729        let first_record = triangle.upper.is_none() && triangle.lower.is_none();
730        let side = if left < right { &mut triangle.upper } else { &mut triangle.lower };
731        let same_side = side.is_some();
732        *side = Some(side.take().unwrap_or(0.0) + coefficient);
733        store_coefficient = first_record || same_side;
734    }
735    if !store_coefficient {
736        return Ok(());
737    }
738    if objective {
739        *data.objective_quadratic.entry(pair).or_insert(0.0) += coefficient;
740        return Ok(());
741    }
742    *data.rows[row.expect("quadratic constraint row")].quadratic.entry(pair).or_insert(0.0) +=
743        coefficient;
744    Ok(())
745}
746
747fn validate_quadratic_triangles(data: &ParsedMps, line: usize) -> Result<(), IoError> {
748    for ((_, left, right), triangle) in &data.quadratic_triangles {
749        if let (Some(upper), Some(lower)) = (triangle.upper, triangle.lower)
750            && upper.total_cmp(&lower).is_ne()
751        {
752            return Err(invalid_mps(
753                line,
754                1,
755                format!(
756                    "asymmetric quadratic matrix entries for columns {:?} and {:?}",
757                    data.columns[*left].name, data.columns[*right].name
758                ),
759            ));
760        }
761    }
762    Ok(())
763}
764
765fn begin_quadratic_section(
766    data: &mut ParsedMps,
767    section: &Section,
768    line: usize,
769) -> Result<(), IoError> {
770    let source = section.name();
771    let objective = match section {
772        Section::QuadObj | Section::QMatrix => true,
773        Section::QSec(name) => qsection_targets_objective(data, name, line)?,
774        Section::QcMatrix(_) => false,
775        _ => return Ok(()),
776    };
777    if objective {
778        if let Some(existing) = data.objective_quadratic_source {
779            return Err(invalid_mps(
780                line,
781                1,
782                format!("objective quadratic data already supplied by {existing}"),
783            ));
784        }
785        data.objective_quadratic_source = Some(source);
786        return Ok(());
787    }
788    let (Section::QcMatrix(row_name) | Section::QSec(row_name)) = section else { unreachable!() };
789    if !data.row_index.contains_key(row_name) {
790        return Err(invalid_mps(
791            line,
792            1,
793            format!("quadratic section names unknown row {row_name:?}"),
794        ));
795    }
796    if !data.quadratic_rows.insert(row_name.clone()) {
797        return Err(invalid_mps(
798            line,
799            1,
800            format!("duplicate quadratic section for row {row_name:?}"),
801        ));
802    }
803    Ok(())
804}
805
806fn check_section_transition(
807    previous: &Section,
808    next: &Section,
809    line: usize,
810) -> Result<(), IoError> {
811    let quadratic_after_sos = matches!(previous, Section::Sos)
812        && matches!(
813            next,
814            Section::QuadObj | Section::QMatrix | Section::QcMatrix(_) | Section::QSec(_)
815        );
816    if next.rank() < previous.rank() && !quadratic_after_sos {
817        return Err(invalid_mps(
818            line,
819            1,
820            format!("{} section appears after {}", next.name(), previous.name()),
821        ));
822    }
823    if next.rank() == previous.rank()
824        && !matches!(next.rank(), SectionRank::Quadratic)
825        && !matches!((previous, next), (Section::Name, Section::Name))
826    {
827        return Err(invalid_mps(line, 1, format!("duplicate {} section", next.name())));
828    }
829    Ok(())
830}
831
832fn parse_mps_line(
833    data: &mut ParsedMps,
834    section: &mut Section,
835    saw_name: &mut bool,
836    line: &str,
837    line_no: usize,
838    options: MpsReadOptions,
839) -> Result<(), IoError> {
840    let trimmed = line.trim();
841    if trimmed.is_empty() {
842        return Ok(());
843    }
844    if trimmed.starts_with('*') {
845        if data.sense.is_none() {
846            data.legacy_sense = parse_legacy_sense(trimmed).or(data.legacy_sense);
847        }
848        return Ok(());
849    }
850    if data.seen_sections & SEEN_END != 0 {
851        return Err(invalid_mps(line_no, 1, "content after ENDATA"));
852    }
853    let items = fields(line);
854    if let Some(next) = header(&items, section) {
855        if matches!(next, Section::Name) {
856            if *saw_name {
857                return Err(invalid_mps(line_no, 1, "duplicate NAME section"));
858            }
859            *saw_name = true;
860            if items.len() > 1 {
861                data.name = items[1..].iter().map(|field| field.text).collect::<Vec<_>>().join(" ");
862            }
863            return Ok(());
864        }
865        if !*saw_name {
866            return Err(invalid_mps(line_no, 1, "the first data line must be NAME"));
867        }
868        check_section_transition(section, &next, line_no)?;
869        if data.intorg && !matches!(next, Section::Columns) {
870            return Err(invalid_mps(line_no, 1, "missing INTEND marker before COLUMNS ends"));
871        }
872        match &next {
873            Section::ObjSense if items.len() == 2 => {
874                data.sense = Some(objective_sense(&items[1], line_no)?);
875            }
876            Section::Rows => data.seen_sections |= SEEN_ROWS,
877            Section::Columns => data.seen_sections |= SEEN_COLUMNS,
878            Section::QuadObj | Section::QMatrix | Section::QcMatrix(_) | Section::QSec(_) => {
879                begin_quadratic_section(data, &next, line_no)?;
880            }
881            Section::Sos => data.current_sos = None,
882            Section::End => data.seen_sections |= SEEN_END,
883            _ => {}
884        }
885        *section = next;
886        return Ok(());
887    }
888    if !*saw_name {
889        return Err(invalid_mps(line_no, 1, "the first data line must be NAME"));
890    }
891    match section {
892        Section::ObjSense => {
893            if items.len() != 1 {
894                return Err(invalid_mps(line_no, 1, "OBJSENSE data requires one field"));
895            }
896            data.sense = Some(objective_sense(&items[0], line_no)?);
897        }
898        Section::Rows => parse_row(data, &items, line_no)?,
899        Section::Columns => parse_columns(data, &items, line_no)?,
900        Section::Rhs => parse_rhs(data, &items, line_no)?,
901        Section::Ranges => parse_ranges(data, &items, line_no)?,
902        Section::Bounds => parse_bound(data, &items, line_no)?,
903        Section::QuadObj | Section::QMatrix | Section::QcMatrix(_) | Section::QSec(_) => {
904            parse_quadratic_record(data, section, &items, line_no, options)?;
905        }
906        Section::Sos => parse_sos_record(data, &items, line_no)?,
907        Section::Indicators => parse_indicator_record(data, &items, line_no)?,
908        Section::Name => return Err(invalid_mps(line_no, 1, "expected NAME header")),
909        Section::End => unreachable!(),
910    }
911    Ok(())
912}
913
914fn parse_indicator_record(
915    data: &mut ParsedMps,
916    items: &[Field<'_>],
917    line: usize,
918) -> Result<(), IoError> {
919    if items.len() != 4 || !items[0].text.eq_ignore_ascii_case("IF") {
920        return Err(invalid_mps(line, 1, "indicator record must be `IF row binary 0|1`"));
921    }
922    let active = match items[3].text {
923        "0" => false,
924        "1" => true,
925        _ => return Err(invalid_mps(line, items[3].column, "indicator value must be 0 or 1")),
926    };
927    data.indicators.push((items[1].text.to_owned(), items[2].text.to_owned(), active));
928    Ok(())
929}
930
931fn parse_sos_record(data: &mut ParsedMps, items: &[Field<'_>], line: usize) -> Result<(), IoError> {
932    // Headers carry a set name, while members carry a numeric weight.
933    // A variable named S1 or S2 must therefore follow the member path.
934    let is_header = matches!(items.first().map(|item| item.text.to_ascii_uppercase()), Some(ty) if matches!(ty.as_str(), "S1" | "S2"))
935        && (items.len() != 2 || !looks_like_number(items[1].text));
936    if is_header {
937        if items.len() != 2 {
938            return Err(invalid_mps(line, 1, "SOS set headers require type and name"));
939        }
940        let sos_type = match items[0].text.to_ascii_uppercase().as_str() {
941            "S1" => SosType::Sos1,
942            _ => SosType::Sos2,
943        };
944        if items[1].text.is_empty() {
945            return Err(invalid_mps(line, items[1].column, "SOS set needs a name"));
946        }
947        data.sos.push(ParsedSos { name: items[1].text.to_owned(), sos_type, members: Vec::new() });
948        data.current_sos = Some(data.sos.len() - 1);
949        return Ok(());
950    }
951    let Some(index) = data.current_sos else {
952        return Err(invalid_mps(line, 1, "SOS member appears before an S1/S2 header"));
953    };
954    if items.len() != 2 {
955        return Err(invalid_mps(line, 1, "SOS member needs variable name and weight"));
956    }
957    let weight = parse_number(&items[1], line)?;
958    data.sos[index].members.push((items[0].text.to_owned(), weight));
959    Ok(())
960}
961
962fn parse_mps<R: BufRead>(
963    mut input: R,
964    fallback_name: &str,
965    options: MpsReadOptions,
966) -> Result<Model, IoError> {
967    let mut data = ParsedMps::new(fallback_name);
968    let mut section = Section::Name;
969    let mut saw_name = false;
970    let mut line_buffer = String::new();
971    let mut last_line = 0;
972    loop {
973        line_buffer.clear();
974        if input.read_line(&mut line_buffer)? == 0 {
975            break;
976        }
977        last_line += 1;
978        let line = line_buffer.trim_end_matches(['\r', '\n']);
979        parse_mps_line(&mut data, &mut section, &mut saw_name, line, last_line, options)?;
980    }
981    let last_line = last_line.max(1);
982    if data.intorg {
983        return Err(invalid_mps(last_line, 1, "missing INTEND marker"));
984    }
985    if data.seen_sections & SEEN_ROWS == 0 {
986        return Err(invalid_mps(last_line, 1, "missing ROWS section"));
987    }
988    if data.seen_sections & SEEN_COLUMNS == 0 {
989        return Err(invalid_mps(last_line, 1, "missing COLUMNS section"));
990    }
991    if data.seen_sections & SEEN_END == 0 {
992        return Err(invalid_mps(last_line, 1, "missing ENDATA"));
993    }
994    validate_quadratic_triangles(&data, last_line)?;
995    build_mps_model(data)
996}
997
998fn expression<'a>(
999    model: &'a Model,
1000    variables: &[Expr<'a>],
1001    linear: impl IntoIterator<Item = (usize, f64)>,
1002    quadratic: impl IntoIterator<Item = ((usize, usize), f64)>,
1003    constant: f64,
1004) -> Expr<'a> {
1005    let mut expr = model.__constant(constant);
1006    for (column, coefficient) in linear {
1007        if coefficient != 0.0 {
1008            expr = expr + coefficient * variables[column];
1009        }
1010    }
1011    let mut quadratic: Vec<_> =
1012        quadratic.into_iter().filter(|(_, coefficient)| *coefficient != 0.0).collect();
1013    quadratic.sort_unstable_by_key(|((left, right), _)| (*left, *right));
1014    for ((left, right), coefficient) in quadratic {
1015        expr = expr + coefficient * variables[left] * variables[right];
1016    }
1017    expr
1018}
1019
1020fn unique_mps_names<'a>(
1021    names: impl IntoIterator<Item = &'a str>,
1022    fallback_prefix: &str,
1023    reserved: impl IntoIterator<Item = &'a str>,
1024) -> Vec<String> {
1025    let mut used: HashSet<String> = reserved.into_iter().map(str::to_owned).collect();
1026    names
1027        .into_iter()
1028        .enumerate()
1029        .map(|(index, name)| {
1030            let base: String = name
1031                .chars()
1032                .map(|ch| {
1033                    if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-') {
1034                        ch
1035                    } else {
1036                        '_'
1037                    }
1038                })
1039                .collect();
1040            let base =
1041                if base.is_empty() { format!("{fallback_prefix}{}", index + 1) } else { base };
1042            let mut candidate = base.clone();
1043            let mut suffix = 1;
1044            while used.contains(&candidate) {
1045                candidate = format!("{base}_{suffix}");
1046                suffix += 1;
1047            }
1048            used.insert(candidate.clone());
1049            candidate
1050        })
1051        .collect()
1052}
1053
1054fn write_quadratic_section<W: Write>(
1055    out: &mut W,
1056    header: &str,
1057    terms: &QuadraticTerms,
1058    variable_names: &[String],
1059    format: MpsQuadraticFormat,
1060    objective: bool,
1061) -> Result<(), IoError> {
1062    writeln!(out, "{header}")?;
1063    for &(left, right, hessian) in &terms.hessian {
1064        let (first, second) = if objective {
1065            // Gurobi and MOSEK document lower-triangular objective records.
1066            // CPLEX's QUADOBJ convention uses the upper triangle.
1067            if format == MpsQuadraticFormat::Cplex && left.index() > right.index() {
1068                (right, left)
1069            } else {
1070                (left, right)
1071            }
1072        } else if left.index() <= right.index() {
1073            (left, right)
1074        } else {
1075            (right, left)
1076        };
1077        let left_name = &variable_names[first.index()];
1078        let right_name = &variable_names[second.index()];
1079        let coefficient =
1080            if objective || format != MpsQuadraticFormat::Gurobi { hessian } else { hessian / 2.0 };
1081        writeln!(out, "    {left_name:<10} {right_name:<10} {coefficient}")?;
1082        if !objective && format != MpsQuadraticFormat::Mosek && first != second {
1083            // Gurobi and CPLEX require QCMATRIX to contain a symmetric Q.
1084            writeln!(out, "    {right_name:<10} {left_name:<10} {coefficient}")?;
1085        }
1086    }
1087    Ok(())
1088}
1089
1090fn write_sos_section<W: Write>(
1091    out: &mut W,
1092    constraints: &[SosConstraint],
1093    variable_names: &[String],
1094) -> Result<(), IoError> {
1095    writeln!(out, "SOS")?;
1096    let active = || constraints.iter().filter(|constraint| constraint.active);
1097    let sos_names =
1098        unique_mps_names(active().map(|s| s.name.as_str()), "S", std::iter::empty::<&str>());
1099    for (set, set_name) in active().zip(sos_names.iter()) {
1100        writeln!(
1101            out,
1102            " S{} {}",
1103            match set.sos_type {
1104                SosType::Sos1 => 1,
1105                SosType::Sos2 => 2,
1106            },
1107            set_name
1108        )?;
1109        for member in &set.members {
1110            writeln!(out, "    {:<10} {}", variable_names[member.variable.index()], member.weight)?;
1111        }
1112    }
1113    Ok(())
1114}
1115
1116#[expect(clippy::too_many_lines)]
1117fn build_mps_model(data: ParsedMps) -> Result<Model, IoError> {
1118    for column in &data.columns {
1119        if column.lower > column.upper {
1120            return Err(invalid_mps(
1121                1,
1122                1,
1123                format!("inconsistent bounds for column {:?}", column.name),
1124            ));
1125        }
1126        if matches!(column.kind, ColumnKind::SemiContinuous | ColumnKind::SemiInteger)
1127            && (!column.lower.is_finite() || column.lower < 0.0)
1128        {
1129            return Err(invalid_mps(
1130                1,
1131                1,
1132                format!("invalid semi-domain threshold for column {:?}", column.name),
1133            ));
1134        }
1135    }
1136    for row in &data.rows {
1137        if row.lower > row.upper {
1138            return Err(invalid_mps(1, 1, format!("inconsistent range for row {:?}", row.name)));
1139        }
1140    }
1141    let mut indicator_rows: FxHashMap<usize, (usize, bool)> = FxHashMap::default();
1142    for (row_name, trigger_name, active) in &data.indicators {
1143        let row = *data.row_index.get(row_name).ok_or_else(|| {
1144            invalid_mps(1, 1, format!("indicator references unknown row {row_name:?}"))
1145        })?;
1146        let trigger = *data.column_index.get(trigger_name).ok_or_else(|| {
1147            invalid_mps(1, 1, format!("indicator references unknown variable {trigger_name:?}"))
1148        })?;
1149        if data.columns[trigger].kind != ColumnKind::Binary {
1150            return Err(invalid_mps(
1151                1,
1152                1,
1153                format!("indicator trigger {trigger_name:?} is not binary"),
1154            ));
1155        }
1156        let body = &data.rows[row];
1157        if body.lower.is_finite()
1158            && body.upper.is_finite()
1159            && !body.lower.total_cmp(&body.upper).is_eq()
1160        {
1161            return Err(IoError::UnsupportedMps {
1162                section: "INDICATORS".into(),
1163                feature: "ranged indicator row".into(),
1164            });
1165        }
1166        if !body.quadratic.is_empty() {
1167            return Err(IoError::UnsupportedMps {
1168                section: "INDICATORS".into(),
1169                feature: "quadratic indicator row".into(),
1170            });
1171        }
1172        if indicator_rows.insert(row, (trigger, *active)).is_some() {
1173            return Err(invalid_mps(1, 1, format!("row {row_name:?} has multiple indicators")));
1174        }
1175    }
1176    let mut sos_names = HashSet::new();
1177    for set in &data.sos {
1178        if !sos_names.insert(set.name.clone()) {
1179            return Err(invalid_mps(1, 1, format!("duplicate SOS name {:?}", set.name)));
1180        }
1181        if set.members.is_empty() {
1182            return Err(invalid_mps(1, 1, format!("SOS {:?} has no members", set.name)));
1183        }
1184        let mut members = HashSet::new();
1185        let mut weights = Vec::new();
1186        for (var, weight) in &set.members {
1187            if !data.column_index.contains_key(var) {
1188                return Err(invalid_mps(1, 1, format!("unknown SOS variable {var:?}")));
1189            }
1190            if !members.insert(var) || weights.contains(weight) {
1191                return Err(invalid_mps(
1192                    1,
1193                    1,
1194                    format!("duplicate SOS member or weight in {:?}", set.name),
1195                ));
1196            }
1197            weights.push(*weight);
1198        }
1199    }
1200    let model = Model::new(data.name);
1201    let mut variables = Vec::with_capacity(data.columns.len());
1202    for column in &data.columns {
1203        let domain = match column.kind {
1204            ColumnKind::Continuous => Domain::Real,
1205            ColumnKind::Integer => Domain::Integer,
1206            ColumnKind::Binary => Domain::Binary,
1207            ColumnKind::SemiContinuous => Domain::SemiContinuous { threshold: column.lower },
1208            ColumnKind::SemiInteger => Domain::SemiInteger { threshold: column.lower },
1209        };
1210        let model_lower = match column.kind {
1211            ColumnKind::SemiContinuous | ColumnKind::SemiInteger => 0.0,
1212            _ => column.lower,
1213        };
1214        variables.push(
1215            model
1216                .__var(column.name.clone())
1217                .bounds(model_lower, column.upper)
1218                .domain(domain)
1219                .build(),
1220        );
1221    }
1222    let mut pending_indicators = Vec::new();
1223    for (row_index, row) in data.rows.into_iter().enumerate() {
1224        let expr = expression(&model, &variables, row.linear, row.quadratic, 0.0);
1225        if let Some((trigger, active)) = indicator_rows.get(&row_index).copied() {
1226            pending_indicators.push((row.name, trigger, active, expr, row.lower, row.upper));
1227        } else {
1228            model.__add_constraint_interval(row.name, expr, row.lower, row.upper);
1229        }
1230    }
1231    for (name, trigger, active, expr, lower, upper) in pending_indicators {
1232        model.__add_indicator_interval(name, variables[trigger], active, expr, lower, upper);
1233    }
1234    for set in data.sos {
1235        let members = set.members.into_iter().map(|(name, weight)| {
1236            let index = *data.column_index.get(&name).expect("validated SOS variable");
1237            (variables[index], weight)
1238        });
1239        model.add_sos_constraint(set.name, set.sos_type, members);
1240    }
1241    let objective = expression(
1242        &model,
1243        &variables,
1244        data.objective_linear.into_iter().enumerate(),
1245        data.objective_quadratic,
1246        data.objective_constant,
1247    );
1248    match data.sense.or(data.legacy_sense).unwrap_or(ObjectiveSense::Minimize) {
1249        ObjectiveSense::Minimize => model.__minimize(objective),
1250        ObjectiveSense::Maximize => model.__maximize(objective),
1251    }
1252    Ok(model)
1253}
1254
1255/// Read an MPS byte stream with default options.
1256///
1257/// # Errors
1258///
1259/// Returns [`IoError`] for I/O failures, malformed syntax, or unsupported sections.
1260pub fn read_mps<R: Read>(input: R) -> Result<Model, IoError> {
1261    read_mps_with(input, &MpsReadOptions::default())
1262}
1263
1264/// Read an MPS byte stream with explicit import options.
1265///
1266/// # Errors
1267///
1268/// Returns [`IoError`] for I/O failures, malformed syntax, or unsupported sections.
1269pub fn read_mps_with<R: Read>(input: R, options: &MpsReadOptions) -> Result<Model, IoError> {
1270    parse_mps(BufReader::new(input), "mps_model", *options)
1271}
1272
1273/// Read an MPS file with default options.
1274///
1275/// # Errors
1276///
1277/// Returns [`IoError`] for I/O failures, malformed syntax, or unsupported sections.
1278pub fn read_mps_file(path: impl AsRef<Path>) -> Result<Model, IoError> {
1279    read_mps_file_with(path, &MpsReadOptions::default())
1280}
1281
1282/// Read an MPS file with explicit import options.
1283///
1284/// # Errors
1285///
1286/// Returns [`IoError`] for I/O failures, malformed syntax, or unsupported sections.
1287pub fn read_mps_file_with(
1288    path: impl AsRef<Path>,
1289    options: &MpsReadOptions,
1290) -> Result<Model, IoError> {
1291    let path = path.as_ref();
1292    let fallback = path.file_stem().and_then(|name| name.to_str()).unwrap_or("mps_model");
1293    parse_mps(BufReader::new(File::open(path)?), fallback, *options)
1294}
1295
1296/// Write `model` to `out` in fixed-format MPS.
1297///
1298/// MPS represents linear and quadratic LP/MILP/QP/QCP models. Higher-degree
1299/// or otherwise nonlinear expressions in the objective or constraints raise
1300/// [`IoError::Nonlinear`]. Second-order cone constraints raise [`IoError::Conic`].
1301/// The objective row is named `OBJ`.
1302/// Variable and constraint names have whitespace replaced by underscores and
1303/// are made unique within their respective MPS namespaces. The generated
1304/// objective row reserves the name `OBJ`. A feasibility model
1305/// (`objective!(m, Feasibility)`) leaves that row without coefficients and
1306/// declares `OBJSENSE MIN`.
1307///
1308/// # Errors
1309///
1310/// Returns [`IoError`] if there is an error writing the MPS data or if the model contains unsupported features.
1311///
1312pub fn write_mps<W: Write>(model: &Model, out: &mut W) -> Result<(), IoError> {
1313    write_mps_with(model, out, &MpsWriteOptions::default())
1314}
1315
1316/// Write `model` to `out` with explicit quadratic MPS options.
1317///
1318/// `Gurobi` and `Cplex` emit `QUADOBJ` plus `QCMATRIX` sections. `Mosek` emits
1319/// `QSECTION` sections for the objective and quadratic constraints.
1320///
1321/// # Errors
1322///
1323/// Returns [`IoError`] if the model contains unsupported expressions or writing
1324/// the output fails.
1325#[expect(clippy::too_many_lines)]
1326pub fn write_mps_with<W: Write>(
1327    model: &Model,
1328    out: &mut W,
1329    options: &MpsWriteOptions,
1330) -> Result<(), IoError> {
1331    if model.num_soc_constraints() > 0
1332        || matches!(model.kind(), ModelKind::SOCP | ModelKind::MISOCP)
1333    {
1334        return Err(IoError::Conic);
1335    }
1336    if model.has_active_indicator_constraints() {
1337        return write_mps_with_indicators(model, out, *options);
1338    }
1339    if model.has_active_sos_constraints() && options.quadratic_format == MpsQuadraticFormat::Mosek {
1340        return Err(IoError::UnsupportedMps {
1341            section: "SOS".into(),
1342            feature: "SOS is not supported by the MOSEK MPS dialect".into(),
1343        });
1344    }
1345    let arena = model.arena();
1346    let vars = model.variables();
1347    let model_constraints = model.constraints();
1348    let constraints = model_constraints.algebraic();
1349    let (obj_sense, obj_terms) = crate::objective::export_terms(model, &arena, &vars)?;
1350    let variable_names = unique_mps_names(vars.iter().map(|v| v.name.as_str()), "C", []);
1351    let row_names = unique_mps_names(constraints.iter().map(|c| c.name.as_str()), "R", ["OBJ"]);
1352
1353    // Pre-compute quadratic terms once, reused for COLUMNS, RHS, and quadratic sections.
1354    let con_terms: Vec<QuadraticTerms> = constraints
1355        .iter()
1356        .map(|c| {
1357            extract_quadratic(&arena, c.lhs).ok_or_else(|| IoError::Nonlinear {
1358                location: format!("constraint {:?}", c.name),
1359                term: describe_nonlinear_term(&arena, c.lhs, &|v| var_name(&vars, v))
1360                    .unwrap_or_else(|| "<nonlinear>".into()),
1361            })
1362        })
1363        .collect::<Result<_, _>>()?;
1364
1365    // Build column index: VarId to [(row_name, coef)] in row order (OBJ first, then constraints).
1366    let mut col_index: FxHashMap<VarId, Vec<(&str, f64)>> = FxHashMap::default();
1367    for (v, c) in &obj_terms.linear {
1368        col_index.entry(*v).or_default().push(("OBJ", *c));
1369    }
1370    for (row_name, terms) in row_names.iter().zip(con_terms.iter()) {
1371        for (v, coef) in &terms.linear {
1372            col_index.entry(*v).or_default().push((row_name.as_str(), *coef));
1373        }
1374    }
1375
1376    writeln!(out, "* OXIMO MPS export")?;
1377    writeln!(
1378        out,
1379        "* sense: {}",
1380        match obj_sense {
1381            ObjectiveSense::Minimize => "minimize",
1382            ObjectiveSense::Maximize => "maximize",
1383        }
1384    )?;
1385    writeln!(out, "NAME          {}", model.name)?;
1386    writeln!(out, "OBJSENSE")?;
1387    writeln!(
1388        out,
1389        " {}",
1390        match obj_sense {
1391            ObjectiveSense::Minimize => "MIN",
1392            ObjectiveSense::Maximize => "MAX",
1393        }
1394    )?;
1395
1396    writeln!(out, "ROWS")?;
1397    writeln!(out, " N  OBJ")?;
1398    for (c, row_name) in constraints.iter().zip(row_names.iter()) {
1399        let tag = match c.as_single() {
1400            Some((Sense::Le, _)) => 'L',
1401            Some((Sense::Ge, _)) => 'G',
1402            Some((Sense::Eq, _)) => 'E',
1403            // A two-sided range is an `L` row bounded by the `RANGES` section below.
1404            None if c.is_range() => 'L',
1405            // A free `[-inf, +inf]` row imposes nothing: emit an unconstraining
1406            // `N` row (no RHS) rather than an `L` row with a `+inf` bound.
1407            None => 'N',
1408        };
1409        writeln!(out, " {tag}  {row_name}")?;
1410    }
1411
1412    writeln!(out, "COLUMNS")?;
1413    let mut int_open = false;
1414    for (v, column_name) in vars.iter().zip(variable_names.iter()) {
1415        // Binary and semi-integer columns carry their integrality via bounds.
1416        let needs_marker = matches!(v.domain, Domain::Integer);
1417        if needs_marker && !int_open {
1418            writeln!(out, "    MARKER                 'MARKER'                 'INTORG'")?;
1419            int_open = true;
1420        } else if !needs_marker && int_open {
1421            writeln!(out, "    MARKER                 'MARKER'                 'INTEND'")?;
1422            int_open = false;
1423        }
1424        if let Some(entries) = col_index.get(&v.id) {
1425            for (row_name, coef) in entries {
1426                writeln!(out, "    {column_name:<10} {row_name:<10} {coef}")?;
1427            }
1428        } else {
1429            writeln!(out, "    {column_name:<10} {:<10} 0", "OBJ")?;
1430        }
1431    }
1432    if int_open {
1433        writeln!(out, "    MARKER                 'MARKER'                 'INTEND'")?;
1434    }
1435
1436    writeln!(out, "RHS")?;
1437    let obj_constant = obj_terms.constant;
1438    if obj_constant != 0.0 {
1439        writeln!(out, "    RHS       OBJ       {}", -obj_constant)?;
1440    }
1441    for ((c, row_name), t) in constraints.iter().zip(row_names.iter()).zip(con_terms.iter()) {
1442        // A range row's RHS is its upper bound (it is an `L` row), the `RANGES`
1443        // section then widens it down to the lower bound.
1444        let rhs = match c.as_single() {
1445            Some((_, rhs)) => rhs,
1446            None if c.is_range() => c.upper,
1447            // Free `N` row: carries no RHS.
1448            None => continue,
1449        };
1450        let adjusted = rhs - t.constant;
1451        if adjusted != 0.0 {
1452            writeln!(out, "    RHS       {row_name:<10} {adjusted}")?;
1453        }
1454    }
1455
1456    if constraints.iter().any(Constraint::is_range) {
1457        writeln!(out, "RANGES")?;
1458        for (c, row_name) in constraints.iter().zip(row_names.iter()) {
1459            if c.is_range() {
1460                writeln!(out, "    RNG       {row_name:<10} {}", c.upper - c.lower)?;
1461            }
1462        }
1463    }
1464
1465    writeln!(out, "BOUNDS")?;
1466    for (v, column_name) in vars.iter().zip(variable_names.iter()) {
1467        let lb = v.lb;
1468        let ub = v.ub;
1469        if matches!(v.domain, Domain::Binary) {
1470            writeln!(out, " BV BND       {column_name}")?;
1471            if lb != 0.0 {
1472                writeln!(out, " LO BND       {column_name:<10} {lb}")?;
1473            }
1474            if (ub - 1.0).abs() >= f64::EPSILON {
1475                writeln!(out, " UP BND       {column_name:<10} {ub}")?;
1476            }
1477            continue;
1478        }
1479        if let Some(thr) = v.domain.semi_threshold() {
1480            writeln!(out, " LO BND       {column_name:<10} {thr}")?;
1481            let semi_ub = if ub.is_finite() { ub } else { MPS_INFINITY_SENTINEL };
1482            // `is_integer()` distinguishes the two semi domains here.
1483            let code = if v.domain.is_integer() { "SI" } else { "SC" };
1484            writeln!(out, " {code} BND       {column_name:<10} {semi_ub}")?;
1485            continue;
1486        }
1487        if lb.is_finite() && (lb - ub).abs() < f64::EPSILON {
1488            writeln!(out, " FX BND       {column_name:<10} {lb}")?;
1489            continue;
1490        }
1491        let infinite_lo = lb == f64::NEG_INFINITY;
1492        let infinite_hi = ub == f64::INFINITY;
1493        match (infinite_lo, infinite_hi) {
1494            (true, true) => writeln!(out, " FR BND       {column_name}")?,
1495            (true, false) => {
1496                writeln!(out, " MI BND       {column_name}")?;
1497                writeln!(out, " UP BND       {column_name:<10} {ub}")?;
1498            }
1499            (false, true) => {
1500                if lb != 0.0 {
1501                    writeln!(out, " LO BND       {column_name:<10} {lb}")?;
1502                }
1503            }
1504            (false, false) => {
1505                if lb != 0.0 {
1506                    writeln!(out, " LO BND       {column_name:<10} {lb}")?;
1507                }
1508                writeln!(out, " UP BND       {column_name:<10} {ub}")?;
1509            }
1510        }
1511    }
1512
1513    let sos = model.sos_constraints();
1514    let has_active_sos = sos.iter().any(|constraint| constraint.active);
1515    if options.quadratic_format == MpsQuadraticFormat::Cplex && has_active_sos {
1516        write_sos_section(out, &sos, &variable_names)?;
1517    }
1518
1519    if !obj_terms.hessian.is_empty() {
1520        let header = if options.quadratic_format == MpsQuadraticFormat::Mosek {
1521            "QSECTION OBJ"
1522        } else {
1523            "QUADOBJ"
1524        };
1525        write_quadratic_section(
1526            out,
1527            header,
1528            &obj_terms,
1529            &variable_names,
1530            options.quadratic_format,
1531            true,
1532        )?;
1533    }
1534    for (row_name, terms) in row_names.iter().zip(con_terms.iter()) {
1535        if terms.hessian.is_empty() {
1536            continue;
1537        }
1538        let header = match options.quadratic_format {
1539            MpsQuadraticFormat::Mosek => format!("QSECTION {row_name}"),
1540            MpsQuadraticFormat::Gurobi | MpsQuadraticFormat::Cplex => {
1541                format!("QCMATRIX {row_name}")
1542            }
1543        };
1544        write_quadratic_section(
1545            out,
1546            &header,
1547            terms,
1548            &variable_names,
1549            options.quadratic_format,
1550            false,
1551        )?;
1552    }
1553
1554    if options.quadratic_format != MpsQuadraticFormat::Cplex && has_active_sos {
1555        write_sos_section(out, &sos, &variable_names)?;
1556    }
1557
1558    writeln!(out, "ENDATA")?;
1559    Ok(())
1560}
1561
1562fn rebuild_quadratic<'a>(
1563    model: &'a Model,
1564    variables: &[Expr<'a>],
1565    terms: &QuadraticTerms,
1566) -> Expr<'a> {
1567    let mut expr = model.__constant(terms.constant);
1568    for &(var, coefficient) in &terms.linear {
1569        expr = expr + coefficient * variables[var.index()];
1570    }
1571    for &(left, right, hessian) in &terms.hessian {
1572        let coefficient = if left == right { hessian / 2.0 } else { hessian };
1573        expr = expr + coefficient * variables[left.index()] * variables[right.index()];
1574    }
1575    expr
1576}
1577
1578/// MPS indicators reference ordinary rows. Build a serialization-only model
1579/// containing those body rows, then append the standard `INDICATORS` section.
1580fn write_mps_with_indicators<W: Write>(
1581    model: &Model,
1582    out: &mut W,
1583    options: MpsWriteOptions,
1584) -> Result<(), IoError> {
1585    let source_vars = model.variables();
1586    let arena = model.arena();
1587    let temporary = Model::new(model.name.clone());
1588    let variables: Vec<_> = source_vars
1589        .iter()
1590        .map(|v| temporary.__var(v.name.clone()).bounds(v.lb, v.ub).domain(v.domain).build())
1591        .collect();
1592    let mut raw_row_names = Vec::new();
1593    let mut used: HashSet<String> =
1594        model.constraints().algebraic().iter().map(|c| c.name.to_string()).collect();
1595    for c in model.constraints().algebraic() {
1596        let terms = extract_quadratic(&arena, c.lhs).ok_or_else(|| IoError::Nonlinear {
1597            location: format!("constraint {:?}", c.name),
1598            term: "nonlinear expression".into(),
1599        })?;
1600        let expr = rebuild_quadratic(&temporary, &variables, &terms);
1601        temporary.__add_constraint_interval(c.name.clone(), expr, c.lower, c.upper);
1602        raw_row_names.push(c.name.to_string());
1603    }
1604    let mut records: Vec<(usize, VarId, bool)> = Vec::new();
1605    for indicator in model.indicator_constraints().iter().filter(|c| c.active) {
1606        let terms = extract_quadratic(&arena, indicator.lhs).expect("validated affine indicator");
1607        if !terms.hessian.is_empty() {
1608            return Err(IoError::UnsupportedMps {
1609                section: "INDICATORS".into(),
1610                feature: "quadratic indicator row".into(),
1611            });
1612        }
1613        let mut add_body = |suffix: &str, sense: Sense, rhs: f64| {
1614            let base = if suffix.is_empty() {
1615                indicator.name.to_string()
1616            } else {
1617                format!("{}_{suffix}", indicator.name)
1618            };
1619            let mut name = base.clone();
1620            let mut suffix = 1usize;
1621            while !used.insert(name.clone()) {
1622                name = format!("{base}_{suffix}");
1623                suffix += 1;
1624            }
1625            let expr = rebuild_quadratic(&temporary, &variables, &terms);
1626            let row = match sense {
1627                Sense::Le => expr.le(rhs),
1628                Sense::Ge => expr.ge(rhs),
1629                Sense::Eq => expr.eq(rhs),
1630            };
1631            temporary.__add_constraint(name.clone(), row);
1632            raw_row_names.push(name);
1633            records.push((raw_row_names.len() - 1, indicator.trigger, indicator.active_value));
1634        };
1635        if let Some((sense, rhs)) = indicator.as_single() {
1636            add_body("", sense, rhs);
1637        } else if indicator.is_range() {
1638            add_body("lo", Sense::Ge, indicator.lower);
1639            add_body("hi", Sense::Le, indicator.upper);
1640        }
1641    }
1642    for set in model.sos_constraints().iter().filter(|s| s.active) {
1643        temporary.add_sos_constraint(
1644            set.name.clone(),
1645            set.sos_type,
1646            set.members.iter().map(|member| (variables[member.variable.index()], member.weight)),
1647        );
1648    }
1649    if model.is_feasibility() {
1650        temporary.__feasibility();
1651    } else if let Some(objective) = model.objective().as_ref() {
1652        let terms = extract_quadratic(&arena, objective.expr).ok_or_else(|| {
1653            IoError::Nonlinear { location: "objective".into(), term: "nonlinear expression".into() }
1654        })?;
1655        let expr = rebuild_quadratic(&temporary, &variables, &terms);
1656        match objective.sense {
1657            ObjectiveSense::Minimize => temporary.__minimize(expr),
1658            ObjectiveSense::Maximize => temporary.__maximize(expr),
1659        }
1660    }
1661    let mut bytes = Vec::new();
1662    write_mps_with(&temporary, &mut bytes, &options)?;
1663    let text = String::from_utf8(bytes).expect("MPS writer emits ASCII");
1664    let end = text.rfind("ENDATA").expect("writer emits ENDATA");
1665    out.write_all(&text.as_bytes()[..end])?;
1666    let row_names = unique_mps_names(raw_row_names.iter().map(String::as_str), "R", ["OBJ"]);
1667    let variable_names = unique_mps_names(source_vars.iter().map(|v| v.name.as_str()), "C", []);
1668    writeln!(out, "INDICATORS")?;
1669    for (row, trigger, value) in records {
1670        writeln!(
1671            out,
1672            " IF {} {} {}",
1673            row_names[row],
1674            variable_names[trigger.index()],
1675            u8::from(value)
1676        )?;
1677    }
1678    out.write_all(&text.as_bytes()[end..])?;
1679    Ok(())
1680}
1681
1682/// Convenience: render the MPS into a `String`.
1683///
1684/// # Errors
1685///
1686/// Returns [`IoError`] if writing the MPS data fails.
1687///
1688/// # Panics
1689///
1690/// Panics if the MPS writer internal buffer does not produce valid UTF-8 data.
1691pub fn to_mps_string(model: &Model) -> Result<String, IoError> {
1692    to_mps_string_with(model, &MpsWriteOptions::default())
1693}
1694
1695/// Convenience: render the MPS into a `String` with explicit export options.
1696///
1697/// # Errors
1698///
1699/// Returns [`IoError`] if the model contains unsupported expressions or writing
1700/// the output fails.
1701///
1702/// # Panics
1703///
1704/// Panics if the MPS writer produces non-UTF-8 output.
1705pub fn to_mps_string_with(model: &Model, options: &MpsWriteOptions) -> Result<String, IoError> {
1706    let mut buf = Vec::new();
1707    write_mps_with(model, &mut buf, options)?;
1708    Ok(String::from_utf8(buf).expect("MPS writer emits ASCII"))
1709}