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}