echidna_optim/piggyback.rs
1use std::fmt;
2
3use echidna::{BytecodeTape, Dual, Float};
4
5/// Reason a piggyback solve failed to converge.
6///
7/// Marked `#[non_exhaustive]` so future variants can be added without
8/// breaking exhaustive `match`es. Numeric fields use `f64` (cast via
9/// `Float::to_f64`) for uniform diagnostic output regardless of the
10/// solver's `F` type.
11#[non_exhaustive]
12#[derive(Debug, Clone)]
13pub enum PiggybackError {
14 /// The primal `z_{k+1} = G(z_k, x)` produced a non-finite norm
15 /// (relative-norm `||z_new - z||/(1 + ||z||)` is NaN/Inf), or
16 /// the primal vector itself contained non-finite components in
17 /// the forward-adjoint loop. `last_norm` is the primal-delta
18 /// relative norm at the detecting iteration: non-finite when
19 /// detection came from the norm check (the usual case);
20 /// finite — and itself diagnostic — when detection came from
21 /// the componentwise finite check (primal vector overflowed
22 /// mid-iteration while the step-to-step delta stayed bounded).
23 PrimalDivergence { iteration: usize, last_norm: f64 },
24 /// Primal stayed finite but the tangent
25 /// `ż_{k+1} = G_z · ż_k + G_x · ẋ` produced non-finite values.
26 /// Catches the ratio-converging case where the primal norm
27 /// remains bounded while individual tangent components overflow.
28 /// `last_norm` is the primal-delta relative norm at the
29 /// detecting iteration — **finite** by construction here (the
30 /// tangent-only divergence path takes the norm-finite branch
31 /// before the componentwise check fires); surfacing it tells
32 /// the caller the primal iteration was bounded while the JVP
33 /// overflowed.
34 TangentDivergence { iteration: usize, last_norm: f64 },
35 /// Adjoint `λ_{k+1} = G_z^T · λ_k + z̄` produced non-finite
36 /// values (norm or individual components). `last_norm` is the
37 /// adjoint-delta relative norm at the detecting iteration:
38 /// non-finite when detection came from the norm check; finite
39 /// when it came from the componentwise `lambda_new` check.
40 AdjointDivergence { iteration: usize, last_norm: f64 },
41 /// `piggyback_tangent_solve` reached `max_iter` before **both** the
42 /// primal and tangent step-deltas met `tol`. `z_norm` is the final
43 /// iteration's relative primal-delta norm
44 /// (`||z_new - z|| / (1 + ||z||)`) — a value just over `tol` signals
45 /// proximity to convergence; many orders over signals stagnation; a
46 /// value **at or below** `tol` means the primal had converged and the
47 /// tangent (which starts at zero and converges on its own schedule)
48 /// was the stream still iterating. `iteration` equals `max_iter`.
49 IterationsExhaustedTangent { iteration: usize, z_norm: f64 },
50 /// `piggyback_adjoint_solve` reached `max_iter` without meeting
51 /// `tol`. `lam_norm` is the final iteration's relative adjoint-
52 /// delta norm (`||λ_new - λ|| / (1 + ||λ||)`).
53 IterationsExhaustedAdjoint { iteration: usize, lam_norm: f64 },
54 /// `piggyback_forward_adjoint_solve` reached `max_iter` without
55 /// meeting `tol` on both norms simultaneously. Each field is the
56 /// final iteration's relative norm for the corresponding stream.
57 IterationsExhaustedForwardAdjoint {
58 iteration: usize,
59 z_norm: f64,
60 lam_norm: f64,
61 },
62 /// A runtime-supplied vector argument to a public `*_solve` fn
63 /// had an unexpected length. `field` names the argument (e.g.
64 /// `"z_dot"`, `"z_bar"`), `expected` is the length the API
65 /// requires (typically `num_states` or `x.len()`), `actual` is
66 /// the length the caller supplied.
67 ///
68 /// Note: tape-shape contract mismatches
69 /// (`validate_step_tape`) and step-fn argument mismatches
70 /// (`piggyback_tangent_step[_with_buf]`) continue to panic —
71 /// those are programmer-contract violations, not recoverable
72 /// runtime failures.
73 DimensionMismatch {
74 field: &'static str,
75 expected: usize,
76 actual: usize,
77 },
78}
79
80impl fmt::Display for PiggybackError {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 PiggybackError::PrimalDivergence {
84 iteration,
85 last_norm,
86 } => {
87 write!(
88 f,
89 "piggyback: primal diverged at iteration {iteration} (last_norm = {last_norm:.3e})"
90 )
91 }
92 PiggybackError::TangentDivergence {
93 iteration,
94 last_norm,
95 } => {
96 write!(
97 f,
98 "piggyback: tangent diverged at iteration {iteration} (last_norm = {last_norm:.3e})"
99 )
100 }
101 PiggybackError::AdjointDivergence {
102 iteration,
103 last_norm,
104 } => {
105 write!(
106 f,
107 "piggyback: adjoint diverged at iteration {iteration} (last_norm = {last_norm:.3e})"
108 )
109 }
110 PiggybackError::IterationsExhaustedTangent { iteration, z_norm } => {
111 write!(
112 f,
113 "piggyback: tangent solve reached max_iter = {iteration} (z_norm = {z_norm:.3e})"
114 )
115 }
116 PiggybackError::IterationsExhaustedAdjoint {
117 iteration,
118 lam_norm,
119 } => {
120 write!(
121 f,
122 "piggyback: adjoint solve reached max_iter = {iteration} (lam_norm = {lam_norm:.3e})"
123 )
124 }
125 PiggybackError::IterationsExhaustedForwardAdjoint {
126 iteration,
127 z_norm,
128 lam_norm,
129 } => {
130 write!(
131 f,
132 "piggyback: forward-adjoint solve reached max_iter = {iteration} (z_norm = {z_norm:.3e}, lam_norm = {lam_norm:.3e})"
133 )
134 }
135 PiggybackError::DimensionMismatch {
136 field,
137 expected,
138 actual,
139 } => {
140 write!(
141 f,
142 "piggyback: dimension mismatch for `{field}` (expected {expected}, got {actual})"
143 )
144 }
145 }
146 }
147}
148
149impl std::error::Error for PiggybackError {}
150
151echidna::assert_send_sync!(PiggybackError);
152
153/// Validate that a step tape G: R^(m+n) -> R^m has the expected shape.
154///
155/// Uses `assert_eq!` (panic) rather than `Result` because shape
156/// mismatches are programmer errors — calling `piggyback_*_solve` with
157/// an inconsistent tape is a contract violation, not a runtime
158/// numerical failure that callers should recover from.
159fn validate_step_tape<F: Float>(tape: &BytecodeTape<F>, z: &[F], x: &[F], num_states: usize) {
160 assert_eq!(z.len(), num_states);
161 assert_eq!(tape.num_inputs(), num_states + x.len());
162 assert_eq!(
163 tape.num_outputs(),
164 num_states,
165 "step tape must have num_outputs == num_states (G: R^(m+n) -> R^m)"
166 );
167}
168
169/// One tangent piggyback step through a fixed-point map G.
170///
171/// Given the iteration `z_{k+1} = G(z_k, x)`, computes both the primal step
172/// and the tangent propagation `ż_{k+1} = G_z · ż_k + G_x · ẋ` in a single
173/// forward pass using dual numbers.
174///
175/// Returns `(z_new, z_dot_new)`.
176pub fn piggyback_tangent_step<F: Float>(
177 step_tape: &BytecodeTape<F>,
178 z: &[F],
179 x: &[F],
180 z_dot: &[F],
181 x_dot: &[F],
182 num_states: usize,
183) -> (Vec<F>, Vec<F>) {
184 let mut buf = Vec::new();
185 piggyback_tangent_step_with_buf(step_tape, z, x, z_dot, x_dot, num_states, &mut buf)
186}
187
188/// One tangent piggyback step, reusing `buf` across calls.
189///
190/// Same as [`piggyback_tangent_step`] but avoids reallocating the internal
191/// dual-number buffer on each call.
192pub fn piggyback_tangent_step_with_buf<F: Float>(
193 step_tape: &BytecodeTape<F>,
194 z: &[F],
195 x: &[F],
196 z_dot: &[F],
197 x_dot: &[F],
198 num_states: usize,
199 buf: &mut Vec<Dual<F>>,
200) -> (Vec<F>, Vec<F>) {
201 validate_step_tape(step_tape, z, x, num_states);
202 let m = num_states;
203 let n = x.len();
204 assert_eq!(z_dot.len(), m, "z_dot length must equal num_states");
205 assert_eq!(x_dot.len(), n, "x_dot length must equal x length");
206
207 // Build dual inputs: [Dual(z_i, ż_i), ..., Dual(x_j, ẋ_j), ...]
208 let mut dual_inputs = Vec::with_capacity(m + n);
209 for i in 0..m {
210 dual_inputs.push(Dual::new(z[i], z_dot[i]));
211 }
212 for j in 0..n {
213 dual_inputs.push(Dual::new(x[j], x_dot[j]));
214 }
215
216 step_tape.forward_tangent(&dual_inputs, buf);
217
218 // Extract outputs: .re -> z_new, .eps -> z_dot_new
219 let out_indices = step_tape.all_output_indices();
220 let mut z_new = Vec::with_capacity(m);
221 let mut z_dot_new = Vec::with_capacity(m);
222 for &idx in out_indices {
223 let d = buf[idx as usize];
224 z_new.push(d.re);
225 z_dot_new.push(d.eps);
226 }
227
228 (z_new, z_dot_new)
229}
230
231/// Tangent piggyback solve: find fixed point z* = G(z*, x) and its tangent ż*.
232///
233/// Iterates the fixed-point map `z_{k+1} = G(z_k, x)` while simultaneously
234/// propagating tangents `ż_{k+1} = G_z · ż_k + G_x · ẋ`.
235///
236/// Returns `Ok((z_star, z_dot_star, iterations))` when **both** iterates
237/// have converged (relative step-delta below `tol` for each). The tangent
238/// starts at zero and converges on its own schedule — its error decays as
239/// `ρᵏ·‖ż*‖` regardless of how close `z0` is to `z*` — so primal
240/// convergence alone would return a truncated Neumann sum for a
241/// warm-started primal. Returns
242/// `Err(PiggybackError::PrimalDivergence)` when the primal norm becomes
243/// non-finite, `Err(PiggybackError::TangentDivergence)` when the primal
244/// stays finite but the tangent overflows (ratio-converging case), or
245/// `Err(PiggybackError::IterationsExhaustedTangent { iteration, z_norm })` when
246/// `max_iter` is reached before both deltas satisfy `tol`.
247pub fn piggyback_tangent_solve<F: Float>(
248 step_tape: &BytecodeTape<F>,
249 z0: &[F],
250 x: &[F],
251 x_dot: &[F],
252 num_states: usize,
253 max_iter: usize,
254 tol: F,
255) -> Result<(Vec<F>, Vec<F>, usize), PiggybackError> {
256 // Runtime vector-length check at solve-level: surfaces dimension
257 // mismatches as `Err` before the first iteration dispatches to the
258 // step fn (which still panics on bad input as a contract-level
259 // guarantee).
260 if x_dot.len() != x.len() {
261 return Err(PiggybackError::DimensionMismatch {
262 field: "x_dot",
263 expected: x.len(),
264 actual: x_dot.len(),
265 });
266 }
267 let m = num_states;
268 let mut z = z0.to_vec();
269 let mut z_dot = vec![F::zero(); m];
270 let mut buf = Vec::new();
271 let mut last_norm: f64 = f64::NAN;
272
273 for k in 0..max_iter {
274 let (z_new, z_dot_new) =
275 piggyback_tangent_step_with_buf(step_tape, &z, x, &z_dot, x_dot, num_states, &mut buf);
276
277 // Relative convergence: ||z_new - z|| / (1 + ||z||)
278 let mut delta_sq = F::zero();
279 let mut z_sq = F::zero();
280 for i in 0..m {
281 let d = z_new[i] - z[i];
282 delta_sq = delta_sq + d * d;
283 z_sq = z_sq + z[i] * z[i];
284 }
285 let norm = delta_sq.sqrt() / (F::one() + z_sq.sqrt());
286 // Variant-mapping order: norm-check first → PrimalDivergence;
287 // tangent-finite check second → TangentDivergence. A non-finite
288 // primal naturally produces a non-finite norm, so it falls into
289 // PrimalDivergence by detection priority.
290 if !norm.is_finite() {
291 return Err(PiggybackError::PrimalDivergence {
292 iteration: k,
293 last_norm: norm.to_f64().unwrap_or(f64::NAN),
294 });
295 }
296 // Detect tangent divergence even when the primal `z_new` itself is
297 // finite: the JVP iteration `z_dot_{k+1} = G_z·z_dot_k + G_x·x_dot`
298 // can produce Inf/NaN tangents that a primal-only norm check misses.
299 if !z_dot_new.iter().all(|v| v.is_finite()) {
300 // `norm` is guaranteed finite here — the `!norm.is_finite()`
301 // branch above would have returned `PrimalDivergence` first.
302 // The debug_assert guards against a future refactor that
303 // reorders these checks and silently invalidates the
304 // `TangentDivergence::last_norm` docstring's "finite by
305 // construction" promise.
306 debug_assert!(
307 norm.is_finite(),
308 "TangentDivergence path must see a finite primal norm"
309 );
310 return Err(PiggybackError::TangentDivergence {
311 iteration: k,
312 last_norm: norm.to_f64().unwrap_or(f64::NAN),
313 });
314 }
315 // Tangent convergence, mirroring the primal's relative-delta form
316 // (and `piggyback_forward_adjoint_solve`'s two-stream gate). The
317 // tangent starts at zero, so its delta shrinks on its own schedule;
318 // gating on the primal alone would return early with a truncated
319 // Neumann sum whenever the primal is warm-started. A non-finite
320 // `tangent_norm` cannot fake convergence (`NaN < tol` and
321 // `Inf < tol` are both false), and non-finite tangents were already
322 // rejected componentwise above.
323 let mut tangent_delta_sq = F::zero();
324 let mut tangent_sq = F::zero();
325 for i in 0..m {
326 let d = z_dot_new[i] - z_dot[i];
327 tangent_delta_sq = tangent_delta_sq + d * d;
328 tangent_sq = tangent_sq + z_dot[i] * z_dot[i];
329 }
330 let tangent_norm = tangent_delta_sq.sqrt() / (F::one() + tangent_sq.sqrt());
331
332 last_norm = norm.to_f64().unwrap_or(f64::NAN);
333 if norm < tol && tangent_norm < tol {
334 return Ok((z_new, z_dot_new, k + 1));
335 }
336
337 z = z_new;
338 z_dot = z_dot_new;
339 }
340
341 Err(PiggybackError::IterationsExhaustedTangent {
342 iteration: max_iter,
343 z_norm: last_norm,
344 })
345}
346
347/// Adjoint piggyback solve at a converged fixed point z* = G(z*, x).
348///
349/// Iterates the adjoint fixed-point equation `λ_{k+1} = G_z^T · λ_k + z̄`
350/// using reverse-mode sweeps through the step tape. At convergence, returns
351/// `x̄ = G_x^T · λ*`.
352///
353/// Requires z* to already be computed (e.g. by the primal solver).
354/// The iteration converges when G is a contraction (‖G_z‖ < 1).
355///
356/// Returns `Ok((x_bar, iterations))` on convergence. Returns
357/// `Err(PiggybackError::AdjointDivergence)` when the adjoint norm is
358/// non-finite or `lambda_new` overflows (ratio-converging case), or
359/// `Err(PiggybackError::IterationsExhaustedAdjoint { iteration, lam_norm })`
360/// when `max_iter` is reached without satisfying `tol`.
361pub fn piggyback_adjoint_solve<F: Float>(
362 step_tape: &mut BytecodeTape<F>,
363 z_star: &[F],
364 x: &[F],
365 z_bar: &[F],
366 num_states: usize,
367 max_iter: usize,
368 tol: F,
369) -> Result<(Vec<F>, usize), PiggybackError> {
370 // Runtime arg-length check first so solve-fn users see
371 // `Err(DimensionMismatch)` in preference to a `validate_step_tape`
372 // panic when both would fire. Matches the check ordering in
373 // `piggyback_tangent_solve`.
374 let m = num_states;
375 if z_bar.len() != m {
376 return Err(PiggybackError::DimensionMismatch {
377 field: "z_bar",
378 expected: m,
379 actual: z_bar.len(),
380 });
381 }
382 validate_step_tape(step_tape, z_star, x, num_states);
383
384 // Set primal values: forward([z*, x])
385 let mut input = Vec::with_capacity(m + x.len());
386 input.extend_from_slice(z_star);
387 input.extend_from_slice(x);
388 step_tape.forward(&input);
389
390 let mut lambda = z_bar.to_vec();
391 let mut last_norm: f64 = f64::NAN;
392
393 for k in 0..max_iter {
394 // reverse_seeded(λ) returns [G_z^T · λ; G_x^T · λ] (length m+n)
395 let adj = step_tape.reverse_seeded(&lambda);
396
397 // λ_new[i] = adj[i] + z_bar[i] for i = 0..m
398 let mut lambda_new = Vec::with_capacity(m);
399 let mut delta_sq = F::zero();
400 let mut lam_sq = F::zero();
401 for i in 0..m {
402 let l_new = adj[i] + z_bar[i];
403 let d = l_new - lambda[i];
404 delta_sq = delta_sq + d * d;
405 lam_sq = lam_sq + lambda[i] * lambda[i];
406 lambda_new.push(l_new);
407 }
408
409 let norm = delta_sq.sqrt() / (F::one() + lam_sq.sqrt());
410 if !norm.is_finite() {
411 return Err(PiggybackError::AdjointDivergence {
412 iteration: k,
413 last_norm: norm.to_f64().unwrap_or(f64::NAN),
414 });
415 }
416 // A ratio-converging iteration with exponentially-growing `lambda`
417 // magnitudes (spectral radius of `G_z^T` ≥ 1) can produce finite
418 // `norm` while `lambda_new` is Inf/NaN. Explicit finite check
419 // catches the divergence regardless of ratio behaviour.
420 if !lambda_new.iter().all(|v| v.is_finite()) {
421 debug_assert!(
422 norm.is_finite(),
423 "AdjointDivergence componentwise path must see a finite norm"
424 );
425 return Err(PiggybackError::AdjointDivergence {
426 iteration: k,
427 last_norm: norm.to_f64().unwrap_or(f64::NAN),
428 });
429 }
430 last_norm = norm.to_f64().unwrap_or(f64::NAN);
431 if norm < tol {
432 // One extra reverse pass with converged lambda to get consistent x_bar.
433 // Without this, adj[m..] uses the pre-convergence lambda, introducing
434 // O(tol * ||G_x||) error.
435 let adj_final = step_tape.reverse_seeded(&lambda_new);
436 return Ok((adj_final[m..].to_vec(), k + 1));
437 }
438
439 lambda = lambda_new;
440 }
441
442 Err(PiggybackError::IterationsExhaustedAdjoint {
443 iteration: max_iter,
444 lam_norm: last_norm,
445 })
446}
447
448/// Interleaved forward-adjoint piggyback solve.
449///
450/// Simultaneously iterates the primal fixed-point `z_{k+1} = G(z_k, x)` and
451/// the adjoint equation `λ_{k+1} = G_z^T · λ_k + z̄`. This cuts the total
452/// iteration count from `K_primal + K_adjoint` to `max(K_primal, K_adjoint)`.
453///
454/// Returns `Ok((z_star, x_bar, iterations))` when both `z` and `λ` converge.
455/// Returns `Err(PiggybackError::PrimalDivergence)` when `z_norm` becomes
456/// non-finite or `z_new` itself contains non-finite components,
457/// `Err(PiggybackError::AdjointDivergence)` when the adjoint norm or
458/// `lambda_new` overflows, or
459/// `Err(PiggybackError::IterationsExhaustedForwardAdjoint { iteration, z_norm, lam_norm })`
460/// when `max_iter` is reached without satisfying `tol`.
461pub fn piggyback_forward_adjoint_solve<F: Float>(
462 step_tape: &mut BytecodeTape<F>,
463 z0: &[F],
464 x: &[F],
465 z_bar: &[F],
466 num_states: usize,
467 max_iter: usize,
468 tol: F,
469) -> Result<(Vec<F>, Vec<F>, usize), PiggybackError> {
470 // Runtime arg-length check first — see note on
471 // `piggyback_adjoint_solve`.
472 let m = num_states;
473 if z_bar.len() != m {
474 return Err(PiggybackError::DimensionMismatch {
475 field: "z_bar",
476 expected: m,
477 actual: z_bar.len(),
478 });
479 }
480 validate_step_tape(step_tape, z0, x, num_states);
481
482 // Pre-allocate input buffer [z, x]
483 let mut input = Vec::with_capacity(m + x.len());
484 input.extend_from_slice(z0);
485 input.extend_from_slice(x);
486
487 let mut lambda = z_bar.to_vec();
488 let mut last_z_norm: f64 = f64::NAN;
489 let mut last_lam_norm: f64 = f64::NAN;
490
491 for k in 0..max_iter {
492 // Forward pass at current z
493 step_tape.forward(&input);
494 let z_new = step_tape.output_values();
495
496 // Reverse pass with current λ
497 let adj = step_tape.reverse_seeded(&lambda);
498
499 // Primal convergence: ||z_new - z|| / (1 + ||z||)
500 let mut z_delta_sq = F::zero();
501 let mut z_sq = F::zero();
502 for i in 0..m {
503 let d = z_new[i] - input[i];
504 z_delta_sq = z_delta_sq + d * d;
505 z_sq = z_sq + input[i] * input[i];
506 }
507 let z_norm = z_delta_sq.sqrt() / (F::one() + z_sq.sqrt());
508 if !z_norm.is_finite() {
509 return Err(PiggybackError::PrimalDivergence {
510 iteration: k,
511 last_norm: z_norm.to_f64().unwrap_or(f64::NAN),
512 });
513 }
514
515 // Adjoint update and convergence: λ_new = G_z^T · λ + z̄
516 let mut lam_delta_sq = F::zero();
517 let mut lam_sq = F::zero();
518 let mut lambda_new = Vec::with_capacity(m);
519 for i in 0..m {
520 let l_new = adj[i] + z_bar[i];
521 let d = l_new - lambda[i];
522 lam_delta_sq = lam_delta_sq + d * d;
523 lam_sq = lam_sq + lambda[i] * lambda[i];
524 lambda_new.push(l_new);
525 }
526 let lam_norm = lam_delta_sq.sqrt() / (F::one() + lam_sq.sqrt());
527 if !lam_norm.is_finite() {
528 return Err(PiggybackError::AdjointDivergence {
529 iteration: k,
530 last_norm: lam_norm.to_f64().unwrap_or(f64::NAN),
531 });
532 }
533 // Same divergence case as the standalone solvers: a ratio-converging
534 // iteration with exponentially-growing lambda magnitudes can produce
535 // finite `lam_norm` while `lambda_new` itself is Inf/NaN.
536 if !lambda_new.iter().all(|v| v.is_finite()) {
537 debug_assert!(
538 lam_norm.is_finite(),
539 "AdjointDivergence componentwise path must see a finite lam_norm"
540 );
541 return Err(PiggybackError::AdjointDivergence {
542 iteration: k,
543 last_norm: lam_norm.to_f64().unwrap_or(f64::NAN),
544 });
545 }
546 // Defense-in-depth: a non-finite `z_new[i]` would typically have
547 // already shown up as `!z_norm.is_finite()` above (the delta/sq
548 // loops touch every index), but guard the componentwise case
549 // explicitly so a future refactor of the norm computation can't
550 // silently lose primal-divergence detection.
551 if !z_new.iter().all(|v| v.is_finite()) {
552 debug_assert!(
553 z_norm.is_finite(),
554 "PrimalDivergence componentwise path must see a finite z_norm"
555 );
556 return Err(PiggybackError::PrimalDivergence {
557 iteration: k,
558 last_norm: z_norm.to_f64().unwrap_or(f64::NAN),
559 });
560 }
561
562 last_z_norm = z_norm.to_f64().unwrap_or(f64::NAN);
563 last_lam_norm = lam_norm.to_f64().unwrap_or(f64::NAN);
564
565 if z_norm < tol && lam_norm < tol {
566 // One extra reverse pass with converged lambda_new to get consistent x_bar,
567 // matching the pattern in piggyback_adjoint_solve.
568 input[..m].copy_from_slice(&z_new[..m]);
569 step_tape.forward(&input);
570 let adj_final = step_tape.reverse_seeded(&lambda_new);
571 return Ok((z_new, adj_final[m..].to_vec(), k + 1));
572 }
573
574 // Update z in the input buffer
575 input[..m].copy_from_slice(&z_new[..m]);
576 lambda = lambda_new;
577 }
578
579 Err(PiggybackError::IterationsExhaustedForwardAdjoint {
580 iteration: max_iter,
581 z_norm: last_z_norm,
582 lam_norm: last_lam_norm,
583 })
584}