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