Skip to main content

pounce_nl/
sol_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 multipliers in **pounce's internal (cyipopt)
105    /// convention**, length `m`: the `lambda` of
106    /// `L = f + lambda' g - z_L (x - x_L) + z_U (x - x_U)`, so
107    /// `d obj / d b = -lambda`.
108    ///
109    /// [`format_sol`] negates these on the way out, because the AMPL
110    /// `.sol` dual block carries *marginal values* (`d obj / d b`),
111    /// not Lagrange multipliers. Pass the internal multipliers here
112    /// and let the writer do the translation — do not pre-negate at
113    /// the call site.
114    ///
115    /// See [Gay, "Hooking Your Solver to AMPL" §5](https://ampl.com/REFS/hooking2.pdf).
116    pub mult_g: &'a [Number],
117    /// AMPL solver return code. Convention: 0 = solved, 100..199 =
118    /// "solved with warning", 200..299 = "infeasible", 300..399 =
119    /// "unbounded", 400..499 = "limit reached", 500..599 = "failure".
120    /// See [Gay §5, table on p. 23](https://ampl.com/REFS/hooking2.pdf).
121    pub solve_result_num: i32,
122    /// Suffix blocks to emit after the primal/dual blocks. Empty when
123    /// no sensitivity / reduced-Hessian outputs are populated.
124    pub suffixes: &'a [SolSuffix],
125}
126
127/// Format `payload` into AMPL `.sol` ASCII text, with a generic
128/// `Options` block.
129///
130/// Prefer [`format_sol_with_options`] whenever the originating `.nl` is
131/// available: a real ASL solver echoes *that model's* option words, and
132/// this entry point has none to echo.
133pub fn format_sol(payload: &SolutionFile<'_>) -> String {
134    format_sol_with_options(payload, &[])
135}
136
137/// Format `payload` into AMPL `.sol` ASCII text, echoing `ampl_options`.
138///
139/// `ampl_options` are the model's own option words, read verbatim from
140/// `.nl` header line 0 (`NlProblem::ampl_options`). Ipopt and the ASL's
141/// `writesol.c` echo them back unchanged rather than interpreting them,
142/// and the values are per-model: `bearing_400` (`g3 1 1 0`) yields
143/// `3 / 1 / 1 / 0`, while `arki0003` and `camshape_6400` (`g3 10 1 0`)
144/// yield `3 / 10 / 1 / 0`. Verified against Ipopt 3.14.20 `-AMPL` output.
145///
146/// Pass an empty slice when there is no originating header — problems
147/// built through `NlProblem::from_expressions`, the WASM entry point —
148/// and a generic `3 / 1 / 1 / 0` goes out instead. That is a valid block
149/// (see the count discussion below); it simply is not this model's.
150pub fn format_sol_with_options(payload: &SolutionFile<'_>, ampl_options: &[i64]) -> String {
151    let mut out = String::new();
152
153    // Header: message + a blank line + the `Options` block.
154    //
155    // The block is a count followed by that many integer option words.
156    // We used to write a count of `0`, which AMPL and Pyomo's *legacy*
157    // `.sol` reader accept — but no ASL solver emits it, and Pyomo's
158    // v2 reader (`pyomo.contrib.solver.solvers.asl_sol_reader`, the one
159    // behind `ipopt_v2` and the future default `ipopt`) reads the first
160    // two option words unconditionally, to detect the documented ASL
161    // quirk where a second word of `3` means two extra words follow the
162    // `z` block. It therefore asserts `n_opts >= 2` and a count of `0`
163    // aborts the parse, so a `.sol` POUNCE wrote could not be loaded
164    // through the modern interface at all.
165    //
166    // Echo the model's own option words, which is what Ipopt and the
167    // ASL's `writesol.c` do — they are AMPL's flags, passed through
168    // rather than interpreted. With no header to echo, fall back to a
169    // generic three-word block; `1` in the second slot deliberately does
170    // not trigger the `3`-quirk test above.
171    for line in payload.message.lines() {
172        let _ = writeln!(out, "{line}");
173    }
174    out.push('\n');
175    out.push_str("Options\n");
176    if ampl_options.len() >= 2 {
177        let _ = writeln!(out, "{}", ampl_options.len());
178        for opt in ampl_options {
179            let _ = writeln!(out, "{opt}");
180        }
181    } else {
182        out.push_str("3\n1\n1\n0\n");
183    }
184
185    // Count block: the canonical AMPL four-integer form
186    //   <n_dual_written> <n_con> <n_primal_written> <n_var>
187    // The "written" counts tell the reader how many value lines to
188    // consume; the bare counts are matched against the originating
189    // `.nl`. We write every dual and primal, so the pairs collapse to
190    // (m, m) and (n, n). Emitting only `m` and `n` (the two-integer
191    // short form) makes AMPL's and Pyomo's `.sol` readers fail.
192    let m = payload.mult_g.len();
193    let n = payload.x.len();
194    let _ = writeln!(out, "{m}");
195    let _ = writeln!(out, "{m}");
196    let _ = writeln!(out, "{n}");
197    let _ = writeln!(out, "{n}");
198
199    // Dual block, then primal block. AMPL writes doubles with at least
200    // 16 significant digits to round-trip through IEEE-754; we use
201    // Rust's `{:.17e}` to match.
202    // AMPL's dual block is the *marginal value* `d obj / d b`, while
203    // pounce carries the Lagrange multiplier of
204    // `L = f + lambda' g`, for which `d obj / d b = -lambda`. Negate
205    // on the way out so `.sol` consumers (AMPL, Pyomo `model.dual`,
206    // and anything reading the file directly) see shadow prices with
207    // the sign the rest of the ecosystem uses. See gh #271.
208    for &v in payload.mult_g {
209        let _ = writeln!(out, "{:.17e}", -v);
210    }
211    for &v in payload.x {
212        let _ = writeln!(out, "{v:.17e}");
213    }
214
215    // Objective-number + solver return code. AMPL convention: every
216    // .sol must end with at least an `objno <objno> <code>` line so
217    // the reader can extract `solve_result_num`.
218    let _ = writeln!(out, "objno 0 {}", payload.solve_result_num);
219
220    // Suffix blocks. AMPL's reader skips empty / all-zero suffixes,
221    // but it accepts them; we sparse-trim ints/reals to keep the
222    // output small. Problem-level kinds always write a single entry.
223    for s in payload.suffixes {
224        write_suffix(&mut out, s);
225    }
226
227    out
228}
229
230fn write_suffix(out: &mut String, s: &SolSuffix) {
231    let target_bits = s.target as u32 & 0x3;
232    match &s.values {
233        SolSuffixValues::Int(vs) => {
234            let entries: Vec<(usize, Index)> = vs
235                .iter()
236                .enumerate()
237                .filter(|&(_, &v)| v != 0)
238                .map(|(i, &v)| (i, v))
239                .collect();
240            write_suffix_header(out, target_bits, entries.len(), &s.name);
241            for (i, v) in entries {
242                let _ = writeln!(out, "{i} {v}");
243            }
244        }
245        SolSuffixValues::Real(vs) => {
246            let entries: Vec<(usize, Number)> = vs
247                .iter()
248                .enumerate()
249                .filter(|&(_, &v)| v != 0.0)
250                .map(|(i, &v)| (i, v))
251                .collect();
252            write_suffix_header(out, target_bits | 0x4, entries.len(), &s.name);
253            for (i, v) in entries {
254                let _ = writeln!(out, "{i} {v:.17e}");
255            }
256        }
257        SolSuffixValues::ProblemInt(v) => {
258            write_suffix_header(out, target_bits, 1, &s.name);
259            let _ = writeln!(out, "0 {v}");
260        }
261        SolSuffixValues::ProblemReal(v) => {
262            write_suffix_header(out, target_bits | 0x4, 1, &s.name);
263            let _ = writeln!(out, "0 {v:.17e}");
264        }
265    }
266}
267
268/// Emit the canonical AMPL `.sol` suffix header: five integers
269/// `suffix <kind> <nvalues> <namelen> <tablen> <tabline>` followed by
270/// the suffix name on its own line. `namelen` is `strlen(name)+1` (the
271/// value ASL's `writesol.c` writes); `tablen`/`tabline` are 0 — pounce
272/// never emits a suffix value-table. AMPL's and Pyomo's `.sol` readers
273/// both require this five-integer form and read the name from the next
274/// line; the older three-token `suffix <kind> <nvalues> <name>` shape
275/// is rejected.
276fn write_suffix_header(out: &mut String, kind: u32, nvalues: usize, name: &str) {
277    let namelen = name.len() + 1;
278    let _ = writeln!(out, "suffix {kind} {nvalues} {namelen} 0 0");
279    let _ = writeln!(out, "{name}");
280}
281
282/// Convenience: write `payload` to `path` (truncating any existing
283/// file). Returns the bytes written on success.
284pub fn write_sol_file(path: &Path, payload: &SolutionFile<'_>) -> std::io::Result<usize> {
285    write_sol_file_with_options(path, payload, &[])
286}
287
288/// As [`write_sol_file`], echoing the model's own `ampl_options` (see
289/// [`format_sol_with_options`]).
290pub fn write_sol_file_with_options(
291    path: &Path,
292    payload: &SolutionFile<'_>,
293    ampl_options: &[i64],
294) -> std::io::Result<usize> {
295    let s = format_sol_with_options(payload, ampl_options);
296    std::fs::write(path, &s)?;
297    Ok(s.len())
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn options_block(text: &str) -> Vec<String> {
305        let mut lines = text.lines().skip_while(|l| *l != "Options");
306        lines.next();
307        let n: usize = lines.next().unwrap().parse().unwrap();
308        std::iter::once(n.to_string())
309            .chain(lines.take(n).map(str::to_string))
310            .collect()
311    }
312
313    /// A solver echoes the model's own AMPL option words rather than
314    /// inventing them. `arki0003` and `camshape_6400` carry `g3 10 1 0`,
315    /// and Ipopt 3.14.20 writes `3 / 10 / 1 / 0` for both — verified
316    /// against real `-AMPL` output, not just the spec.
317    #[test]
318    fn options_block_echoes_the_models_own_words() {
319        let payload = SolutionFile {
320            message: "POUNCE: SolveSucceeded",
321            x: &[1.0],
322            mult_g: &[],
323            solve_result_num: 0,
324            suffixes: &[],
325        };
326        let text = format_sol_with_options(&payload, &[10, 1, 0]);
327        assert_eq!(options_block(&text), ["3", "10", "1", "0"]);
328    }
329
330    /// With no originating header, a generic block goes out. It must
331    /// still satisfy Pyomo's v2 reader: at least two words, and a second
332    /// word that is not the `3` that signals two extra trailing values.
333    #[test]
334    fn options_block_falls_back_when_there_is_no_header() {
335        let payload = SolutionFile {
336            message: "POUNCE: SolveSucceeded",
337            x: &[1.0],
338            mult_g: &[],
339            solve_result_num: 0,
340            suffixes: &[],
341        };
342        for opts in [vec![], vec![7]] {
343            let text = format_sol_with_options(&payload, &opts);
344            let block = options_block(&text);
345            assert_eq!(block, ["3", "1", "1", "0"], "opts={opts:?}");
346            assert_ne!(block[2], "3", "must not trigger the vbtol quirk");
347        }
348    }
349
350    #[test]
351    fn writes_basic_primal_dual_block() {
352        let payload = SolutionFile {
353            message: "POUNCE: SolveSucceeded",
354            x: &[1.0, 2.5, -0.5],
355            mult_g: &[0.1, -0.2],
356            solve_result_num: 0,
357            suffixes: &[],
358        };
359        let s = format_sol(&payload);
360        // Header banner present.
361        assert!(s.starts_with("POUNCE: SolveSucceeded\n"));
362        // The ASL option block, as `writesol.c` and Ipopt emit it. A
363        // count of `0` here trips `assert n_opts >= 2` in Pyomo's v2
364        // `.sol` reader, which is what `ipopt_v2` (and the future
365        // default `ipopt`) uses — see `format_sol`.
366        assert!(s.contains("\nOptions\n3\n1\n1\n0\n"), "option block:\n{s}");
367        // Four-integer count block: n_dual=2, m=2, n_primal=3, n=3.
368        assert!(s.contains("\n2\n2\n3\n3\n"), "counts missing:\n{s}");
369        // First dual line: 0.1 in exponent form.
370        assert!(
371            s.contains("1.00000000000000006e-1\n") || s.contains("1.0e-1\n"),
372            "lambda not present:\n{s}",
373        );
374        // objno tail present.
375        assert!(s.trim_end().ends_with("objno 0 0"));
376    }
377
378    #[test]
379    fn writes_real_var_suffix_sparse_trimming_zeros() {
380        let payload = SolutionFile {
381            message: "POUNCE-SENS",
382            x: &[0.0, 0.0],
383            mult_g: &[],
384            solve_result_num: 0,
385            suffixes: &[SolSuffix {
386                name: "sens_sol_state_1".into(),
387                target: SolSuffixTarget::Var,
388                // Dense (0, 5.0, 0, 3.5); only indices 1 and 3 should
389                // appear.
390                values: SolSuffixValues::Real(vec![0.0, 5.0, 0.0, 3.5]),
391            }],
392        };
393        let s = format_sol(&payload);
394        // Canonical header: kind = 0|0x4 = 4 (real var), 2 values,
395        // namelen = 17 ("sens_sol_state_1" + NUL), no table; name on
396        // the following line.
397        assert!(
398            s.contains("\nsuffix 4 2 17 0 0\nsens_sol_state_1\n"),
399            "missing suffix header:\n{s}",
400        );
401        // entries present with correct indices.
402        assert!(s.contains("\n1 5.0"), "missing entry idx 1:\n{s}");
403        assert!(s.contains("\n3 3.5"), "missing entry idx 3:\n{s}");
404        // index 0 / 2 are zero — must not appear in the suffix block.
405        // (The single-digit `0` could appear elsewhere, so we anchor.)
406        assert!(!s.contains("\n0 0.0"), "zero entry was not trimmed:\n{s}",);
407    }
408
409    #[test]
410    fn writes_int_constraint_suffix() {
411        let payload = SolutionFile {
412            message: "msg",
413            x: &[],
414            mult_g: &[],
415            solve_result_num: 0,
416            suffixes: &[SolSuffix {
417                name: "sens_init_constr".into(),
418                target: SolSuffixTarget::Con,
419                values: SolSuffixValues::Int(vec![0, 1, 2, 0]),
420            }],
421        };
422        let s = format_sol(&payload);
423        // kind = 1 (con, integer), 2 values, namelen = 17.
424        assert!(s.contains("\nsuffix 1 2 17 0 0\nsens_init_constr\n"), "{s}");
425        assert!(s.contains("\n1 1\n"));
426        assert!(s.contains("\n2 2\n"));
427    }
428
429    #[test]
430    fn writes_problem_real_suffix() {
431        let payload = SolutionFile {
432            message: "msg",
433            x: &[],
434            mult_g: &[],
435            solve_result_num: 0,
436            suffixes: &[SolSuffix {
437                name: "wall_time".into(),
438                target: SolSuffixTarget::Problem,
439                values: SolSuffixValues::ProblemReal(0.0123),
440            }],
441        };
442        let s = format_sol(&payload);
443        // kind = 3 | 0x4 = 7 (problem-level, real), namelen = 10.
444        assert!(s.contains("\nsuffix 7 1 10 0 0\nwall_time\n"), "{s}");
445        // Single entry at idx 0.
446        assert!(s.contains("0 1.23"));
447    }
448
449    #[test]
450    fn round_trip_through_nl_reader_suffix_parser() {
451        // Build a .sol with an integer var-suffix, then feed the
452        // suffix block to the .nl-style parser to confirm shape /
453        // index conventions agree. We don't reuse parse_nl_text here
454        // because the .sol prefix differs from .nl; instead we just
455        // string-search the emitted suffix header against the
456        // {kind, name, count} contract.
457        let payload = SolutionFile {
458            message: "m",
459            x: &[],
460            mult_g: &[],
461            solve_result_num: 0,
462            suffixes: &[SolSuffix {
463                name: "foo".into(),
464                target: SolSuffixTarget::Var,
465                values: SolSuffixValues::Int(vec![1, 0, 3]),
466            }],
467        };
468        let s = format_sol(&payload);
469        // kind = 0 (var int), 2 values, namelen = 4.
470        assert!(s.contains("\nsuffix 0 2 4 0 0\nfoo\n"), "{s}");
471    }
472}