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}