Skip to main content

pounce_algorithm/
iter_dump.rs

1//! Per-iteration binary trace dumper for Track-A bit-equivalence
2//! validation against upstream Ipopt.
3//!
4//! Format spec: `tools/iter-dump/FORMAT.md` (POUNCEIT v1, little-endian,
5//! 32-byte fixed header + variable-length name + per-iter records).
6//! A reference Python parser lives at `tools/iter-dump/dump_inspect.py`.
7//!
8//! Activation: gated by the `IPOPT_ITER_DUMP_PATH` environment variable.
9//! When unset or empty, [`IterDumper::from_env`] returns `None` and the
10//! main loop's hook is a no-op. The optional `IPOPT_ITER_DUMP_NAME`
11//! variable supplies the problem-name string written into the header.
12//!
13//! This module is `pub(crate)` and not exposed in the public API. It is
14//! invoked from [`crate::ipopt_alg::IpoptAlgorithm::optimize`] at the
15//! same logical points as upstream's writer (after init for iter 0,
16//! after every `accept_trial_point`).
17//!
18//! In v1 the four PD perturbations (`delta_s/c/d`) and the filter
19//! contents are advisory and may be left at zero / empty: comparators
20//! treat them as such (see FORMAT.md §"`delta_s` / `delta_c` /
21//! `delta_d`").
22
23use crate::ipopt_cq::IpoptCqHandle;
24use crate::ipopt_data::IpoptDataHandle;
25use pounce_common::types::Number;
26use pounce_linalg::dense_vector::DenseVector;
27use pounce_linalg::Vector;
28use std::fs::File;
29use std::io::{BufWriter, Write};
30use std::path::PathBuf;
31
32/// Magic bytes identifying a POUNCEIT v1 stream. Matches the upstream
33/// patched-Ipopt writer byte-for-byte.
34pub const MAGIC: &[u8; 8] = b"POUNCEIT";
35/// Format version this writer emits.
36pub const FORMAT_VERSION: u32 = 1;
37
38/// Environment variable that enables dumping (set to an absolute file
39/// path).
40pub const ENV_DUMP_PATH: &str = "IPOPT_ITER_DUMP_PATH";
41/// Optional environment variable supplying the problem-name string
42/// recorded in the header.
43pub const ENV_DUMP_NAME: &str = "IPOPT_ITER_DUMP_NAME";
44
45/// Writer that emits the POUNCEIT v1 binary trace. One instance per
46/// `optimize()` call; dropped at the end of the solve, which flushes
47/// the underlying buffered file.
48pub(crate) struct IterDumper {
49    writer: BufWriter<File>,
50    /// Whether the header has been emitted. We defer header emission
51    /// until the first record, when `(n, m)` are known from the
52    /// initialised `curr` iterate.
53    header_written: bool,
54    name: String,
55}
56
57impl IterDumper {
58    /// Construct from `IPOPT_ITER_DUMP_PATH`. Returns `None` if the env
59    /// var is unset or empty (no-op path). On open-failure, returns
60    /// `None` after a stderr note: a broken dump path must never
61    /// destabilise the solver.
62    pub(crate) fn from_env() -> Option<Self> {
63        let path = std::env::var(ENV_DUMP_PATH).ok()?;
64        if path.is_empty() {
65            return None;
66        }
67        let pb = PathBuf::from(&path);
68        let file = match File::create(&pb) {
69            Ok(f) => f,
70            Err(e) => {
71                eprintln!(
72                    "iter_dump: failed to open `{}` for writing: {} — dumping disabled",
73                    path, e
74                );
75                return None;
76            }
77        };
78        let name = std::env::var(ENV_DUMP_NAME).unwrap_or_default();
79        Some(Self {
80            writer: BufWriter::new(file),
81            header_written: false,
82            name,
83        })
84    }
85
86    fn write_u32(&mut self, v: u32) -> std::io::Result<()> {
87        self.writer.write_all(&v.to_le_bytes())
88    }
89
90    fn write_f64(&mut self, v: Number) -> std::io::Result<()> {
91        self.writer.write_all(&v.to_le_bytes())
92    }
93
94    fn write_vec(&mut self, v: &dyn Vector) -> std::io::Result<()> {
95        let len = v.dim() as u32;
96        self.write_u32(len)?;
97        if len == 0 {
98            return Ok(());
99        }
100        // Try to grab a contiguous f64 slice from a DenseVector. A
101        // homogeneous DenseVector materialises to a `len`-long expanded
102        // value vector to match upstream's on-disk representation.
103        if let Some(dense) = v.as_any().downcast_ref::<DenseVector>() {
104            if dense.is_homogeneous() {
105                let expanded = dense.expanded_values();
106                for x in &expanded {
107                    self.writer.write_all(&x.to_le_bytes())?;
108                }
109                return Ok(());
110            }
111            // Non-homogeneous DenseVector: write raw little-endian bytes.
112            for x in dense.values() {
113                self.writer.write_all(&x.to_le_bytes())?;
114            }
115            return Ok(());
116        }
117        // Fallback for non-DenseVector backings: this should not occur
118        // in v1.0 (POUNCE is dense-only) but we handle it via a copy
119        // through `make_new` + `copy`, then probe again.
120        let mut tmp = v.make_new();
121        tmp.copy(v);
122        if let Some(dense) = tmp.as_any().downcast_ref::<DenseVector>() {
123            for x in dense.expanded_values().iter() {
124                self.writer.write_all(&x.to_le_bytes())?;
125            }
126            return Ok(());
127        }
128        // Last resort: write zeros (preserves file structure so a
129        // comparator can at least flag the divergence).
130        for _ in 0..len {
131            self.writer.write_all(&0.0_f64.to_le_bytes())?;
132        }
133        Ok(())
134    }
135
136    /// Emit the fixed POUNCEIT header. Called once before the first
137    /// record, when `(n, m)` are known.
138    fn write_header(&mut self, n: u32, m: u32) -> std::io::Result<()> {
139        debug_assert!(!self.header_written);
140        self.writer.write_all(MAGIC)?;
141        self.write_u32(FORMAT_VERSION)?;
142        self.write_u32(n)?;
143        self.write_u32(m)?;
144        // nnz_jac, nnz_h: written as 0 to match the patched upstream
145        // Ipopt's v1 behaviour. Comparators treat these as advisory.
146        self.write_u32(0)?;
147        self.write_u32(0)?;
148        let name_len = self.name.len();
149        self.write_u32(name_len as u32)?;
150        let name_bytes = self.name.clone();
151        self.writer.write_all(name_bytes.as_bytes())?;
152        self.header_written = true;
153        Ok(())
154    }
155
156    /// Emit one iteration record. `data` and `cq` must reference the
157    /// post-`accept_trial_point` state (or, for iter 0, the initialised
158    /// `curr` iterate).
159    pub(crate) fn write_record(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) {
160        if let Err(e) = self.write_record_inner(data, cq) {
161            eprintln!(
162                "iter_dump: failed to write iteration record: {} — dumping aborted",
163                e
164            );
165        }
166    }
167
168    fn write_record_inner(
169        &mut self,
170        data: &IpoptDataHandle,
171        cq: &IpoptCqHandle,
172    ) -> std::io::Result<()> {
173        // Snapshot all data we need before any I/O (avoid holding a
174        // borrow across self.writer writes — we don't, but it keeps the
175        // structure clear).
176        let (iter, mu, tau, alpha_pr, alpha_du, delta_x, delta_s, delta_c, delta_d, curr_opt) = {
177            let d = data.borrow();
178            (
179                d.iter_count as u32,
180                d.curr_mu,
181                d.curr_tau,
182                d.info_alpha_primal,
183                d.info_alpha_dual,
184                d.info_regu_x,
185                d.perturbations.delta_s,
186                d.perturbations.delta_c,
187                d.perturbations.delta_d,
188                d.curr.clone(),
189            )
190        };
191        let Some(curr) = curr_opt else {
192            // No `curr` yet (defensive): nothing to write.
193            return Ok(());
194        };
195
196        // CQ-derived scalars — must be computed *outside* a `data`
197        // borrow because CQ accessors take `data.borrow()` themselves.
198        let inf_pr = cq.borrow().curr_primal_infeasibility_max();
199        let inf_du = cq.borrow().curr_dual_infeasibility_max();
200        let constr_viol = cq.borrow().curr_constraint_violation();
201        let dual_inf = inf_du; // alias per FORMAT.md
202                               // FORMAT.md describes `complementarity` as
203                               // `IpCq().curr_complementarity(0.0, NORM_MAX)` — the max-norm
204                               // unbarriered complementarity. We compute it directly from the
205                               // four `curr_compl_*` blocks (the same pieces curr_nlp_error
206                               // already uses).
207        let complementarity = {
208            let cq_ref = cq.borrow();
209            cq_ref
210                .curr_compl_x_l()
211                .amax()
212                .max(cq_ref.curr_compl_x_u().amax())
213                .max(cq_ref.curr_compl_s_l().amax())
214                .max(cq_ref.curr_compl_s_u().amax())
215        };
216        let f_val = cq.borrow().curr_f();
217
218        // Header (lazy-write on first record so we know n/m).
219        if !self.header_written {
220            let n = curr.x.dim() as u32;
221            let m = (curr.y_c.dim() + curr.y_d.dim()) as u32;
222            self.write_header(n, m)?;
223        }
224
225        // Scalar block: u32 iter, u32 status, 14 * f64.
226        self.write_u32(iter)?;
227        self.write_u32(0)?; // status — always 0 ("in progress") in v1
228        self.write_f64(mu)?;
229        self.write_f64(tau)?;
230        self.write_f64(alpha_pr)?;
231        self.write_f64(alpha_du)?;
232        self.write_f64(delta_x)?;
233        self.write_f64(delta_s)?;
234        self.write_f64(delta_c)?;
235        self.write_f64(delta_d)?;
236        self.write_f64(inf_pr)?;
237        self.write_f64(inf_du)?;
238        self.write_f64(constr_viol)?;
239        self.write_f64(dual_inf)?;
240        self.write_f64(complementarity)?;
241        self.write_f64(f_val)?;
242
243        // Iterate vector block: x, s, y_c, y_d, z_L, z_U, v_L, v_U.
244        self.write_vec(&*curr.x)?;
245        self.write_vec(&*curr.s)?;
246        self.write_vec(&*curr.y_c)?;
247        self.write_vec(&*curr.y_d)?;
248        self.write_vec(&*curr.z_l)?;
249        self.write_vec(&*curr.z_u)?;
250        self.write_vec(&*curr.v_l)?;
251        self.write_vec(&*curr.v_u)?;
252
253        // Filter block — advisory in v1, write count=0.
254        self.write_u32(0)?;
255        Ok(())
256    }
257}
258
259impl Drop for IterDumper {
260    fn drop(&mut self) {
261        // BufWriter flushes on drop, but surface any error rather than
262        // swallowing it silently.
263        if let Err(e) = self.writer.flush() {
264            eprintln!("iter_dump: failed to flush trace file on drop: {}", e);
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::ipopt_data::IpoptData;
273    use crate::iterates_vector::IteratesVector;
274    use pounce_linalg::dense_vector::DenseVectorSpace;
275    use std::cell::RefCell;
276    use std::rc::Rc;
277
278    fn dense(n: i32, vals: Option<&[Number]>) -> Rc<dyn Vector> {
279        let space = DenseVectorSpace::new(n);
280        let mut dv = space.make_new_dense();
281        if let Some(v) = vals {
282            dv.set_values(v);
283        }
284        Rc::new(dv) as Rc<dyn Vector>
285    }
286
287    #[test]
288    fn write_vec_emits_len_then_values_little_endian() {
289        // Round-trip a small vector through write_vec → in-memory buffer.
290        // We don't have IpoptDataHandle here, so we test write_vec
291        // directly via a tempfile + manual byte-level check.
292        let path =
293            std::env::temp_dir().join(format!("pounce_iter_dump_test_{}.bin", std::process::id()));
294        std::env::set_var(ENV_DUMP_PATH, &path);
295        let mut dumper = IterDumper::from_env().expect("dumper");
296        std::env::remove_var(ENV_DUMP_PATH);
297        let v = dense(3, Some(&[1.0_f64, 2.0, 3.0]));
298        dumper.write_vec(&*v).unwrap();
299        drop(dumper);
300        let bytes = std::fs::read(&path).unwrap();
301        // 4 bytes len (=3) + 3 * 8 bytes values
302        assert_eq!(bytes.len(), 4 + 3 * 8);
303        assert_eq!(&bytes[0..4], &3u32.to_le_bytes());
304        assert_eq!(&bytes[4..12], &1.0_f64.to_le_bytes());
305        assert_eq!(&bytes[12..20], &2.0_f64.to_le_bytes());
306        assert_eq!(&bytes[20..28], &3.0_f64.to_le_bytes());
307        let _ = std::fs::remove_file(&path);
308    }
309
310    #[test]
311    fn from_env_returns_none_when_unset() {
312        std::env::remove_var(ENV_DUMP_PATH);
313        assert!(IterDumper::from_env().is_none());
314    }
315
316    #[test]
317    fn header_writes_magic_and_version() {
318        let path =
319            std::env::temp_dir().join(format!("pounce_iter_dump_hdr_{}.bin", std::process::id()));
320        std::env::set_var(ENV_DUMP_PATH, &path);
321        std::env::set_var(ENV_DUMP_NAME, "hs071");
322        let mut dumper = IterDumper::from_env().expect("dumper");
323        std::env::remove_var(ENV_DUMP_PATH);
324        std::env::remove_var(ENV_DUMP_NAME);
325        dumper.write_header(4, 2).unwrap();
326        drop(dumper);
327        let bytes = std::fs::read(&path).unwrap();
328        assert_eq!(&bytes[0..8], MAGIC);
329        assert_eq!(&bytes[8..12], &1u32.to_le_bytes()); // version
330        assert_eq!(&bytes[12..16], &4u32.to_le_bytes()); // n
331        assert_eq!(&bytes[16..20], &2u32.to_le_bytes()); // m
332        assert_eq!(&bytes[20..24], &0u32.to_le_bytes()); // nnz_jac
333        assert_eq!(&bytes[24..28], &0u32.to_le_bytes()); // nnz_h
334        assert_eq!(&bytes[28..32], &5u32.to_le_bytes()); // name_len
335        assert_eq!(&bytes[32..37], b"hs071");
336        assert_eq!(bytes.len(), 37);
337        let _ = std::fs::remove_file(&path);
338    }
339
340    /// Smoke test: build an IpoptData/Cq pair, write a record, and
341    /// verify the byte count matches FORMAT.md's record size formula.
342    /// Computing CQ values requires an Nlp; this test stays at the
343    /// vector-write layer rather than wiring a full mock NLP.
344    #[test]
345    fn iv_dim_matches_record_layout_assumption() {
346        let iv = IteratesVector::new(
347            dense(4, Some(&[1.0, 2.0, 3.0, 4.0])),
348            dense(1, Some(&[0.5])),
349            dense(1, Some(&[1.0])),
350            dense(1, Some(&[1.0])),
351            dense(4, Some(&[1.0, 1.0, 1.0, 1.0])),
352            dense(4, Some(&[1.0, 1.0, 1.0, 1.0])),
353            dense(1, Some(&[1.0])),
354            dense(0, None),
355        );
356        // hs071 layout per FORMAT.md.
357        assert_eq!(iv.x.dim(), 4);
358        assert_eq!(iv.v_u.dim(), 0);
359        let mut data = IpoptData::new();
360        data.set_curr(iv);
361        let _h: IpoptDataHandle = Rc::new(RefCell::new(data));
362    }
363}