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                tracing::warn!(target: "pounce::diagnostics",
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    /// Test-only constructor that opens `path` directly, bypassing the
87    /// process environment. Unit tests must NOT round-trip through
88    /// `from_env()`/`set_var`: the solver hot paths read `POUNCE_DBG_*`
89    /// via `std::env::var` on concurrently-running test threads, and on
90    /// glibc a `setenv` racing those `getenv` calls can hand back a
91    /// corrupted value — which made `from_env` open the wrong path and the
92    /// test read an empty file (the CI flake this replaces).
93    #[cfg(test)]
94    fn for_test(path: &std::path::Path, name: &str) -> std::io::Result<Self> {
95        let file = File::create(path)?;
96        Ok(Self {
97            writer: BufWriter::new(file),
98            header_written: false,
99            name: name.to_string(),
100        })
101    }
102
103    fn write_u32(&mut self, v: u32) -> std::io::Result<()> {
104        self.writer.write_all(&v.to_le_bytes())
105    }
106
107    fn write_f64(&mut self, v: Number) -> std::io::Result<()> {
108        self.writer.write_all(&v.to_le_bytes())
109    }
110
111    fn write_vec(&mut self, v: &dyn Vector) -> std::io::Result<()> {
112        let len = v.dim() as u32;
113        self.write_u32(len)?;
114        if len == 0 {
115            return Ok(());
116        }
117        // Try to grab a contiguous f64 slice from a DenseVector. A
118        // homogeneous DenseVector materialises to a `len`-long expanded
119        // value vector to match upstream's on-disk representation.
120        if let Some(dense) = v.as_any().downcast_ref::<DenseVector>() {
121            if dense.is_homogeneous() {
122                let expanded = dense.expanded_values();
123                for x in &expanded {
124                    self.writer.write_all(&x.to_le_bytes())?;
125                }
126                return Ok(());
127            }
128            // Non-homogeneous DenseVector: write raw little-endian bytes.
129            for x in dense.values() {
130                self.writer.write_all(&x.to_le_bytes())?;
131            }
132            return Ok(());
133        }
134        // Fallback for non-DenseVector backings: this should not occur
135        // in v1.0 (POUNCE is dense-only) but we handle it via a copy
136        // through `make_new` + `copy`, then probe again.
137        let mut tmp = v.make_new();
138        tmp.copy(v);
139        if let Some(dense) = tmp.as_any().downcast_ref::<DenseVector>() {
140            for x in dense.expanded_values().iter() {
141                self.writer.write_all(&x.to_le_bytes())?;
142            }
143            return Ok(());
144        }
145        // Last resort: write zeros (preserves file structure so a
146        // comparator can at least flag the divergence).
147        for _ in 0..len {
148            self.writer.write_all(&0.0_f64.to_le_bytes())?;
149        }
150        Ok(())
151    }
152
153    /// Flush buffered bytes to the underlying file. `Drop` also flushes,
154    /// but that path can only warn on failure; call this when you need to
155    /// observe (and surface) a flush error — e.g. before reading the file
156    /// back in a test.
157    fn flush(&mut self) -> std::io::Result<()> {
158        self.writer.flush()
159    }
160
161    /// Emit the fixed POUNCEIT header. Called once before the first
162    /// record, when `(n, m)` are known.
163    fn write_header(&mut self, n: u32, m: u32) -> std::io::Result<()> {
164        debug_assert!(!self.header_written);
165        self.writer.write_all(MAGIC)?;
166        self.write_u32(FORMAT_VERSION)?;
167        self.write_u32(n)?;
168        self.write_u32(m)?;
169        // nnz_jac, nnz_h: written as 0 to match the patched upstream
170        // Ipopt's v1 behaviour. Comparators treat these as advisory.
171        self.write_u32(0)?;
172        self.write_u32(0)?;
173        let name_len = self.name.len();
174        self.write_u32(name_len as u32)?;
175        let name_bytes = self.name.clone();
176        self.writer.write_all(name_bytes.as_bytes())?;
177        self.header_written = true;
178        Ok(())
179    }
180
181    /// Emit one iteration record. `data` and `cq` must reference the
182    /// post-`accept_trial_point` state (or, for iter 0, the initialised
183    /// `curr` iterate).
184    pub(crate) fn write_record(&mut self, data: &IpoptDataHandle, cq: &IpoptCqHandle) {
185        if let Err(e) = self.write_record_inner(data, cq) {
186            tracing::warn!(target: "pounce::diagnostics",
187                "iter_dump: failed to write iteration record: {} — dumping aborted",
188                e
189            );
190        }
191    }
192
193    fn write_record_inner(
194        &mut self,
195        data: &IpoptDataHandle,
196        cq: &IpoptCqHandle,
197    ) -> std::io::Result<()> {
198        // Snapshot all data we need before any I/O (avoid holding a
199        // borrow across self.writer writes — we don't, but it keeps the
200        // structure clear).
201        let (iter, mu, tau, alpha_pr, alpha_du, delta_x, delta_s, delta_c, delta_d, curr_opt) = {
202            let d = data.borrow();
203            (
204                d.iter_count as u32,
205                d.curr_mu,
206                d.curr_tau,
207                d.info_alpha_primal,
208                d.info_alpha_dual,
209                d.info_regu_x,
210                d.perturbations.delta_s,
211                d.perturbations.delta_c,
212                d.perturbations.delta_d,
213                d.curr.clone(),
214            )
215        };
216        let Some(curr) = curr_opt else {
217            // No `curr` yet (defensive): nothing to write.
218            return Ok(());
219        };
220
221        // CQ-derived scalars — must be computed *outside* a `data`
222        // borrow because CQ accessors take `data.borrow()` themselves.
223        let inf_pr = cq.borrow().curr_primal_infeasibility_max();
224        let inf_du = cq.borrow().curr_dual_infeasibility_max();
225        let constr_viol = cq.borrow().curr_constraint_violation();
226        let dual_inf = inf_du; // alias per FORMAT.md
227                               // FORMAT.md describes `complementarity` as
228                               // `IpCq().curr_complementarity(0.0, NORM_MAX)` — the max-norm
229                               // unbarriered complementarity. We compute it directly from the
230                               // four `curr_compl_*` blocks (the same pieces curr_nlp_error
231                               // already uses).
232        let complementarity = {
233            let cq_ref = cq.borrow();
234            cq_ref
235                .curr_compl_x_l()
236                .amax()
237                .max(cq_ref.curr_compl_x_u().amax())
238                .max(cq_ref.curr_compl_s_l().amax())
239                .max(cq_ref.curr_compl_s_u().amax())
240        };
241        let f_val = cq.borrow().curr_f();
242
243        // Header (lazy-write on first record so we know n/m).
244        if !self.header_written {
245            let n = curr.x.dim() as u32;
246            let m = (curr.y_c.dim() + curr.y_d.dim()) as u32;
247            self.write_header(n, m)?;
248        }
249
250        // Scalar block: u32 iter, u32 status, 14 * f64.
251        self.write_u32(iter)?;
252        self.write_u32(0)?; // status — always 0 ("in progress") in v1
253        self.write_f64(mu)?;
254        self.write_f64(tau)?;
255        self.write_f64(alpha_pr)?;
256        self.write_f64(alpha_du)?;
257        self.write_f64(delta_x)?;
258        self.write_f64(delta_s)?;
259        self.write_f64(delta_c)?;
260        self.write_f64(delta_d)?;
261        self.write_f64(inf_pr)?;
262        self.write_f64(inf_du)?;
263        self.write_f64(constr_viol)?;
264        self.write_f64(dual_inf)?;
265        self.write_f64(complementarity)?;
266        self.write_f64(f_val)?;
267
268        // Iterate vector block: x, s, y_c, y_d, z_L, z_U, v_L, v_U.
269        self.write_vec(&*curr.x)?;
270        self.write_vec(&*curr.s)?;
271        self.write_vec(&*curr.y_c)?;
272        self.write_vec(&*curr.y_d)?;
273        self.write_vec(&*curr.z_l)?;
274        self.write_vec(&*curr.z_u)?;
275        self.write_vec(&*curr.v_l)?;
276        self.write_vec(&*curr.v_u)?;
277
278        // Filter block — advisory in v1, write count=0.
279        self.write_u32(0)?;
280        Ok(())
281    }
282}
283
284impl Drop for IterDumper {
285    fn drop(&mut self) {
286        // BufWriter flushes on drop, but surface any error rather than
287        // swallowing it silently.
288        if let Err(e) = self.flush() {
289            tracing::warn!(target: "pounce::diagnostics", "iter_dump: failed to flush trace file on drop: {}", e);
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::ipopt_data::IpoptData;
298    use crate::iterates_vector::IteratesVector;
299    use pounce_linalg::dense_vector::DenseVectorSpace;
300    use std::cell::RefCell;
301    use std::rc::Rc;
302
303    /// Serializes tests that mutate the process-global `ENV_DUMP_PATH` /
304    /// `ENV_DUMP_NAME` env vars. Without this, parallel tests interleave
305    /// `set_var`/`remove_var` and one test's `from_env()` observes
306    /// another's path. Poison is ignored — a panicking test still
307    /// releases the critical section for the rest.
308    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
309        static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
310        ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner())
311    }
312
313    /// A process- and call-unique temp path. `std::process::id()` alone is
314    /// constant for the whole test binary, so a re-run or any future test
315    /// reusing the same prefix would share one file; the atomic counter
316    /// makes every path unique within the process, removing that footgun.
317    fn unique_temp_path(tag: &str) -> std::path::PathBuf {
318        use std::sync::atomic::{AtomicU64, Ordering};
319        static SEQ: AtomicU64 = AtomicU64::new(0);
320        let n = SEQ.fetch_add(1, Ordering::Relaxed);
321        std::env::temp_dir().join(format!(
322            "pounce_iter_dump_{tag}_{}_{n}.bin",
323            std::process::id()
324        ))
325    }
326
327    fn dense(n: i32, vals: Option<&[Number]>) -> Rc<dyn Vector> {
328        let space = DenseVectorSpace::new(n);
329        let mut dv = space.make_new_dense();
330        if let Some(v) = vals {
331            dv.set_values(v);
332        }
333        Rc::new(dv) as Rc<dyn Vector>
334    }
335
336    #[test]
337    fn write_vec_emits_len_then_values_little_endian() {
338        // Round-trip a small vector through write_vec → tempfile. Open the
339        // file directly (no env) — see `for_test`.
340        let path = unique_temp_path("vec");
341        let mut dumper = IterDumper::for_test(&path, "").expect("dumper");
342        let v = dense(3, Some(&[1.0_f64, 2.0, 3.0]));
343        dumper.write_vec(&*v).unwrap();
344        // Flush explicitly so a write/flush failure surfaces here as a
345        // clear error instead of a mystifying empty-file assert below.
346        dumper.flush().expect("flush");
347        drop(dumper);
348        let bytes = std::fs::read(&path).unwrap();
349        // 4 bytes len (=3) + 3 * 8 bytes values
350        assert_eq!(bytes.len(), 4 + 3 * 8);
351        assert_eq!(&bytes[0..4], &3u32.to_le_bytes());
352        assert_eq!(&bytes[4..12], &1.0_f64.to_le_bytes());
353        assert_eq!(&bytes[12..20], &2.0_f64.to_le_bytes());
354        assert_eq!(&bytes[20..28], &3.0_f64.to_le_bytes());
355        let _ = std::fs::remove_file(&path);
356    }
357
358    #[test]
359    fn from_env_returns_none_when_unset() {
360        // The only test that still touches the process environment, and it
361        // only reads/clears `ENV_DUMP_PATH` (no test sets it anymore), so
362        // there is no setenv/getenv data race with concurrent tests.
363        let _env = env_guard();
364        std::env::remove_var(ENV_DUMP_PATH);
365        assert!(IterDumper::from_env().is_none());
366    }
367
368    #[test]
369    fn header_writes_magic_and_version() {
370        // Open the file directly (no env) — see `for_test`.
371        let path = unique_temp_path("hdr");
372        let mut dumper = IterDumper::for_test(&path, "hs071").expect("dumper");
373        dumper.write_header(4, 2).unwrap();
374        dumper.flush().expect("flush");
375        drop(dumper);
376        let bytes = std::fs::read(&path).unwrap();
377        assert_eq!(&bytes[0..8], MAGIC);
378        assert_eq!(&bytes[8..12], &1u32.to_le_bytes()); // version
379        assert_eq!(&bytes[12..16], &4u32.to_le_bytes()); // n
380        assert_eq!(&bytes[16..20], &2u32.to_le_bytes()); // m
381        assert_eq!(&bytes[20..24], &0u32.to_le_bytes()); // nnz_jac
382        assert_eq!(&bytes[24..28], &0u32.to_le_bytes()); // nnz_h
383        assert_eq!(&bytes[28..32], &5u32.to_le_bytes()); // name_len
384        assert_eq!(&bytes[32..37], b"hs071");
385        assert_eq!(bytes.len(), 37);
386        let _ = std::fs::remove_file(&path);
387    }
388
389    /// Smoke test: build an IpoptData/Cq pair, write a record, and
390    /// verify the byte count matches FORMAT.md's record size formula.
391    /// Computing CQ values requires an Nlp; this test stays at the
392    /// vector-write layer rather than wiring a full mock NLP.
393    #[test]
394    fn iv_dim_matches_record_layout_assumption() {
395        let iv = IteratesVector::new(
396            dense(4, Some(&[1.0, 2.0, 3.0, 4.0])),
397            dense(1, Some(&[0.5])),
398            dense(1, Some(&[1.0])),
399            dense(1, Some(&[1.0])),
400            dense(4, Some(&[1.0, 1.0, 1.0, 1.0])),
401            dense(4, Some(&[1.0, 1.0, 1.0, 1.0])),
402            dense(1, Some(&[1.0])),
403            dense(0, None),
404        );
405        // hs071 layout per FORMAT.md.
406        assert_eq!(iv.x.dim(), 4);
407        assert_eq!(iv.v_u.dim(), 0);
408        let mut data = IpoptData::new();
409        data.set_curr(iv);
410        let _h: IpoptDataHandle = Rc::new(RefCell::new(data));
411    }
412}