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}