Skip to main content

carapace_core/analysis/
arclength.rs

1//! Arc-length continuation (`Integrator::ArcLength`) — the design in
2//! `docs/arclength.md`: a scaled spherical constraint, an oriented
3//! predictor (secant by default, bordered tangent when asked or after a
4//! cutback or sharp turn), full Newton correction of displacement *and*
5//! load factor through the bordered system, coupled backtracking, and
6//! bounded adaptive cutbacks — all transactional, so only an accepted step
7//! ever commits material history or advances continuation state.
8//!
9//! With `q` the independent DOFs, `lambda` the pseudo-time/load parameter,
10//! `p` the reference-load sensitivity, `W`/`beta` the metric:
11//!
12//! ```text
13//! r(q, lambda) = P(lambda) - F_int(q)
14//! g            = dq^T W dq + beta^2 dlambda^2 - s^2      (dq = q - q_n, ...)
15//! [ K              -p              ] [delta q     ]   [ r ]
16//! [ 2 dq^T W   2 beta^2 dlambda    ] [delta lambda] = [-g ]
17//! ```
18
19use nalgebra::DVector;
20use slotmap::Key;
21
22use crate::model::{Domain, ElementOps, NodeId, SparseMatrix};
23
24use super::bordered::{Border, BorderedFactorization, BorderedSolver};
25use super::convergence::ForceTolerance;
26use super::{
27    Algorithm, Analysis, AnalysisError, ArcFailure, ConvergenceTest, StepResult, TangentStrategy,
28};
29
30/// Arc-length continuation settings (`Integrator::ArcLength`). Build with
31/// [`ArcLength::fixed`] or [`ArcLength::adaptive`] and adjust fields from
32/// there; every field is validated when the first step runs.
33#[derive(Debug, Clone, PartialEq)]
34pub struct ArcLength<NId = NodeId> {
35    /// Radius of the first step, in the dimensionless metric.
36    pub initial_radius: f64,
37    /// Cutbacks never go below this radius.
38    pub min_radius: f64,
39    /// Growth never goes above this radius. Setting all three radii equal
40    /// gives a fixed-radius run with no retries or growth.
41    pub max_radius: f64,
42    /// Corrector solves the adaptive radius aims for (`docs/arclength.md`
43    /// §7: `s_next = s * clamp(sqrt(target / N), 0.5, 1.5)`).
44    pub target_iterations: usize,
45    /// Cutbacks allowed per step before the step fails.
46    pub max_retries: usize,
47    /// Initial direction of travel in `lambda`.
48    pub direction: ArcDirection,
49    /// How the metric is scaled.
50    pub scales: ArcScales,
51    /// Predictor used after the first step.
52    pub predictor: ArcPredictor,
53    /// Orients the first tangent when it is singular (or overrides the
54    /// default `direction`-based orientation when given).
55    pub seed: Option<ArcSeed<NId>>,
56    /// Acceptance tolerance on the constraint, `|g| / s^2`.
57    pub arc_tolerance: f64,
58    /// Optional acceptance tolerance on the last undamped coupled
59    /// correction, relative to the radius: `||delta||_M <= tol * s`.
60    pub correction_tolerance: Option<f64>,
61    pub backtracking: Backtracking,
62    pub stop: ArcStopCriteria<NId>,
63}
64
65/// Which way `lambda` moves on the first step.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum ArcDirection {
68    #[default]
69    Increasing,
70    Decreasing,
71}
72
73/// The dimensionless continuation metric `W = diag(1/u*^2 | 1/theta*^2)`,
74/// `beta = 1/lambda*` (`docs/arclength.md` §3).
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub enum ArcScales {
77    /// Characteristic translation `displacement`, rotation `rotation`
78    /// (required when the model has independent rotational DOFs) and load
79    /// factor `load`, all finite and positive.
80    Explicit {
81        displacement: f64,
82        rotation: Option<f64>,
83        load: f64,
84    },
85    /// Derive the translation/rotation scales from the first elastic
86    /// tangent: with `K v = p`, `u* = load * max |v_i|` over independent
87    /// translations (`theta*` likewise over rotations), so a load-factor
88    /// change of `load` and the displacement it causes weigh equally.
89    /// Derived once per phase and reported in `ArcStepInfo`.
90    Auto { load: f64 },
91}
92
93/// Predictor after the first step (`docs/arclength.md` §4).
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum ArcPredictor {
96    /// The normalized last accepted increment: no extra factorization and
97    /// oriented by construction. The bordered tangent is still used for
98    /// the step after a cutback or a sharp turn.
99    #[default]
100    Secant,
101    /// The bordered tangent at the committed state, every step.
102    Tangent,
103}
104
105/// A user-supplied first direction, in physical node/DOF terms plus a load
106/// component. Normalized in the metric and used to orient the first
107/// bordered tangent (not as the predictor itself).
108#[derive(Debug, Clone, PartialEq)]
109pub struct ArcSeed<NId = NodeId> {
110    pub components: Vec<(NId, usize, f64)>,
111    pub load: f64,
112}
113
114/// Coupled backtracking on the merit function (`docs/arclength.md` §5).
115#[derive(Debug, Clone, Copy, PartialEq)]
116pub struct Backtracking {
117    /// Armijo sufficient-decrease constant.
118    pub armijo: f64,
119    /// Smallest step fraction tried before the attempt is rejected.
120    pub min_step: f64,
121}
122
123impl Default for Backtracking {
124    fn default() -> Self {
125        Self {
126            armijo: 1e-4,
127            min_step: 1.0 / 128.0,
128        }
129    }
130}
131
132/// Optional stop criteria (`docs/arclength.md` §7). Any one that is met
133/// ends the phase after the step that met it is accepted; later `step()`
134/// calls return `AnalysisError::ContinuationComplete`.
135#[derive(Debug, Clone, PartialEq, Default)]
136pub struct ArcStopCriteria<NId = NodeId> {
137    pub displacement: Option<DisplacementTarget<NId>>,
138    pub load_factor: Option<LoadFactorTarget>,
139    /// Stop at the first step on which `lambda` crosses zero.
140    pub load_factor_zero_crossing: bool,
141    pub max_chord_length: Option<f64>,
142    pub max_steps: Option<usize>,
143}
144
145/// Stop when `node`'s `dof` reaches `value` (in either direction). With
146/// `exact`, the bracketing step is retaken with an interpolated radius so
147/// the phase ends on the target.
148#[derive(Debug, Clone, Copy, PartialEq)]
149pub struct DisplacementTarget<NId = NodeId> {
150    pub node: NId,
151    pub dof: usize,
152    pub value: f64,
153    pub exact: bool,
154}
155
156/// Stop when `lambda` reaches `value` (in either direction); `exact` as
157/// for `DisplacementTarget`.
158#[derive(Debug, Clone, Copy, PartialEq)]
159pub struct LoadFactorTarget {
160    pub value: f64,
161    pub exact: bool,
162}
163
164/// Per-step continuation diagnostics (`StepResult::arc`).
165#[derive(Debug, Clone, Copy, PartialEq)]
166pub struct ArcStepInfo {
167    /// Radius of the accepted attempt.
168    pub radius: f64,
169    /// Radius proposed for the next step.
170    pub next_radius: f64,
171    /// Cutbacks before the accepted attempt.
172    pub retries: usize,
173    /// Corrector solves in the accepted attempt.
174    pub corrector_solves: usize,
175    /// Bordered factorizations across every attempt, predictors included.
176    pub factorizations: usize,
177    /// Force-test measure at acceptance (`||r||` for `NormUnbalance`,
178    /// the largest scaled component for `Combined`).
179    pub force_measure: f64,
180    /// `|g| / s^2` at acceptance.
181    pub arc_error: f64,
182    /// Accumulated accepted chord length in the metric.
183    pub chord_length: f64,
184    /// Sign of `det K` near the accepted state (from the last bordered
185    /// factorization of the step), when one was available.
186    pub det_sign: Option<i8>,
187    /// `det K` changed sign while `lambda` kept its direction: the step
188    /// probably crossed a bifurcation and stayed on the primary branch.
189    pub bifurcation_suspected: bool,
190    /// The metric's translation scale (explicit or derived).
191    pub displacement_scale: f64,
192    /// The metric's rotation scale, when the model has rotational DOFs.
193    pub rotation_scale: Option<f64>,
194    /// Set on the step that met a stop criterion.
195    pub stop: Option<ArcStop>,
196}
197
198/// Which stop criterion ended the phase, and how closely it was met.
199#[derive(Debug, Clone, Copy, PartialEq)]
200pub struct ArcStop {
201    pub reason: StopReason,
202    /// Whether an exact-landing retake hit the target within tolerance.
203    pub landed_exactly: bool,
204    /// Final value minus target (zero for criteria without a target).
205    pub overshoot: f64,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum StopReason {
210    DisplacementTarget,
211    LoadFactorTarget,
212    LoadFactorZeroCrossing,
213    ChordLength,
214    StepCount,
215}
216
217impl<NId> ArcLength<NId> {
218    /// Fixed radius `radius` with every other setting at its default.
219    pub fn fixed(radius: f64, scales: ArcScales) -> Self {
220        Self::adaptive(radius, radius, radius, scales)
221    }
222
223    /// Adaptive radius in `[min_radius, max_radius]`, starting at
224    /// `initial_radius`, with every other setting at its default.
225    pub fn adaptive(
226        initial_radius: f64,
227        min_radius: f64,
228        max_radius: f64,
229        scales: ArcScales,
230    ) -> Self {
231        Self {
232            initial_radius,
233            min_radius,
234            max_radius,
235            target_iterations: 6,
236            max_retries: 8,
237            direction: ArcDirection::Increasing,
238            scales,
239            predictor: ArcPredictor::Secant,
240            seed: None,
241            arc_tolerance: 1e-8,
242            correction_tolerance: None,
243            backtracking: Backtracking::default(),
244            stop: ArcStopCriteria {
245                displacement: None,
246                load_factor: None,
247                load_factor_zero_crossing: false,
248                max_chord_length: None,
249                max_steps: None,
250            },
251        }
252    }
253
254    /// Checks this configuration together with the algorithm and
255    /// convergence test it will run under — the same checks every arc step
256    /// makes, exposed so wire decoders can reject bad input up front.
257    /// Arc length needs `Newton { tangent: Current, line_search: None }`
258    /// (its coupled backtracking replaces line search) and a force test
259    /// (`NormUnbalance` or `Combined`); displacement/work tests alone don't
260    /// certify equilibrium.
261    pub fn validate_with(
262        &self,
263        algorithm: &Algorithm,
264        test: &ConvergenceTest,
265    ) -> Result<(), AnalysisError> {
266        self.validate()?;
267        test.validate()?;
268        match algorithm {
269            Algorithm::Newton {
270                tangent: TangentStrategy::Current,
271                line_search: None,
272            } => {}
273            _ => return Err(AnalysisError::InvalidOption { field: "algorithm" }),
274        }
275        if !matches!(
276            test,
277            ConvergenceTest::NormUnbalance { .. } | ConvergenceTest::Combined { .. }
278        ) {
279            return Err(AnalysisError::InvalidOption {
280                field: "convergence",
281            });
282        }
283        Ok(())
284    }
285
286    /// Range checks on this configuration alone.
287    pub fn validate(&self) -> Result<(), AnalysisError> {
288        let positive = |v: f64| v.is_finite() && v > 0.0;
289        let invalid = |field| Err(AnalysisError::InvalidOption { field });
290        if !positive(self.min_radius) {
291            return invalid("integrator.minRadius");
292        }
293        if !positive(self.initial_radius) || self.initial_radius < self.min_radius {
294            return invalid("integrator.initialRadius");
295        }
296        if !positive(self.max_radius) || self.max_radius < self.initial_radius {
297            return invalid("integrator.maxRadius");
298        }
299        if self.target_iterations == 0 {
300            return invalid("integrator.targetIterations");
301        }
302        if self.max_retries > 64 {
303            return invalid("integrator.maxRetries");
304        }
305        match self.scales {
306            ArcScales::Explicit {
307                displacement,
308                rotation,
309                load,
310            } => {
311                if !positive(displacement) {
312                    return invalid("integrator.displacementScale");
313                }
314                if rotation.is_some_and(|r| !positive(r)) {
315                    return invalid("integrator.rotationScale");
316                }
317                if !positive(load) {
318                    return invalid("integrator.loadScale");
319                }
320            }
321            ArcScales::Auto { load } => {
322                if !positive(load) {
323                    return invalid("integrator.loadScale");
324                }
325            }
326        }
327        if !positive(self.arc_tolerance) {
328            return invalid("integrator.arcTolerance");
329        }
330        if self.correction_tolerance.is_some_and(|t| !positive(t)) {
331            return invalid("integrator.correctionTolerance");
332        }
333        let Backtracking { armijo, min_step } = self.backtracking;
334        if !(armijo.is_finite() && armijo > 0.0 && armijo < 0.5) {
335            return invalid("integrator.backtracking.armijo");
336        }
337        if !(min_step.is_finite() && min_step > 0.0 && min_step <= 1.0) {
338            return invalid("integrator.backtracking.minStep");
339        }
340        if let Some(seed) = &self.seed {
341            if !seed.load.is_finite() || seed.components.iter().any(|c| !c.2.is_finite()) {
342                return invalid("integrator.seed");
343            }
344        }
345        let stop = &self.stop;
346        if stop
347            .displacement
348            .as_ref()
349            .is_some_and(|t| !t.value.is_finite())
350        {
351            return invalid("integrator.stop.displacement");
352        }
353        if stop.load_factor.is_some_and(|t| !t.value.is_finite()) {
354            return invalid("integrator.stop.loadFactor");
355        }
356        if stop.max_chord_length.is_some_and(|c| !positive(c)) {
357            return invalid("integrator.stop.maxChordLength");
358        }
359        if stop.max_steps == Some(0) {
360            return invalid("integrator.stop.maxSteps");
361        }
362        Ok(())
363    }
364
365    /// Whether switching from `self` to `other` keeps the metric and load
366    /// definition, so accepted continuation history stays valid. Radius
367    /// bounds, retry/iteration budgets, tolerances and stop criteria may
368    /// change freely.
369    pub(crate) fn same_path_definition(&self, other: &Self) -> bool
370    where
371        NId: PartialEq,
372    {
373        self.scales == other.scales
374            && self.direction == other.direction
375            && self.seed == other.seed
376            && self.predictor == other.predictor
377    }
378}
379
380/// Accepted continuation history for one phase (`docs/arclength.md` §4/§7),
381/// snapshotted with the domain so a failed step restores it exactly.
382#[derive(Debug, Clone)]
383pub(crate) struct ArcState {
384    weights: DVector<f64>,
385    beta: f64,
386    /// `C` for the bordered system: `u*`/`theta*` per equation, then
387    /// `lambda*`.
388    unknown_scale: DVector<f64>,
389    displacement_scale: f64,
390    rotation_scale: Option<f64>,
391    rotational: Vec<bool>,
392    /// Metric-normalized last accepted increment (`n+1` components).
393    direction: Option<DVector<f64>>,
394    /// Metric-normalized seed, consumed by the first step.
395    seed: Option<DVector<f64>>,
396    last_delta_lambda: f64,
397    next_radius: f64,
398    chord_length: f64,
399    accepted_steps: usize,
400    force_tangent_next: bool,
401    det_sign: Option<i8>,
402    stopped: bool,
403}
404
405/// One attempt's accepted state, before it is committed.
406struct Accepted {
407    /// Tangent at the accepted state, for the determinant-sign fallback.
408    k: SparseMatrix,
409    delta_lambda: f64,
410    corrector_solves: usize,
411    damped: bool,
412    force_measure: f64,
413    arc_error: f64,
414    det_sign: Option<i8>,
415    increment: DVector<f64>,
416}
417
418/// Everything an attempt needs that stays fixed for one step.
419struct StepContext<'a> {
420    q_n: &'a DVector<f64>,
421    lambda_n: f64,
422    p: &'a DVector<f64>,
423    external_n: &'a DVector<f64>,
424    internal_n: &'a DVector<f64>,
425    predictor: &'a DVector<f64>,
426}
427
428impl ArcState {
429    /// Called when `set_integrator` installs an `ArcLength` with the same
430    /// path definition: keeps the metric, direction, radius and chord
431    /// length, but clears a previous stop and restarts the step count, so
432    /// new stop criteria can continue the same path.
433    pub(crate) fn reopen(&mut self) {
434        self.stopped = false;
435        self.accepted_steps = 0;
436    }
437
438    fn metric_dot(&self, a: &DVector<f64>, b: &DVector<f64>) -> f64 {
439        let n = self.weights.len();
440        let mut sum = self.beta * self.beta * a[n] * b[n];
441        for i in 0..n {
442            sum += self.weights[i] * a[i] * b[i];
443        }
444        sum
445    }
446
447    fn metric_norm(&self, a: &DVector<f64>) -> f64 {
448        self.metric_dot(a, a).sqrt()
449    }
450}
451
452/// `true` when continuation history must be discarded because the
453/// integrator change alters the path definition (or leaves arc length).
454pub(crate) fn integrator_change_invalidates<NId: PartialEq>(
455    old: &super::Integrator<NId>,
456    new: &super::Integrator<NId>,
457) -> bool {
458    match (old, new) {
459        (super::Integrator::ArcLength(a), super::Integrator::ArcLength(b)) => {
460            !a.same_path_definition(b)
461        }
462        _ => true,
463    }
464}
465
466/// Dense `(n+1)` concatenation `(q, lambda)`.
467fn join(q: &DVector<f64>, lambda: f64) -> DVector<f64> {
468    let n = q.len();
469    DVector::from_fn(n + 1, |i, _| if i < n { q[i] } else { lambda })
470}
471
472fn unit_last(n: usize) -> DVector<f64> {
473    DVector::from_fn(n + 1, |i, _| if i == n { 1.0 } else { 0.0 })
474}
475
476fn all_finite(v: &DVector<f64>) -> bool {
477    v.iter().all(|x| x.is_finite())
478}
479
480impl<const NDIM: usize, const NDOF: usize, NId, E> Analysis<NDIM, NDOF, NId, E>
481where
482    NId: Key,
483    E: ElementOps<NDIM, NDOF, NId> + Clone,
484    E::Load: Clone,
485{
486    /// One arc-length step (`Analysis::step` dispatches here). On any error
487    /// the domain, continuation history, step count and load factor are
488    /// restored exactly; only success commits material history.
489    pub(crate) fn arc_step(
490        &mut self,
491        config: &ArcLength<NId>,
492    ) -> Result<StepResult, AnalysisError> {
493        if self.arc.as_ref().is_some_and(|state| state.stopped) {
494            return Err(AnalysisError::ContinuationComplete);
495        }
496        config.validate_with(&self.algorithm, &self.test)?;
497
498        let snapshot = self.domain.clone();
499        let snapshot_arc = self.arc.clone();
500        let (snapshot_step_count, snapshot_load_factor) = (self.step_count, self.load_factor);
501
502        let result = self.try_arc_step(config);
503        match result {
504            Ok(step) => {
505                self.domain.commit(self.load_factor);
506                Ok(step)
507            }
508            Err(error) => {
509                self.domain = snapshot;
510                self.arc = snapshot_arc;
511                self.step_count = snapshot_step_count;
512                self.load_factor = snapshot_load_factor;
513                Err(error)
514            }
515        }
516    }
517
518    fn try_arc_step(&mut self, config: &ArcLength<NId>) -> Result<StepResult, AnalysisError> {
519        let lambda_n = self.load_factor;
520        let p = self.domain.assemble_reference_load_sensitivity(lambda_n);
521        let (k_n, internal_n) = self.domain.assemble_tangent_and_resistance(lambda_n);
522        let external_n = self.domain.assemble_reference_load(lambda_n);
523        let q_n = self.domain.gather_displacement();
524        let mut factorizations = 0;
525
526        if self.arc.is_none() {
527            let state = self.initialize_arc(
528                config,
529                &p,
530                &k_n,
531                &internal_n,
532                &external_n,
533                &mut factorizations,
534            )?;
535            self.arc = Some(Box::new(state));
536        }
537        self.step_count += 1;
538        let step = self.step_count;
539
540        let state = self.arc.as_ref().expect("initialized above").clone();
541        let n = q_n.len();
542
543        // Predictor direction for this step (independent of the radius, so
544        // computed once and shared by every attempt).
545        let tangent_wanted = state.direction.is_none()
546            || config.predictor == ArcPredictor::Tangent
547            || state.force_tangent_next;
548        let predictor = if tangent_wanted {
549            let t_prev = match (&state.direction, &state.seed) {
550                (Some(direction), _) => direction.clone(),
551                (None, Some(seed)) => seed.clone(),
552                (None, None) => {
553                    let sign = match config.direction {
554                        ArcDirection::Increasing => 1.0,
555                        ArcDirection::Decreasing => -1.0,
556                    };
557                    unit_last(n) * (sign / state.beta)
558                }
559            };
560            factorizations += 1;
561            match bordered_tangent(&mut self.bordered, &state, &k_n, &p, &t_prev) {
562                Ok((t, _det_at_committed_state)) => t,
563                Err(failure) => match &state.direction {
564                    Some(direction) => direction.clone(),
565                    None if state.seed.is_none() && failure == ArcFailure::SingularSystem => {
566                        return Err(AnalysisError::MissingSeedDirection)
567                    }
568                    None => {
569                        return Err(AnalysisError::CutbacksExhausted {
570                            step,
571                            attempts: 0,
572                            radius: state.next_radius,
573                            last_failure: failure,
574                        })
575                    }
576                },
577            }
578        } else {
579            state
580                .direction
581                .clone()
582                .expect("secant needs a previous step")
583        };
584
585        let context = StepContext {
586            q_n: &q_n,
587            lambda_n,
588            p: &p,
589            external_n: &external_n,
590            internal_n: &internal_n,
591            predictor: &predictor,
592        };
593
594        let start = self.domain.clone();
595        let mut radius = state
596            .next_radius
597            .clamp(config.min_radius, config.max_radius);
598        let mut retries = 0;
599        let mut attempts = 0;
600        let accepted = loop {
601            attempts += 1;
602            let outcome = attempt(
603                &mut self.domain,
604                &mut self.bordered,
605                &state,
606                config,
607                &self.test,
608                &context,
609                radius,
610                &mut factorizations,
611            );
612            match outcome {
613                Ok(accepted) => break accepted,
614                Err(failure) => {
615                    self.domain = start.clone();
616                    let exhausted = retries >= config.max_retries || radius <= config.min_radius;
617                    if exhausted {
618                        return Err(AnalysisError::CutbacksExhausted {
619                            step,
620                            attempts,
621                            radius,
622                            last_failure: failure,
623                        });
624                    }
625                    radius = (radius * 0.5).max(config.min_radius);
626                    retries += 1;
627                }
628            }
629        };
630
631        let mut accepted = accepted;
632        let mut radius_used = radius;
633        self.load_factor = lambda_n + accepted.delta_lambda;
634
635        // Stop criteria, with optional exact landing on a bracketed target.
636        let mut stop = None;
637        let stop_criteria = &config.stop;
638        if let Some(target) = stop_criteria.displacement {
639            let eq_value = |domain: &Domain<NDIM, NDOF, NId, E>| {
640                domain.node(target.node).displacement[target.dof]
641            };
642            let before = start.node(target.node).displacement[target.dof];
643            let after = eq_value(&self.domain);
644            if brackets(before, after, target.value) {
645                let landed = target.exact
646                    && self.land_exactly(
647                        &start,
648                        &state,
649                        config,
650                        &context,
651                        &mut accepted,
652                        &mut radius_used,
653                        &mut factorizations,
654                        before,
655                        after,
656                        target.value,
657                        &eq_value,
658                    );
659                stop = Some(ArcStop {
660                    reason: StopReason::DisplacementTarget,
661                    landed_exactly: landed,
662                    overshoot: eq_value(&self.domain) - target.value,
663                });
664            }
665        }
666        if stop.is_none() {
667            if let Some(target) = stop_criteria.load_factor {
668                let after = self.load_factor;
669                if brackets(lambda_n, after, target.value) {
670                    let landed = target.exact
671                        && self.land_exactly_on_load(
672                            &start,
673                            &state,
674                            config,
675                            &context,
676                            &mut accepted,
677                            &mut radius_used,
678                            &mut factorizations,
679                            after,
680                            target.value,
681                        );
682                    stop = Some(ArcStop {
683                        reason: StopReason::LoadFactorTarget,
684                        landed_exactly: landed,
685                        overshoot: self.load_factor - target.value,
686                    });
687                }
688            }
689        }
690        if stop.is_none()
691            && stop_criteria.load_factor_zero_crossing
692            && lambda_n != 0.0
693            && (self.load_factor == 0.0 || self.load_factor.signum() != lambda_n.signum())
694        {
695            stop = Some(ArcStop {
696                reason: StopReason::LoadFactorZeroCrossing,
697                landed_exactly: false,
698                overshoot: self.load_factor,
699            });
700        }
701
702        // Commit continuation history for the accepted increment.
703        let norm = state.metric_norm(&accepted.increment);
704        let new_direction = &accepted.increment / norm;
705        let sharp_turn = state
706            .direction
707            .as_ref()
708            .is_some_and(|old| state.metric_dot(old, &new_direction) < SHARP_TURN_COS);
709        // From the step's last corrector factorization; when the predictor
710        // was accepted outright, factor once at the accepted state (a sign
711        // carried over from earlier steps would hide a crossing).
712        let det_sign = accepted.det_sign.or_else(|| {
713            factorizations += 1;
714            accepted_det_sign(&mut self.bordered, &accepted.k, &p)
715        });
716        let state = self.arc.as_mut().expect("initialized above");
717        let bifurcation_suspected = matches!(
718            (state.det_sign, det_sign),
719            (Some(old), Some(new)) if old != 0 && new != 0 && old != new
720        ) && state.last_delta_lambda != 0.0
721            && accepted.delta_lambda.signum() == state.last_delta_lambda.signum();
722
723        let grow = retries == 0 && !accepted.damped && !sharp_turn;
724        let factor = (config.target_iterations as f64 / accepted.corrector_solves.max(1) as f64)
725            .sqrt()
726            .clamp(0.5, 1.5);
727        let factor = if grow { factor } else { factor.min(1.0) };
728        state.next_radius = (radius_used * factor).clamp(config.min_radius, config.max_radius);
729        state.direction = Some(new_direction);
730        state.seed = None;
731        state.last_delta_lambda = accepted.delta_lambda;
732        state.chord_length += norm;
733        state.accepted_steps += 1;
734        state.force_tangent_next = retries > 0 || sharp_turn;
735        state.det_sign = det_sign;
736
737        if stop.is_none()
738            && stop_criteria
739                .max_chord_length
740                .is_some_and(|c| state.chord_length >= c)
741        {
742            stop = Some(ArcStop {
743                reason: StopReason::ChordLength,
744                landed_exactly: false,
745                overshoot: 0.0,
746            });
747        }
748        if stop.is_none()
749            && stop_criteria
750                .max_steps
751                .is_some_and(|m| state.accepted_steps >= m)
752        {
753            stop = Some(ArcStop {
754                reason: StopReason::StepCount,
755                landed_exactly: false,
756                overshoot: 0.0,
757            });
758        }
759        state.stopped = stop.is_some();
760
761        let info = super::ArcStepInfo {
762            radius: radius_used,
763            next_radius: state.next_radius,
764            retries,
765            corrector_solves: accepted.corrector_solves,
766            factorizations,
767            force_measure: accepted.force_measure,
768            arc_error: accepted.arc_error,
769            chord_length: state.chord_length,
770            det_sign,
771            bifurcation_suspected,
772            displacement_scale: state.displacement_scale,
773            rotation_scale: state.rotation_scale,
774            stop,
775        };
776        Ok(StepResult {
777            step,
778            load_factor: self.load_factor,
779            iterations: accepted.corrector_solves,
780            factorizations,
781            arc: Some(info),
782        })
783    }
784
785    /// Validates the load definition and entry equilibrium, builds the
786    /// metric, and resolves the seed (`docs/arclength.md` §3–§4).
787    fn initialize_arc(
788        &mut self,
789        config: &ArcLength<NId>,
790        p: &DVector<f64>,
791        k_n: &SparseMatrix,
792        internal_n: &DVector<f64>,
793        external_n: &DVector<f64>,
794        factorizations: &mut usize,
795    ) -> Result<ArcState, AnalysisError> {
796        if self.domain.has_unfrozen_path_series() {
797            return Err(AnalysisError::UnsupportedLoadSeries);
798        }
799        if p.amax() == 0.0 {
800            return Err(AnalysisError::ZeroLoadSensitivity);
801        }
802        let n = p.len();
803        let rotational = self.domain.rotational_equations();
804        let has_rotation = rotational.iter().any(|&r| r);
805        let has_translation = rotational.iter().any(|&r| !r);
806
807        let residual = external_n - internal_n;
808        let (balanced, measure) = force_check(
809            &self.test,
810            &rotational,
811            &residual,
812            &[external_n, internal_n],
813        );
814        if !balanced {
815            return Err(AnalysisError::InitialStateNotInEquilibrium { measure });
816        }
817
818        let (displacement_scale, rotation_scale, load_scale) = match config.scales {
819            ArcScales::Explicit {
820                displacement,
821                rotation,
822                load,
823            } => {
824                if has_rotation && rotation.is_none() {
825                    return Err(AnalysisError::InvalidOption {
826                        field: "integrator.rotationScale",
827                    });
828                }
829                (displacement, rotation.filter(|_| has_rotation), load)
830            }
831            ArcScales::Auto { load } => {
832                // `K v = p` through the bordered system with a pure-load
833                // row: x_lambda = 1, K x_q = p.
834                let zeros = DVector::zeros(n);
835                let minus_p = -p;
836                *factorizations += 1;
837                let factor = self
838                    .bordered
839                    .factor(
840                        k_n,
841                        Border {
842                            column: &minus_p,
843                            row: &zeros,
844                            corner: 1.0,
845                        },
846                        &DVector::from_element(n + 1, 1.0),
847                    )
848                    .map_err(|_| AnalysisError::SingularSystem)?;
849                let v = factor
850                    .solve(&unit_last(n))
851                    .map_err(|_| AnalysisError::SingularSystem)?;
852                let largest = |want_rotation: bool| {
853                    (0..n)
854                        .filter(|&i| rotational[i] == want_rotation)
855                        .map(|i| v[i].abs())
856                        .fold(0.0, f64::max)
857                };
858                let displacement = load * largest(false);
859                let rotation = has_rotation.then(|| load * largest(true));
860                let usable = |s: f64| s.is_finite() && s > 0.0;
861                if has_translation && !usable(displacement) {
862                    return Err(AnalysisError::InvalidOption {
863                        field: "integrator.displacementScale",
864                    });
865                }
866                if rotation.is_some_and(|r| !usable(r)) {
867                    return Err(AnalysisError::InvalidOption {
868                        field: "integrator.rotationScale",
869                    });
870                }
871                (
872                    if has_translation { displacement } else { 1.0 },
873                    rotation,
874                    load,
875                )
876            }
877        };
878
879        let unknown_scale = DVector::from_fn(n + 1, |i, _| {
880            if i == n {
881                load_scale
882            } else if rotational[i] {
883                rotation_scale.expect("rotation scale required above")
884            } else {
885                displacement_scale
886            }
887        });
888        let weights = DVector::from_fn(n, |i, _| unknown_scale[i].powi(-2));
889        let beta = 1.0 / load_scale;
890
891        let mut state = ArcState {
892            weights,
893            beta,
894            unknown_scale,
895            displacement_scale,
896            rotation_scale,
897            rotational,
898            direction: None,
899            seed: None,
900            last_delta_lambda: 0.0,
901            next_radius: config.initial_radius,
902            chord_length: 0.0,
903            accepted_steps: 0,
904            force_tangent_next: false,
905            det_sign: None,
906            stopped: false,
907        };
908
909        if let Some(seed) = &config.seed {
910            let mut q = DVector::zeros(n);
911            for &(node, dof, value) in &seed.components {
912                if dof >= NDOF {
913                    return Err(AnalysisError::InvalidOption {
914                        field: "integrator.seed",
915                    });
916                }
917                let eq =
918                    self.domain
919                        .equation_of(node, dof)
920                        .ok_or(AnalysisError::InvalidOption {
921                            field: "integrator.seed",
922                        })?;
923                q[eq] += value;
924            }
925            let direction = join(&q, seed.load);
926            let norm = state.metric_norm(&direction);
927            if !(norm.is_finite() && norm > 0.0) {
928                return Err(AnalysisError::InvalidOption {
929                    field: "integrator.seed",
930                });
931            }
932            state.seed = Some(direction / norm);
933        }
934        Ok(state)
935    }
936
937    /// Retakes the bracketing step with interpolated radii (regula falsi on
938    /// the radius) until `value_of` hits `target`, keeping the original
939    /// bracketing step if that doesn't succeed. Returns whether it landed.
940    #[allow(clippy::too_many_arguments)]
941    fn land_exactly(
942        &mut self,
943        start: &Domain<NDIM, NDOF, NId, E>,
944        state: &ArcState,
945        config: &ArcLength<NId>,
946        context: &StepContext<'_>,
947        accepted: &mut Accepted,
948        radius_used: &mut f64,
949        factorizations: &mut usize,
950        before: f64,
951        after: f64,
952        target: f64,
953        value_of: &impl Fn(&Domain<NDIM, NDOF, NId, E>) -> f64,
954    ) -> bool {
955        self.retake_until(
956            start,
957            state,
958            config,
959            context,
960            accepted,
961            radius_used,
962            factorizations,
963            before,
964            after,
965            target,
966            &|domain, _lambda| value_of(domain),
967        )
968    }
969
970    #[allow(clippy::too_many_arguments)]
971    fn land_exactly_on_load(
972        &mut self,
973        start: &Domain<NDIM, NDOF, NId, E>,
974        state: &ArcState,
975        config: &ArcLength<NId>,
976        context: &StepContext<'_>,
977        accepted: &mut Accepted,
978        radius_used: &mut f64,
979        factorizations: &mut usize,
980        after: f64,
981        target: f64,
982    ) -> bool {
983        let before = context.lambda_n;
984        self.retake_until(
985            start,
986            state,
987            config,
988            context,
989            accepted,
990            radius_used,
991            factorizations,
992            before,
993            after,
994            target,
995            &|_domain, lambda| lambda,
996        )
997    }
998
999    #[allow(clippy::too_many_arguments)]
1000    fn retake_until(
1001        &mut self,
1002        start: &Domain<NDIM, NDOF, NId, E>,
1003        state: &ArcState,
1004        config: &ArcLength<NId>,
1005        context: &StepContext<'_>,
1006        accepted: &mut Accepted,
1007        radius_used: &mut f64,
1008        factorizations: &mut usize,
1009        before: f64,
1010        after: f64,
1011        target: f64,
1012        value_of: &impl Fn(&Domain<NDIM, NDOF, NId, E>, f64) -> f64,
1013    ) -> bool {
1014        let tolerance = LANDING_TOLERANCE * (after - before).abs().max(f64::MIN_POSITIVE);
1015        if (after - target).abs() <= tolerance {
1016            return true;
1017        }
1018        let bracket_domain = self.domain.clone();
1019        let bracket_lambda = self.load_factor;
1020
1021        // f(s) = value(s) - target, bracketed on [0, s_used].
1022        let (mut s_lo, mut f_lo) = (0.0, before - target);
1023        let (mut s_hi, mut f_hi) = (*radius_used, after - target);
1024        for _ in 0..MAX_LANDING_RETAKES {
1025            let s = s_lo + (s_hi - s_lo) * (-f_lo) / (f_hi - f_lo);
1026            if !(s.is_finite() && s > 0.0) {
1027                break;
1028            }
1029            self.domain = start.clone();
1030            let mut retake_factorizations = 0;
1031            let Ok(retaken) = attempt(
1032                &mut self.domain,
1033                &mut self.bordered,
1034                state,
1035                config,
1036                &self.test,
1037                context,
1038                s,
1039                &mut retake_factorizations,
1040            ) else {
1041                *factorizations += retake_factorizations;
1042                break;
1043            };
1044            *factorizations += retake_factorizations;
1045            let lambda = context.lambda_n + retaken.delta_lambda;
1046            let f = value_of(&self.domain, lambda) - target;
1047            if f.abs() <= tolerance {
1048                self.load_factor = lambda;
1049                *accepted = retaken;
1050                *radius_used = s;
1051                return true;
1052            }
1053            if f.signum() == f_lo.signum() {
1054                s_lo = s;
1055                f_lo = f;
1056            } else {
1057                s_hi = s;
1058                f_hi = f;
1059            }
1060        }
1061        self.domain = bracket_domain;
1062        self.load_factor = bracket_lambda;
1063        false
1064    }
1065}
1066
1067/// Turn (in the metric) beyond which the next step suppresses radius growth
1068/// and uses the bordered tangent: about 30 degrees.
1069const SHARP_TURN_COS: f64 = 0.866;
1070/// Orientation guard: the accepted increment must satisfy
1071/// `d^T M t >= ORIENTATION_EPS * s^2` (`docs/arclength.md` §4).
1072const ORIENTATION_EPS: f64 = 1e-6;
1073/// Exact landing hits the target to this fraction of the bracketing step's
1074/// change in the controlled quantity.
1075const LANDING_TOLERANCE: f64 = 1e-8;
1076const MAX_LANDING_RETAKES: usize = 6;
1077/// Iterations over which the merit function must at least halve.
1078const STAGNATION_WINDOW: usize = 5;
1079
1080fn brackets(before: f64, after: f64, target: f64) -> bool {
1081    before != target && (before - target) * (after - target) <= 0.0
1082}
1083
1084/// The force half of acceptance: `(passes, measure)`.
1085fn force_check(
1086    test: &ConvergenceTest,
1087    rotational: &[bool],
1088    residual: &DVector<f64>,
1089    magnitudes: &[&DVector<f64>],
1090) -> (bool, f64) {
1091    match *test {
1092        ConvergenceTest::NormUnbalance { tol, .. } => {
1093            let norm = residual.norm();
1094            (norm < tol, norm)
1095        }
1096        _ => {
1097            let tolerance = ForceTolerance::for_test(test, rotational, magnitudes)
1098                .expect("arc length only accepts force tests");
1099            let measure = tolerance.scaled_max(residual);
1100            (measure <= 1.0, measure)
1101        }
1102    }
1103}
1104
1105/// The bordered tangent at the committed state, oriented by `t_prev`:
1106/// `[K -p; (M t_prev)^T] v = e_{n+1}`, `t = v / ||v||_M`. Also returns the
1107/// sign of `det K` (Cramer: `v_lambda = det K / det A`).
1108fn bordered_tangent(
1109    bordered: &mut BorderedSolver,
1110    state: &ArcState,
1111    k: &SparseMatrix,
1112    p: &DVector<f64>,
1113    t_prev: &DVector<f64>,
1114) -> Result<(DVector<f64>, Option<i8>), ArcFailure> {
1115    let n = p.len();
1116    let row = DVector::from_fn(n, |i, _| state.weights[i] * t_prev[i]);
1117    let minus_p = -p;
1118    let factor = bordered.factor(
1119        k,
1120        Border {
1121            column: &minus_p,
1122            row: &row,
1123            corner: state.beta * state.beta * t_prev[n],
1124        },
1125        &state.unknown_scale,
1126    )?;
1127    let v = factor.solve(&unit_last(n))?;
1128    let norm = state.metric_norm(&v);
1129    if !(norm.is_finite() && norm > 0.0) {
1130        return Err(ArcFailure::NonFinite);
1131    }
1132    let t = v / norm;
1133    // Positive by construction (= 1/||v||_M); anything else means the solve
1134    // can't be trusted.
1135    if state.metric_dot(&t, t_prev) <= 0.0 {
1136        return Err(ArcFailure::InaccurateSolve);
1137    }
1138    let det_k = if t[n] == 0.0 {
1139        0
1140    } else {
1141        factor.det_sign() * t[n].signum() as i8
1142    };
1143    Ok((t, Some(det_k)))
1144}
1145
1146/// Sign of `det K` at an accepted state reached without any corrector
1147/// factorization: `[K -p; 0 1]` has `det = det K`.
1148fn accepted_det_sign(
1149    bordered: &mut BorderedSolver,
1150    k: &SparseMatrix,
1151    p: &DVector<f64>,
1152) -> Option<i8> {
1153    let n = p.len();
1154    let zeros = DVector::zeros(n);
1155    let minus_p = -p;
1156    match bordered.factor(
1157        k,
1158        Border {
1159            column: &minus_p,
1160            row: &zeros,
1161            corner: 1.0,
1162        },
1163        &DVector::from_element(n + 1, 1.0),
1164    ) {
1165        Ok(factor) => Some(factor.det_sign()),
1166        Err(ArcFailure::SingularSystem) => Some(0),
1167        Err(_) => None,
1168    }
1169}
1170
1171/// Sign of `det K` from a corrector factorization: `x_lambda = det K / det A`
1172/// for `A x = e_{n+1}`.
1173fn det_sign_from(factor: &BorderedFactorization, n: usize) -> Option<i8> {
1174    let x = factor.solve(&unit_last(n)).ok()?;
1175    let last = x[n];
1176    if last == 0.0 {
1177        Some(0)
1178    } else {
1179        Some(factor.det_sign() * last.signum() as i8)
1180    }
1181}
1182
1183/// One attempt at radius `s` from the committed state (the caller restores
1184/// the domain on failure): apply the predictor, then coupled Newton with
1185/// backtracking until force equilibrium, the constraint, and any
1186/// correction criteria all hold at the accepted state.
1187#[allow(clippy::too_many_arguments)]
1188fn attempt<const NDIM: usize, const NDOF: usize, NId, E>(
1189    domain: &mut Domain<NDIM, NDOF, NId, E>,
1190    bordered: &mut BorderedSolver,
1191    state: &ArcState,
1192    config: &ArcLength<NId>,
1193    test: &ConvergenceTest,
1194    context: &StepContext<'_>,
1195    s: f64,
1196    factorizations: &mut usize,
1197) -> Result<Accepted, ArcFailure>
1198where
1199    NId: Key,
1200    E: ElementOps<NDIM, NDOF, NId>,
1201{
1202    let n = context.q_n.len();
1203    let t = context.predictor;
1204    let s2 = s * s;
1205
1206    let predictor_q = DVector::from_fn(n, |i, _| s * t[i]);
1207    domain.apply_displacement_increment(&predictor_q);
1208    let mut lambda = context.lambda_n + s * t[n];
1209
1210    // Frozen for the attempt: committed and predicted external force and
1211    // committed internal force (`docs/arclength.md` §6).
1212    let external_predicted = domain.assemble_reference_load(lambda);
1213    let force_tolerance = match *test {
1214        ConvergenceTest::NormUnbalance { tol, .. } => ForceTolerance::uniform(n, tol),
1215        _ => ForceTolerance::for_test(
1216            test,
1217            &state.rotational,
1218            &[context.external_n, &external_predicted, context.internal_n],
1219        )
1220        .expect("arc length only accepts force tests"),
1221    };
1222    let force_ok = |residual: &DVector<f64>| -> (bool, f64) {
1223        match *test {
1224            ConvergenceTest::NormUnbalance { tol, .. } => {
1225                let norm = residual.norm();
1226                (norm < tol, norm)
1227            }
1228            _ => {
1229                let measure = force_tolerance.scaled_max(residual);
1230                (measure <= 1.0, measure)
1231            }
1232        }
1233    };
1234    let displacement_tol = match *test {
1235        ConvergenceTest::Combined {
1236            displacement_tol, ..
1237        } => displacement_tol,
1238        _ => None,
1239    };
1240    let merit = |residual: &DVector<f64>, g: f64| {
1241        0.5 * (force_tolerance.scaled_norm_squared(residual)
1242            + (g / (s2 * config.arc_tolerance)).powi(2))
1243    };
1244    let constraint = |dq: &DVector<f64>, dl: f64| {
1245        let mut sum = state.beta * state.beta * dl * dl - s2;
1246        for i in 0..n {
1247            sum += state.weights[i] * dq[i] * dq[i];
1248        }
1249        sum
1250    };
1251    let evaluate = |domain: &Domain<NDIM, NDOF, NId, E>, lambda: f64| {
1252        let (k, internal) = domain.assemble_tangent_and_resistance(lambda);
1253        let residual = domain.assemble_reference_load(lambda) - internal;
1254        (k, residual)
1255    };
1256
1257    let (mut k, mut residual) = evaluate(domain, lambda);
1258    let mut corrector_solves = 0;
1259    let mut damped = false;
1260    let mut last_undamped: Option<DVector<f64>> = None;
1261    let mut last_factor: Option<BorderedFactorization> = None;
1262    let mut merit_history: Vec<f64> = Vec::new();
1263    let max_iter = test.max_iter();
1264
1265    loop {
1266        let dq = domain.gather_displacement() - context.q_n;
1267        let dl = lambda - context.lambda_n;
1268        if !all_finite(&residual) || !all_finite(&dq) || !dl.is_finite() {
1269            return Err(ArcFailure::NonFinite);
1270        }
1271        let g = constraint(&dq, dl);
1272        let (force_pass, force_measure) = force_ok(&residual);
1273        let arc_error = g.abs() / s2;
1274        let correction_pass = match (&last_undamped, config.correction_tolerance) {
1275            (Some(delta), Some(tol)) => state.metric_norm(delta) <= tol * s,
1276            _ => true,
1277        };
1278        let displacement_pass = match (&last_undamped, displacement_tol) {
1279            (Some(delta), Some(tol)) => delta.rows(0, n).amax() <= tol,
1280            _ => true,
1281        };
1282
1283        if force_pass && arc_error <= config.arc_tolerance && correction_pass && displacement_pass {
1284            let increment = join(&dq, dl);
1285            if state.metric_dot(&increment, t) < ORIENTATION_EPS * s2 {
1286                return Err(ArcFailure::BranchOrientation);
1287            }
1288            let det_sign = last_factor.as_ref().and_then(|f| det_sign_from(f, n));
1289            return Ok(Accepted {
1290                k,
1291                delta_lambda: dl,
1292                corrector_solves,
1293                damped,
1294                force_measure,
1295                arc_error,
1296                det_sign,
1297                increment,
1298            });
1299        }
1300        if corrector_solves >= max_iter {
1301            return Err(ArcFailure::NotConverged);
1302        }
1303        if dq.amax() == 0.0 && dl == 0.0 {
1304            return Err(ArcFailure::DegenerateConstraint);
1305        }
1306
1307        let minus_p = -context.p;
1308        let row = DVector::from_fn(n, |i, _| 2.0 * state.weights[i] * dq[i]);
1309        *factorizations += 1;
1310        let factor = bordered.factor(
1311            &k,
1312            Border {
1313                column: &minus_p,
1314                row: &row,
1315                corner: 2.0 * state.beta * state.beta * dl,
1316            },
1317            &state.unknown_scale,
1318        )?;
1319        let delta = factor.solve(&join(&residual, -g))?;
1320        corrector_solves += 1;
1321
1322        // Coupled backtracking from this iteration's base state. Each
1323        // candidate `base + eta * delta` is projected radially back onto
1324        // the sphere, so every trial satisfies the constraint exactly and
1325        // the merit function measures force imbalance. The correction is
1326        // tangent to the sphere (its constraint row reads `2 dq^T W dq_c +
1327        // 2 beta^2 dl dl_c = -g ~ 0`), so the projection only changes the
1328        // step at second order and `delta` stays a descent direction.
1329        let phi0 = merit(&residual, g);
1330        let base = join(&dq, dl);
1331        let mut eta = 1.0;
1332        let accepted_candidate = loop {
1333            let candidate = &base + eta * &delta;
1334            let norm = state.metric_norm(&candidate);
1335            if norm.is_finite() && norm > 0.0 {
1336                let target = candidate * (s / norm);
1337                let current = domain.gather_displacement() - context.q_n;
1338                let shift = DVector::from_fn(n, |i, _| target[i] - current[i]);
1339                domain.apply_displacement_increment(&shift);
1340                let candidate_lambda = context.lambda_n + target[n];
1341                let (k_c, r_c) = evaluate(domain, candidate_lambda);
1342                let dq_c = domain.gather_displacement() - context.q_n;
1343                let g_c = constraint(&dq_c, candidate_lambda - context.lambda_n);
1344                let phi = merit(&r_c, g_c);
1345                let converged_here = force_ok(&r_c).0 && g_c.abs() / s2 <= config.arc_tolerance;
1346                if phi.is_finite()
1347                    && (phi <= (1.0 - 2.0 * config.backtracking.armijo * eta) * phi0
1348                        || converged_here)
1349                {
1350                    break Some((k_c, r_c, candidate_lambda, phi));
1351                }
1352            }
1353            eta *= 0.5;
1354            if eta < config.backtracking.min_step {
1355                break None;
1356            }
1357        };
1358        let Some((k_c, r_c, candidate_lambda, phi)) = accepted_candidate else {
1359            return Err(ArcFailure::NoDescent);
1360        };
1361        damped |= eta < 1.0;
1362        k = k_c;
1363        residual = r_c;
1364        lambda = candidate_lambda;
1365        last_undamped = Some(delta);
1366        last_factor = Some(factor);
1367
1368        merit_history.push(phi);
1369        let len = merit_history.len();
1370        if len > STAGNATION_WINDOW && phi > 0.5 * merit_history[len - 1 - STAGNATION_WINDOW] {
1371            return Err(ArcFailure::Stagnated);
1372        }
1373    }
1374}