Skip to main content

pounce_algorithm/
iterate_dump.rs

1//! Per-iteration JSONL trace emitter for the studio (issue #68).
2//!
3//! Activated by `--dump iterates:summary` or `--dump iterates:full`
4//! on the CLI (also accepts the legacy `iterate:` spelling); writes
5//! one JSONL line per outer/restoration iteration to the persistent
6//! `iterates.jsonl` stream at `<dump_dir>/iterates.jsonl`.
7//!
8//! Schema is locked by the issue's "Proposed flags" section:
9//!
10//! ```json
11//! {"iter":N,"alpha_pr":..,"alpha_du":..,"tag":"..",
12//!  "restoration":false,"active_mask":"<b64>",
13//!  "x_norm":..,"slack_norm_inf":..,"slack_norm_2":..}
14//! ```
15//!
16//! `full` adds `"x":[..],"slack":[..]`. Vectors are emitted in their
17//! native solver coordinates: `x` is the primal vector and `slack` is
18//! the per-constraint signed residual computed from `curr_c` / `curr_d
19//! - s`.
20//!
21//! Layered on top of `DiagnosticsState::append_iterate_line`, which
22//! owns the buffered file handle.
23
24use crate::ipopt_cq::IpoptCqHandle;
25use crate::ipopt_data::IpoptDataHandle;
26use pounce_common::diagnostics::{DiagCategory, DiagnosticsState, IterateVariant};
27use pounce_common::types::Number;
28use pounce_linalg::dense_vector::DenseVector;
29use pounce_linalg::Vector;
30use std::fmt::Write as _;
31
32/// Emit one iterate record for the current iteration if (a) the
33/// `Iterate` category is enabled in the diagnostics config and (b)
34/// the iter filter matches the current iter. Otherwise no-op.
35///
36/// Must be called *after* `bump_iter`, at the same logical point as
37/// the binary `IterDumper::write_record` (post-init for iter 0,
38/// post-`accept_trial_point` for the per-iter case) so the captured
39/// `iter` field matches `IpData().iter_count()`.
40pub(crate) fn emit_record(diag: &DiagnosticsState, data: &IpoptDataHandle, cq: &IpoptCqHandle) {
41    if !diag.want(DiagCategory::Iterate) {
42        return;
43    }
44    let variant = diag.config.iterate_variant;
45    let json = match build_record(diag, data, cq, variant) {
46        Some(s) => s,
47        None => return,
48    };
49    if let Err(e) = diag.append_iterate_line(&json) {
50        tracing::warn!(target: "pounce::diagnostics",
51            "iterate_dump: failed to append iterate row to iterates.jsonl: {} — continuing",
52            e
53        );
54    }
55}
56
57/// Build the JSON line for one iterate. Returns `None` if `curr` is
58/// not yet set (defensive — shouldn't happen at the documented hook
59/// sites).
60fn build_record(
61    diag: &DiagnosticsState,
62    data: &IpoptDataHandle,
63    cq: &IpoptCqHandle,
64    variant: IterateVariant,
65) -> Option<String> {
66    let iter = diag.current_iter();
67    let restoration = diag.in_restoration();
68    let (alpha_pr, alpha_du, tag, curr_x) = {
69        let d = data.borrow();
70        let curr = d.curr.as_ref()?.clone();
71        (
72            d.info_alpha_primal,
73            d.info_alpha_dual,
74            d.info_alpha_primal_char,
75            curr.x.clone(),
76        )
77    };
78
79    // Constraint-active bitmap. "Active" here = bound-distance below
80    // the current barrier parameter `mu`. Matches "whatever pounce
81    // already computes internally" per issue open-question #3 — the
82    // IPM's complementarity blocks use this same threshold to gauge
83    // proximity to the active set.
84    let mu = data.borrow().curr_mu.max(1e-12);
85    let (active_mask_b64, slack_norm_inf, slack_norm_2, slack_vec) =
86        constraint_active_mask_and_slack(cq, mu);
87
88    let x_norm = vec_inf_norm(&*curr_x);
89
90    let mut out = String::with_capacity(256);
91    out.push('{');
92    write!(out, "\"iter\":{}", iter).ok()?;
93    write!(out, ",\"alpha_pr\":{}", json_f64(alpha_pr)).ok()?;
94    write!(out, ",\"alpha_du\":{}", json_f64(alpha_du)).ok()?;
95    write!(out, ",\"tag\":\"{}\"", escape_tag(tag)).ok()?;
96    write!(
97        out,
98        ",\"restoration\":{}",
99        if restoration { "true" } else { "false" }
100    )
101    .ok()?;
102    write!(out, ",\"active_mask\":\"{}\"", active_mask_b64).ok()?;
103    write!(out, ",\"x_norm\":{}", json_f64(x_norm)).ok()?;
104    write!(out, ",\"slack_norm_inf\":{}", json_f64(slack_norm_inf)).ok()?;
105    write!(out, ",\"slack_norm_2\":{}", json_f64(slack_norm_2)).ok()?;
106
107    if matches!(variant, IterateVariant::Full) {
108        // x: full primal vector.
109        out.push_str(",\"x\":");
110        push_vec_json(&mut out, &*curr_x);
111        // slack: per-constraint signed residual vector (g(x) - bound),
112        // ordered eq-constraints then ineq-constraints.
113        out.push_str(",\"slack\":");
114        push_slice_json(&mut out, &slack_vec);
115    }
116
117    out.push('}');
118    Some(out)
119}
120
121/// Compute the constraint-active bitmap, the inf-norm and 2-norm of
122/// the slack vector, and (for `full`) the slack vector itself.
123///
124/// Slack convention (matches issue contract):
125/// - For equality constraints: `slack[i] = c_i(x)` (signed residual).
126/// - For inequality constraints: `slack[i] = d_i(x) - s_i` (signed
127///   residual of the IPM's slack-reformulation; same quantity Ipopt
128///   uses as `curr_d_minus_s`).
129///
130/// Active-set notion: bit i set iff `|slack[i]| <= mu`. For
131/// equalities every bit is structurally set, but we encode the bound
132/// uniformly so studio readers don't have to special-case.
133fn constraint_active_mask_and_slack(
134    cq: &IpoptCqHandle,
135    tol: Number,
136) -> (String, Number, Number, Vec<Number>) {
137    let c = cq.borrow().curr_c();
138    let d_minus_s = cq.borrow().curr_d_minus_s();
139    let n_eq = c.dim() as usize;
140    let n_ineq = d_minus_s.dim() as usize;
141    let m = n_eq + n_ineq;
142
143    let mut slack = Vec::with_capacity(m);
144    extend_vec_values(&mut slack, &*c);
145    extend_vec_values(&mut slack, &*d_minus_s);
146    debug_assert_eq!(slack.len(), m);
147
148    let inf = slack.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
149    let two = slack.iter().fold(0.0_f64, |acc, &v| acc + v * v).sqrt();
150
151    // Bitmap: m bits, little-endian within each byte (LSB = lowest
152    // constraint index in that byte). Matches the example bytes in
153    // the issue's "Worked example" block.
154    let nbytes = (m + 7) / 8;
155    let mut bits = vec![0u8; nbytes];
156    for (i, &s) in slack.iter().enumerate() {
157        if s.abs() <= tol {
158            bits[i / 8] |= 1 << (i % 8);
159        }
160    }
161    let b64 = base64_encode(&bits);
162    (b64, inf, two, slack)
163}
164
165fn extend_vec_values(out: &mut Vec<Number>, v: &dyn Vector) {
166    if v.dim() == 0 {
167        return;
168    }
169    if let Some(dense) = v.as_any().downcast_ref::<DenseVector>() {
170        // expanded_values() materialises homogeneous backings into a
171        // full slice; for non-homogeneous it returns the stored values.
172        let xs = dense.expanded_values();
173        out.extend_from_slice(&xs);
174        return;
175    }
176    // Defensive copy through a fresh DenseVector if the dyn backing
177    // isn't a DenseVector. POUNCE is dense-only in v1 so this branch
178    // is rare.
179    let mut tmp = v.make_new();
180    tmp.copy(v);
181    if let Some(dense) = tmp.as_any().downcast_ref::<DenseVector>() {
182        out.extend_from_slice(&dense.expanded_values());
183        return;
184    }
185    // Last resort: zeros so caller-side dim invariants hold.
186    out.resize(out.len() + v.dim() as usize, 0.0);
187}
188
189fn vec_inf_norm(v: &dyn Vector) -> Number {
190    if v.dim() == 0 {
191        return 0.0;
192    }
193    v.amax()
194}
195
196fn push_vec_json(out: &mut String, v: &dyn Vector) {
197    let mut buf = Vec::with_capacity(v.dim() as usize);
198    extend_vec_values(&mut buf, v);
199    push_slice_json(out, &buf);
200}
201
202fn push_slice_json(out: &mut String, xs: &[Number]) {
203    out.push('[');
204    let mut first = true;
205    for x in xs {
206        if !first {
207            out.push(',');
208        }
209        first = false;
210        out.push_str(&json_f64(*x));
211    }
212    out.push(']');
213}
214
215/// JSON-safe float encoding. JSON has no NaN/Inf, so non-finite values
216/// emit as `null` (the convention `serde_json` follows). Avoids the
217/// `f64::to_string` "1" → "1.0" coercion concern by relying on Rust's
218/// default `{}` formatter which prints `1` for integral floats — that
219/// matches the issue's worked-example serialisation.
220fn json_f64(x: Number) -> String {
221    if x.is_finite() {
222        // Use `Debug` so integral values print as "1.0" not "1" —
223        // matches the worked example in the issue body.
224        format!("{:?}", x)
225    } else {
226        "null".to_string()
227    }
228}
229
230fn escape_tag(c: char) -> String {
231    match c {
232        ' ' | '\0' => String::new(),
233        '"' => "\\\"".to_string(),
234        '\\' => "\\\\".to_string(),
235        c if (c as u32) < 0x20 => format!("\\u{:04x}", c as u32),
236        c => c.to_string(),
237    }
238}
239
240/// Standard base64 (RFC 4648 §4) with `+ /` and `=` padding. Inlined
241/// to avoid adding a `base64` crate dep for ~40 lines of code.
242fn base64_encode(bytes: &[u8]) -> String {
243    const ALPHA: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
244    let mut out = String::with_capacity(((bytes.len() + 2) / 3) * 4);
245    let mut i = 0;
246    while i + 3 <= bytes.len() {
247        let b0 = bytes[i] as u32;
248        let b1 = bytes[i + 1] as u32;
249        let b2 = bytes[i + 2] as u32;
250        let n = (b0 << 16) | (b1 << 8) | b2;
251        out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
252        out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
253        out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char);
254        out.push(ALPHA[(n & 0x3f) as usize] as char);
255        i += 3;
256    }
257    let rem = bytes.len() - i;
258    if rem == 1 {
259        let b0 = bytes[i] as u32;
260        let n = b0 << 16;
261        out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
262        out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
263        out.push('=');
264        out.push('=');
265    } else if rem == 2 {
266        let b0 = bytes[i] as u32;
267        let b1 = bytes[i + 1] as u32;
268        let n = (b0 << 16) | (b1 << 8);
269        out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
270        out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
271        out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char);
272        out.push('=');
273    }
274    out
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn base64_round_trip_against_known_vectors() {
283        assert_eq!(base64_encode(&[]), "");
284        assert_eq!(base64_encode(&[0x66]), "Zg==");
285        assert_eq!(base64_encode(&[0x66, 0x6f]), "Zm8=");
286        assert_eq!(base64_encode(&[0x66, 0x6f, 0x6f]), "Zm9v");
287        // The issue's worked example shows "AAAAAA==" for an all-zero
288        // 4-byte bitmap and "//8DAA==" for a 4-byte bitmap with the
289        // low 19 bits set.
290        assert_eq!(base64_encode(&[0, 0, 0, 0]), "AAAAAA==");
291        assert_eq!(base64_encode(&[0xff, 0xff, 0x03, 0x00]), "//8DAA==");
292    }
293
294    #[test]
295    fn json_f64_finite_handles_special_cases() {
296        assert_eq!(json_f64(1.0), "1.0");
297        assert_eq!(json_f64(0.0), "0.0");
298        assert_eq!(json_f64(-0.5), "-0.5");
299        assert_eq!(json_f64(f64::NAN), "null");
300        assert_eq!(json_f64(f64::INFINITY), "null");
301        assert_eq!(json_f64(f64::NEG_INFINITY), "null");
302    }
303
304    #[test]
305    fn escape_tag_handles_blank_and_special_chars() {
306        assert_eq!(escape_tag(' '), "");
307        assert_eq!(escape_tag('\0'), "");
308        assert_eq!(escape_tag('f'), "f");
309        assert_eq!(escape_tag('"'), "\\\"");
310        assert_eq!(escape_tag('\\'), "\\\\");
311    }
312}