Skip to main content

pounce_cli/
nl_writer.rs

1//! Minimal AMPL `.sol`-format writer.
2//!
3//! Format reference: David M. Gay, "Hooking Your Solver to AMPL"
4//! (<https://ampl.com/REFS/hooking2.pdf>) §5 ("Returning Results to
5//! AMPL"), cross-checked against the AMPL solver-library reference
6//! implementation `write_sol_ASL` in
7//! <https://github.com/ampl/asl> (`solvers/writesol.c`). We emit the
8//! ASCII variant — the same one AMPL's `commands` file produces by
9//! default when reading back from solvers.
10//!
11//! # Format
12//!
13//! ```text
14//! <message line 1>
15//! <message line 2>
16//! ...                         (free text, ended by a blank line then "Options")
17//!
18//! Options
19//! <nopts>                     (int — number of integer option-words to follow)
20//! <opt0>                      (... nopts lines)
21//! ...
22//! <n_dual>                    (number of dual values written below)
23//! <m>                         (constraint count)
24//! <n_primal>                  (number of primal values written below)
25//! <n>                         (variable count)
26//! <lambda[0]>                 (... n_dual lines, dual values)
27//! ...
28//! <x[0]>                      (... n_primal lines, primal values)
29//! ...
30//! objno <objno> <status>      (optional — selects which objective and the solver-return code)
31//! suffix <kind> <nvalues> <namelen> <tablen> <tabline>  (optional — one block per exported suffix)
32//! <name>                      (the suffix name, on its own line)
33//! <idx> <value>
34//! ...
35//! ```
36//!
37//! The four-integer count block is the canonical AMPL form: each
38//! dimension count is paired with a "values written" partner so the
39//! reader knows how many dual and primal lines to consume before
40//! reaching `objno`. We always write every dual and primal, so
41//! `n_dual == m` and `n_primal == n`. (Earlier pounce builds emitted
42//! only the two bare counts `<m>\n<n>\n`; AMPL's own reader and
43//! Pyomo's `.sol` reader both reject that short form.)
44//!
45//! # Scope
46//!
47//! Smallest writer that lets [`crate::nl_reader::NlSuffixes`] flow
48//! from a pounce solve back through AMPL's reader. Specifically the
49//! `pounce_sens` binary (pounce#17) writes:
50//! * The nominal primal and dual blocks (so AMPL sees `x*` and `λ*`
51//!   on the regular `_var.X` / `_con.dual` slots).
52//! * One or more sensitivity suffixes (`sens_sol_state_<N>`) carrying
53//!   the perturbed primal as a real-var suffix, matching upstream
54//!   `MetadataMeasurement::SetSolution`
55//!   (`ref/Ipopt/contrib/sIPOPT/src/SensMetadataMeasurement.cpp:128-150`).
56
57use pounce_common::types::{Index, Number};
58use std::fmt::Write as _;
59use std::path::Path;
60
61/// A single suffix block to write back into the `.sol` file. Mirrors
62/// the `S`-segment shape of [`crate::nl_reader::NlSuffixes`] entries.
63#[derive(Debug, Clone)]
64pub struct SolSuffix {
65    /// `name` as it appears in AMPL.
66    pub name: String,
67    /// Which side the suffix attaches to. Mapped to AMPL's
68    /// `ASL_Sufkind_var` / `_con` / `_obj` / `_prob` (= 0/1/2/3).
69    pub target: SolSuffixTarget,
70    /// Real or integer-typed values. AMPL's `ASL_Sufkind_real` flag
71    /// (`0x4`) on the kind byte selects this; we accept either typed
72    /// payload here and tag the kind accordingly on write.
73    pub values: SolSuffixValues,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SolSuffixTarget {
78    Var = 0,
79    Con = 1,
80    Obj = 2,
81    Problem = 3,
82}
83
84#[derive(Debug, Clone)]
85pub enum SolSuffixValues {
86    /// One entry per dimension of the target (variables / constraints /
87    /// objectives). Sparse zero-trim happens on write — only non-zero
88    /// entries land in the output, matching how AMPL emits suffixes.
89    Int(Vec<Index>),
90    Real(Vec<Number>),
91    /// Problem-level scalar (target = Problem). Always emitted (no
92    /// sparse trim, since there's only one slot).
93    ProblemInt(Index),
94    ProblemReal(Number),
95}
96
97/// Solution payload bundled for a `.sol` write.
98#[derive(Debug, Clone)]
99pub struct SolutionFile<'a> {
100    /// Free-text banner / status line(s). Goes at the top of the file.
101    pub message: &'a str,
102    /// Primal variable values, length `n`.
103    pub x: &'a [Number],
104    /// Constraint dual values, length `m`.
105    pub lambda: &'a [Number],
106    /// AMPL solver return code. Convention: 0 = solved, 100..199 =
107    /// "solved with warning", 200..299 = "infeasible", 300..399 =
108    /// "unbounded", 400..499 = "limit reached", 500..599 = "failure".
109    /// See [Gay §5, table on p. 23](https://ampl.com/REFS/hooking2.pdf).
110    pub solve_result_num: i32,
111    /// Suffix blocks to emit after the primal/dual blocks. Empty when
112    /// no sensitivity / reduced-Hessian outputs are populated.
113    pub suffixes: &'a [SolSuffix],
114}
115
116/// Format `payload` into AMPL `.sol` ASCII text.
117pub fn format_sol(payload: &SolutionFile<'_>) -> String {
118    let mut out = String::new();
119
120    // Header: message + a blank line + "Options" + zero options.
121    for line in payload.message.lines() {
122        let _ = writeln!(out, "{line}");
123    }
124    out.push('\n');
125    out.push_str("Options\n");
126    out.push_str("0\n");
127
128    // Count block: the canonical AMPL four-integer form
129    //   <n_dual_written> <n_con> <n_primal_written> <n_var>
130    // The "written" counts tell the reader how many value lines to
131    // consume; the bare counts are matched against the originating
132    // `.nl`. We write every dual and primal, so the pairs collapse to
133    // (m, m) and (n, n). Emitting only `m` and `n` (the two-integer
134    // short form) makes AMPL's and Pyomo's `.sol` readers fail.
135    let m = payload.lambda.len();
136    let n = payload.x.len();
137    let _ = writeln!(out, "{m}");
138    let _ = writeln!(out, "{m}");
139    let _ = writeln!(out, "{n}");
140    let _ = writeln!(out, "{n}");
141
142    // Dual block, then primal block. AMPL writes doubles with at least
143    // 16 significant digits to round-trip through IEEE-754; we use
144    // Rust's `{:.17e}` to match.
145    for &v in payload.lambda {
146        let _ = writeln!(out, "{v:.17e}");
147    }
148    for &v in payload.x {
149        let _ = writeln!(out, "{v:.17e}");
150    }
151
152    // Objective-number + solver return code. AMPL convention: every
153    // .sol must end with at least an `objno <objno> <code>` line so
154    // the reader can extract `solve_result_num`.
155    let _ = writeln!(out, "objno 0 {}", payload.solve_result_num);
156
157    // Suffix blocks. AMPL's reader skips empty / all-zero suffixes,
158    // but it accepts them; we sparse-trim ints/reals to keep the
159    // output small. Problem-level kinds always write a single entry.
160    for s in payload.suffixes {
161        write_suffix(&mut out, s);
162    }
163
164    out
165}
166
167fn write_suffix(out: &mut String, s: &SolSuffix) {
168    let target_bits = s.target as u32 & 0x3;
169    match &s.values {
170        SolSuffixValues::Int(vs) => {
171            let entries: Vec<(usize, Index)> = vs
172                .iter()
173                .enumerate()
174                .filter(|(_, &v)| v != 0)
175                .map(|(i, &v)| (i, v))
176                .collect();
177            write_suffix_header(out, target_bits, entries.len(), &s.name);
178            for (i, v) in entries {
179                let _ = writeln!(out, "{i} {v}");
180            }
181        }
182        SolSuffixValues::Real(vs) => {
183            let entries: Vec<(usize, Number)> = vs
184                .iter()
185                .enumerate()
186                .filter(|(_, &v)| v != 0.0)
187                .map(|(i, &v)| (i, v))
188                .collect();
189            write_suffix_header(out, target_bits | 0x4, entries.len(), &s.name);
190            for (i, v) in entries {
191                let _ = writeln!(out, "{i} {v:.17e}");
192            }
193        }
194        SolSuffixValues::ProblemInt(v) => {
195            write_suffix_header(out, target_bits, 1, &s.name);
196            let _ = writeln!(out, "0 {v}");
197        }
198        SolSuffixValues::ProblemReal(v) => {
199            write_suffix_header(out, target_bits | 0x4, 1, &s.name);
200            let _ = writeln!(out, "0 {v:.17e}");
201        }
202    }
203}
204
205/// Emit the canonical AMPL `.sol` suffix header: five integers
206/// `suffix <kind> <nvalues> <namelen> <tablen> <tabline>` followed by
207/// the suffix name on its own line. `namelen` is `strlen(name)+1` (the
208/// value ASL's `writesol.c` writes); `tablen`/`tabline` are 0 — pounce
209/// never emits a suffix value-table. AMPL's and Pyomo's `.sol` readers
210/// both require this five-integer form and read the name from the next
211/// line; the older three-token `suffix <kind> <nvalues> <name>` shape
212/// is rejected.
213fn write_suffix_header(out: &mut String, kind: u32, nvalues: usize, name: &str) {
214    let namelen = name.len() + 1;
215    let _ = writeln!(out, "suffix {kind} {nvalues} {namelen} 0 0");
216    let _ = writeln!(out, "{name}");
217}
218
219/// Convenience: write `payload` to `path` (truncating any existing
220/// file). Returns the bytes written on success.
221pub fn write_sol_file(path: &Path, payload: &SolutionFile<'_>) -> std::io::Result<usize> {
222    let s = format_sol(payload);
223    std::fs::write(path, &s)?;
224    Ok(s.len())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn writes_basic_primal_dual_block() {
233        let payload = SolutionFile {
234            message: "POUNCE: SolveSucceeded",
235            x: &[1.0, 2.5, -0.5],
236            lambda: &[0.1, -0.2],
237            solve_result_num: 0,
238            suffixes: &[],
239        };
240        let s = format_sol(&payload);
241        // Header banner present.
242        assert!(s.starts_with("POUNCE: SolveSucceeded\n"));
243        assert!(s.contains("\nOptions\n0\n"));
244        // Four-integer count block: n_dual=2, m=2, n_primal=3, n=3.
245        assert!(s.contains("\n2\n2\n3\n3\n"), "counts missing:\n{s}");
246        // First dual line: 0.1 in exponent form.
247        assert!(
248            s.contains("1.00000000000000006e-1\n") || s.contains("1.0e-1\n"),
249            "lambda not present:\n{s}",
250        );
251        // objno tail present.
252        assert!(s.trim_end().ends_with("objno 0 0"));
253    }
254
255    #[test]
256    fn writes_real_var_suffix_sparse_trimming_zeros() {
257        let payload = SolutionFile {
258            message: "POUNCE-SENS",
259            x: &[0.0, 0.0],
260            lambda: &[],
261            solve_result_num: 0,
262            suffixes: &[SolSuffix {
263                name: "sens_sol_state_1".into(),
264                target: SolSuffixTarget::Var,
265                // Dense (0, 5.0, 0, 3.5); only indices 1 and 3 should
266                // appear.
267                values: SolSuffixValues::Real(vec![0.0, 5.0, 0.0, 3.5]),
268            }],
269        };
270        let s = format_sol(&payload);
271        // Canonical header: kind = 0|0x4 = 4 (real var), 2 values,
272        // namelen = 17 ("sens_sol_state_1" + NUL), no table; name on
273        // the following line.
274        assert!(
275            s.contains("\nsuffix 4 2 17 0 0\nsens_sol_state_1\n"),
276            "missing suffix header:\n{s}",
277        );
278        // entries present with correct indices.
279        assert!(s.contains("\n1 5.0"), "missing entry idx 1:\n{s}");
280        assert!(s.contains("\n3 3.5"), "missing entry idx 3:\n{s}");
281        // index 0 / 2 are zero — must not appear in the suffix block.
282        // (The single-digit `0` could appear elsewhere, so we anchor.)
283        assert!(!s.contains("\n0 0.0"), "zero entry was not trimmed:\n{s}",);
284    }
285
286    #[test]
287    fn writes_int_constraint_suffix() {
288        let payload = SolutionFile {
289            message: "msg",
290            x: &[],
291            lambda: &[],
292            solve_result_num: 0,
293            suffixes: &[SolSuffix {
294                name: "sens_init_constr".into(),
295                target: SolSuffixTarget::Con,
296                values: SolSuffixValues::Int(vec![0, 1, 2, 0]),
297            }],
298        };
299        let s = format_sol(&payload);
300        // kind = 1 (con, integer), 2 values, namelen = 17.
301        assert!(s.contains("\nsuffix 1 2 17 0 0\nsens_init_constr\n"), "{s}");
302        assert!(s.contains("\n1 1\n"));
303        assert!(s.contains("\n2 2\n"));
304    }
305
306    #[test]
307    fn writes_problem_real_suffix() {
308        let payload = SolutionFile {
309            message: "msg",
310            x: &[],
311            lambda: &[],
312            solve_result_num: 0,
313            suffixes: &[SolSuffix {
314                name: "wall_time".into(),
315                target: SolSuffixTarget::Problem,
316                values: SolSuffixValues::ProblemReal(0.0123),
317            }],
318        };
319        let s = format_sol(&payload);
320        // kind = 3 | 0x4 = 7 (problem-level, real), namelen = 10.
321        assert!(s.contains("\nsuffix 7 1 10 0 0\nwall_time\n"), "{s}");
322        // Single entry at idx 0.
323        assert!(s.contains("0 1.23"));
324    }
325
326    #[test]
327    fn round_trip_through_nl_reader_suffix_parser() {
328        // Build a .sol with an integer var-suffix, then feed the
329        // suffix block to the .nl-style parser to confirm shape /
330        // index conventions agree. We don't reuse parse_nl_text here
331        // because the .sol prefix differs from .nl; instead we just
332        // string-search the emitted suffix header against the
333        // {kind, name, count} contract.
334        let payload = SolutionFile {
335            message: "m",
336            x: &[],
337            lambda: &[],
338            solve_result_num: 0,
339            suffixes: &[SolSuffix {
340                name: "foo".into(),
341                target: SolSuffixTarget::Var,
342                values: SolSuffixValues::Int(vec![1, 0, 3]),
343            }],
344        };
345        let s = format_sol(&payload);
346        // kind = 0 (var int), 2 values, namelen = 4.
347        assert!(s.contains("\nsuffix 0 2 4 0 0\nfoo\n"), "{s}");
348    }
349}