Skip to main content

basin/core/
termination.rs

1//! Stopping reasons reported by solvers and execution controls.
2//!
3//! Configure numerical tolerances on solvers and execution limits or closure
4//! hooks on [`Executor`](crate::Executor) and [`RunControl`](crate::RunControl).
5
6use crate::EvaluationKind;
7use crate::core::math::Scalar;
8use std::time::Duration;
9
10/// Where an executor published a stopping decision.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum TerminationStage {
14    /// Before entering the next solver step, including after initialization.
15    Boundary,
16    /// During a solver step. Completion is independent of termination.
17    Step {
18        /// Whether this step completed an iteration.
19        completed: bool,
20    },
21}
22
23/// An owned explanation of one stopping event, independent of solver history.
24///
25/// Construct reports with [`Self::new`]. Fields remain public, and destructuring
26/// patterns must include `..` so future releases can add publication context.
27#[derive(Clone, Debug, PartialEq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[non_exhaustive]
30pub struct TerminationReport<F: Scalar = f64> {
31    /// The stopping decision and its evidence.
32    pub termination: Termination<F>,
33    /// Where the decision was published.
34    pub stage: TerminationStage,
35    /// Number of completed iterations when the report was published.
36    pub iteration: u64,
37}
38
39/// A stopping decision returned with its explanation at the point of detection.
40///
41/// Numerical failure publishes a coherent state through `Ok`; typed problem
42/// callback errors remain `Result::Err`. Convergence describes the tested
43/// predicates and does not certify a global optimum or parameter accuracy.
44#[derive(Clone, Debug, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46#[non_exhaustive]
47pub enum Termination<F: Scalar = f64> {
48    /// One or more numerical convergence predicates passed.
49    Converged(Convergence<F>),
50    /// All execution budgets exhausted at the observed boundary.
51    Limit(Vec<ExecutionLimit>),
52    /// The eligible objective incumbent reached the requested target.
53    Target {
54        /// Eligible incumbent objective at the stopping boundary.
55        cost: F,
56        /// Requested objective threshold.
57        target: F,
58    },
59    /// A numerical safeguard or configured progress rule stopped the run.
60    Stalled(Stall<F>),
61    /// The solver could not continue numerically.
62    Failed(NumericalFailure<F>),
63    /// The executor cancellation token requested a stop.
64    Cancelled,
65    /// An application hook requested a stop.
66    Application(ApplicationStop),
67}
68
69/// All sufficient convergence predicates that passed at the stopping stage.
70///
71/// A compound predicate remains one criterion; its conditions retain their
72/// conjunction. Tests unavailable at this stage are not evaluated retroactively.
73#[derive(Clone, Debug, PartialEq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75#[cfg_attr(
76    feature = "serde",
77    serde(bound(deserialize = "F: serde::Deserialize<'de>"))
78)]
79pub struct Convergence<F: Scalar = f64> {
80    #[cfg_attr(
81        feature = "serde",
82        serde(deserialize_with = "deserialize_criteria")
83    )]
84    criteria: Vec<ConvergenceCriterion<F>>,
85}
86
87#[cfg(feature = "serde")]
88fn deserialize_criteria<'de, F, D>(
89    deserializer: D,
90) -> Result<Vec<ConvergenceCriterion<F>>, D::Error>
91where
92    F: Scalar + serde::Deserialize<'de>,
93    D: serde::Deserializer<'de>,
94{
95    let criteria =
96        <Vec<ConvergenceCriterion<F>> as serde::Deserialize>::deserialize(
97            deserializer,
98        )?;
99    if criteria.is_empty() {
100        return Err(serde::de::Error::custom(
101            "convergence requires at least one satisfied predicate",
102        ));
103    }
104    Ok(criteria)
105}
106
107impl<F: Scalar> Convergence<F> {
108    /// Construct a value with the supplied initial explanation.
109    pub fn new(criterion: ConvergenceCriterion<F>) -> Self {
110        Self {
111            criteria: vec![criterion],
112        }
113    }
114    /// Read all passing predicates in reporting order.
115    pub fn criteria(&self) -> &[ConvergenceCriterion<F>] {
116        &self.criteria
117    }
118    /// Record another predicate passing at the same observation stage.
119    pub fn push(&mut self, criterion: ConvergenceCriterion<F>) {
120        self.criteria.push(criterion);
121    }
122    pub(crate) fn append(&mut self, other: Self) {
123        self.criteria.extend(other.criteria);
124    }
125}
126
127/// One precisely identified predicate and the measurements used to evaluate it.
128///
129/// Construct records with [`Self::new`], [`Self::upper_bound`], or [`Self::custom`].
130/// Fields remain public for reading, moving, and assignment; destructuring must
131/// include `..`. Future optional metadata must default to behavior compatible
132/// with existing constructors. Optional reporting metadata belongs on this
133/// record so existing [`ConvergenceEvidence`] variant fields can stay fixed.
134///
135/// Struct literals are unavailable outside Basin:
136///
137/// ```compile_fail,E0639
138/// use basin::{ConvergenceCriterion, ConvergenceEvidence, ConvergenceTest};
139///
140/// let criterion = ConvergenceCriterion::<f64> {
141///     test: ConvergenceTest::NoFreeParameters,
142///     evidence: ConvergenceEvidence::NoFreeParameters,
143/// };
144/// ```
145#[derive(Clone, Debug, PartialEq)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147#[non_exhaustive]
148pub struct ConvergenceCriterion<F: Scalar = f64> {
149    /// The mathematical predicate that passed.
150    pub test: ConvergenceTest,
151    /// Measurements from the stopping decision.
152    pub evidence: ConvergenceEvidence<F>,
153}
154
155/// Mathematical identity of a stopping predicate. Norms and reference points
156/// are part of the identity, rather than inferred from a tolerance's name.
157#[derive(Clone, Debug, PartialEq, Eq)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
159#[non_exhaustive]
160pub enum ConvergenceTest {
161    /// `||g||_2² <= tolerance²`.
162    AbsoluteGradientSquared,
163    /// `||g||_2² <= tolerance² * ||g_initial||_2²`.
164    RelativeGradientSquared,
165    /// `||g||_infinity <= tolerance`.
166    AbsoluteGradientInfinity,
167    /// `||x - projection(x - g)||_infinity <= tolerance`.
168    ProjectedGradient,
169    /// L-BFGS-B's bound-clipped gradient infinity norm.
170    BoundClippedGradient,
171    /// `||x_current - x_previous||_2 <= tolerance`.
172    AbsoluteStep,
173    /// `||x_current - x_previous||_2 <= tolerance * ||x_current||_2`.
174    RelativeStep,
175    /// `|f_current - f_previous| <= tolerance`.
176    AbsoluteCostChange,
177    /// `|f_current - f_previous| <= tolerance * |f_previous|`.
178    RelativeCostChange,
179    /// L-BFGS-B's signed reduction:
180    /// `f_previous - f_current <= tolerance * max(|f_previous|, |f_current|, 1)`.
181    RelativeCostReduction,
182    /// Maximum `||x_i - x_0||_infinity <= max(x_abs, x_rel * ||x_0||_infinity)`
183    /// AND maximum `|f_i - f_0| <= max(f_abs, f_rel * max(|f_0|, initial_spread))`.
184    /// Disabled terms are omitted; a dimension with both terms disabled is
185    /// absent from the conjunction and evidence. The initial spread is the
186    /// first finite cost spread after fresh initialization.
187    Simplex,
188    /// `max_j |(Jᵀr)_j| / (||J[:,j]|| * ||r||) <= tolerance`.
189    GradientOrthogonality,
190    /// Robust gradient normalized by model column norms and `sqrt(2*objective)`.
191    RobustGradientOrthogonality,
192    /// Small absolute actual and predicted reductions relative to the prior
193    /// objective, AND gain ratio at most two.
194    RelativeModelReduction,
195    /// Attempted `||h||_2 <= tolerance * ||x||_2`, using the accepted point
196    /// after acceptance and the retained base after rejection.
197    RelativeTrialStep,
198    /// Updated radius `<= tolerance * sqrt(xᵀ D x)` with Marquardt scaling.
199    RelativeTrustRadius,
200    /// Coleman–Li bound-scaled gradient infinity norm on free coordinates.
201    AbsoluteScaledGradient,
202    /// All parameters are fixed by equal bounds.
203    NoFreeParameters,
204    /// CMA-ES `sigma * max_i d_i < tolerance`.
205    DistributionSize,
206    /// Algorithm's radius or mutation step reached its configured floor.
207    Radius,
208    /// MADS poll size reached its configured floor.
209    Mesh,
210    /// A solver-specific predicate, also available to external solvers.
211    /// `key` should include the solver namespace; `definition` states its math.
212    Custom {
213        /// Namespaced predicate identifier.
214        key: String,
215        /// Mathematical definition of the predicate.
216        definition: String,
217    },
218    /// Sample standard deviation of the current simplex costs is at most the
219    /// absolute tolerance: `sqrt(sum_i (f_i - mean(f))^2 / (m - 1)) <= tolerance`,
220    /// with `m >= 2`. Zero tolerance requires identical finite costs.
221    AbsoluteSimplexCostStdDev,
222}
223
224/// Evidence for a predicate. Values describe the actual decision, including
225/// rejected trials, rather than a reconstruction from the final iterate.
226///
227/// New variants may be added, so matches need a fallback arm. Existing variants
228/// keep their field lists throughout Basin 2.x and support direct construction
229/// and complete field patterns. Different mathematical representations use new
230/// variants, as with [`Self::UpperBound`] and [`Self::FactoredUpperBound`].
231/// Optional reporting metadata belongs on [`ConvergenceCriterion`].
232///
233/// ```
234/// use basin::ConvergenceEvidence;
235///
236/// let evidence = ConvergenceEvidence::UpperBound {
237///     value: 0.001,
238///     bound: 0.01,
239///     tolerance: 0.01,
240///     reference: None,
241/// };
242/// match evidence {
243///     ConvergenceEvidence::UpperBound {
244///         value, bound, tolerance, reference,
245///     } => {
246///         assert!(value <= bound);
247///         assert_eq!(tolerance, 0.01);
248///         assert_eq!(reference, None);
249///     }
250///     other => println!("Other evidence: {other:?}"),
251/// }
252/// ```
253#[derive(Clone, Debug, PartialEq)]
254#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
255#[non_exhaustive]
256pub enum ConvergenceEvidence<F: Scalar = f64> {
257    /// `value <= bound` (or the strict comparison documented by the test).
258    UpperBound {
259        /// Observed value.
260        value: F,
261        /// Evaluated comparison bound, including any reference scaling.
262        bound: F,
263        /// Configured tolerance for this predicate.
264        tolerance: F,
265        /// Reference value used to form the bound, when applicable.
266        reference: Option<F>,
267    },
268    /// Factored values preserve measurements outside the scalar's exponent
269    /// range, as used by LM's safeguarded comparisons.
270    FactoredUpperBound {
271        /// Observed value.
272        value: BinaryValue<F>,
273        /// Evaluated comparison bound, including any reference scaling.
274        bound: BinaryValue<F>,
275        /// Configured tolerance for this predicate.
276        tolerance: F,
277        /// Reference value used to form the bound, when applicable.
278        reference: Option<BinaryValue<F>>,
279    },
280    /// Actual and predicted model reductions with their common reference cost.
281    ModelReduction {
282        /// Previous objective minus trial objective.
283        actual: F,
284        /// Predicted decrease from the local model.
285        predicted: F,
286        /// Objective used as the comparison reference.
287        reference_cost: F,
288        /// Actual reduction divided by predicted reduction, with the solver's safeguards.
289        gain_ratio: F,
290        /// Configured tolerance for this predicate.
291        tolerance: F,
292    },
293    /// The configured size and cost-spread conditions, combined with AND.
294    Simplex {
295        /// Maximum infinity-norm vertex distance from the first vertex, if enabled.
296        size: Option<Threshold<F>>,
297        /// Maximum absolute cost difference from the first vertex, if enabled.
298        cost_spread: Option<Threshold<F>>,
299    },
300    /// No numerical comparison was required.
301    NoFreeParameters,
302    /// Named measurements for a custom predicate. An empty collection means
303    /// the predicate exposes no scalar measurements.
304    Measurements(Vec<Measurement<F>>),
305}
306
307/// A nonnegative measurement represented as `significand * 2^exponent`.
308/// Keeping the exponent separate avoids overflow and subnormal underflow in
309/// the report. Zero has a zero significand; no normalization is required.
310#[derive(Clone, Debug, PartialEq)]
311#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
312pub struct BinaryValue<F: Scalar = f64> {
313    /// Nonnegative scalar factor.
314    pub significand: F,
315    /// Base-two exponent, kept separate from the scalar range.
316    pub exponent: i32,
317}
318
319impl<F: Scalar> BinaryValue<F> {
320    // Factors must be nonnegative and finite. Decode before multiplying so
321    // intermediate products cannot overflow or erase a nonzero measurement.
322    pub(crate) fn product(factors: &[F]) -> Self {
323        if factors.contains(&F::zero()) {
324            return Self {
325                significand: F::zero(),
326                exponent: 0,
327            };
328        }
329        let mut significand = F::one();
330        let mut exponent = 0_i32;
331        for factor in factors {
332            let (mantissa, power, _) = factor.integer_decode();
333            let shift = 63 - mantissa.leading_zeros();
334            significand = significand
335                * (F::from_u64(mantissa).unwrap()
336                    / F::from_u64(1_u64 << shift).unwrap());
337            exponent += i32::from(power) + shift as i32;
338            if significand >= F::from_f64(2.).unwrap() {
339                significand = significand * F::from_f64(0.5).unwrap();
340                exponent += 1;
341            }
342        }
343        Self {
344            significand,
345            exponent,
346        }
347    }
348
349    pub(crate) fn product_le(left: &[F], right: &[F]) -> bool {
350        if left.contains(&F::zero()) {
351            return true;
352        }
353        if right.contains(&F::zero()) {
354            return false;
355        }
356        let left = Self::product(left);
357        let right = Self::product(right);
358        (left.exponent, left.significand) <= (right.exponent, right.significand)
359    }
360}
361
362#[derive(Clone, Debug, PartialEq)]
363#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
364/// A scalar comparison value, its tolerance settings, and the evaluated bound.
365///
366/// This complete mathematical record supports struct literals and exhaustive
367/// destructuring. Different comparison rules require distinct evidence
368/// representations.
369pub struct Threshold<F: Scalar = f64> {
370    /// Observed value.
371    pub value: F,
372    /// Evaluated bound: the maximum of the enabled absolute tolerance and
373    /// relative tolerance times its reference.
374    pub tolerance: F,
375    /// Configured absolute tolerance, if enabled.
376    pub absolute_tolerance: Option<F>,
377    /// Configured relative tolerance, if enabled.
378    pub relative_tolerance: Option<F>,
379    /// Reference magnitude used by the relative tolerance, if enabled.
380    pub reference: Option<F>,
381}
382
383#[derive(Clone, Debug, PartialEq)]
384#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
385/// A named scalar measurement produced by a predicate or numerical safeguard.
386pub struct Measurement<F: Scalar = f64> {
387    /// Measurement name defined by the producing predicate.
388    pub name: String,
389    /// Observed value.
390    pub value: F,
391}
392
393impl<F: Scalar> ConvergenceCriterion<F> {
394    /// Construct a predicate report from its identity and evidence.
395    pub fn new(
396        test: ConvergenceTest,
397        evidence: ConvergenceEvidence<F>,
398    ) -> Self {
399        Self { test, evidence }
400    }
401
402    /// Construct a predicate report with its measured value and bound.
403    pub fn upper_bound(
404        test: ConvergenceTest,
405        value: F,
406        bound: F,
407        tolerance: F,
408        reference: Option<F>,
409    ) -> Self {
410        Self::new(
411            test,
412            ConvergenceEvidence::UpperBound {
413                value,
414                bound,
415                tolerance,
416                reference,
417            },
418        )
419    }
420    /// Construct a namespaced predicate with its definition and measurements.
421    pub fn custom(
422        key: impl Into<String>,
423        definition: impl Into<String>,
424        measurements: Vec<Measurement<F>>,
425    ) -> Self {
426        Self::new(
427            ConvergenceTest::Custom {
428                key: key.into(),
429                definition: definition.into(),
430            },
431            ConvergenceEvidence::Measurements(measurements),
432        )
433    }
434}
435
436/// Exhausted execution budgets, with observed work (which can exceed a limit
437/// when a solver step evaluates a batch before the next boundary).
438#[derive(Clone, Debug, PartialEq, Eq)]
439#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
440#[non_exhaustive]
441pub enum ExecutionLimit {
442    /// Completed-iteration budget.
443    Iterations {
444        /// Work observed at the stopping boundary.
445        observed: u64,
446        /// Configured maximum.
447        limit: u64,
448    },
449    /// Cost-evaluation budget using the state's category mapping.
450    CostEvaluations {
451        /// Work observed at the stopping boundary.
452        observed: u64,
453        /// Configured maximum.
454        limit: u64,
455    },
456    /// Raw gradient-evaluation budget.
457    GradientEvaluations {
458        /// Work observed at the stopping boundary.
459        observed: u64,
460        /// Configured maximum.
461        limit: u64,
462    },
463    /// Raw category or total-work budget.
464    Evaluations {
465        /// Counted evaluation category or total work.
466        kind: EvaluationKind,
467        /// Work observed at the stopping boundary.
468        observed: u64,
469        /// Configured maximum.
470        limit: u64,
471    },
472    /// Elapsed-time budget for this execution segment.
473    Time {
474        /// Elapsed duration at the stopping boundary.
475        elapsed: Duration,
476        /// Configured maximum.
477        limit: Duration,
478    },
479}
480
481#[derive(Clone, Debug, PartialEq)]
482#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
483/// Progress stopped without establishing convergence.
484#[non_exhaustive]
485pub enum Stall<F: Scalar = f64> {
486    /// No sufficient improvement in the objective incumbent.
487    NoImprovement {
488        /// Completed iterations since the reference event.
489        iterations: u64,
490        /// Configured number of iterations without progress.
491        patience: u64,
492        /// Configured tolerance for this predicate.
493        tolerance: F,
494        /// Objective used as the comparison reference.
495        reference_cost: F,
496        /// Current eligible incumbent objective.
497        best_cost: F,
498    },
499    /// No accepted proposal during the configured interval.
500    NoAcceptedMove {
501        /// Completed iterations since the reference event.
502        iterations: u64,
503        /// Configured number of iterations without progress.
504        patience: u64,
505    },
506    /// A numerical safeguard stopped progress without proving convergence.
507    Numerical {
508        /// Explanation from the producing solver.
509        message: String,
510        /// Available numerical context for this event.
511        measurements: Vec<Measurement<F>>,
512    },
513}
514
515/// Numerical failure, retaining a nested report when an outer solver cannot
516/// consume an inner result. The inner report does not certify outer convergence.
517#[derive(Clone, Debug, PartialEq)]
518#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
519#[non_exhaustive]
520pub enum NumericalFailure<F: Scalar = f64> {
521    /// A numerical failure detected by a solver.
522    Solver {
523        /// Explanation from the producing solver.
524        message: String,
525        /// Available numerical context for this event.
526        measurements: Vec<Measurement<F>>,
527    },
528    /// An inner result the outer solver cannot consume.
529    Inner {
530        /// The inner stopping event, retaining its stage and completed-iteration count.
531        report: Box<TerminationReport<F>>,
532    },
533}
534
535/// An application hook's own stopping decision, distinct from convergence.
536///
537/// Construct records with [`Self::new`]. Fields remain public for reading,
538/// moving, and assignment; destructuring must include `..`. Future optional
539/// metadata must default to behavior compatible with existing constructors.
540///
541/// Struct literals are unavailable outside Basin:
542///
543/// ```compile_fail,E0639
544/// use basin::ApplicationStop;
545///
546/// let stop = ApplicationStop { code: "user_requested".into() };
547/// ```
548#[derive(Clone, Debug, PartialEq, Eq)]
549#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
550#[non_exhaustive]
551pub struct ApplicationStop {
552    /// Application-defined identifier for this stop.
553    pub code: String,
554}
555impl ApplicationStop {
556    /// Construct an application stop with the supplied identifier.
557    pub fn new(code: impl Into<String>) -> Self {
558        Self { code: code.into() }
559    }
560}
561
562/// Explicit policy for consuming a partial inner solution.
563#[derive(Clone, Copy, Debug, PartialEq, Eq)]
564pub enum PartialResultPolicy {
565    /// Allow limits, targets, and stalls to provide partial progress.
566    Consume,
567    /// Consume only a converged inner result.
568    RequireConvergence,
569}
570
571impl<F: Scalar> Termination<F> {
572    /// Native identities retained for diagnostic consumers migrating from 1.x.
573    /// The returned list is derived from this event, never from solver history.
574    pub fn native_convergence_tests(
575        &self,
576    ) -> Vec<crate::NativeConvergenceTest> {
577        use crate::NativeConvergenceTest as N;
578        use ConvergenceTest as C;
579        let Self::Converged(c) = self else {
580            return vec![];
581        };
582        c.criteria()
583            .iter()
584            .filter_map(|criterion| {
585                Some(match criterion.test {
586                    C::AbsoluteGradientInfinity => N::AbsoluteGradient,
587                    C::GradientOrthogonality => N::GradientOrthogonality,
588                    C::RobustGradientOrthogonality => {
589                        N::RobustGradientOrthogonality
590                    }
591                    C::RelativeModelReduction => N::RelativeModelReduction,
592                    C::RelativeTrialStep => N::RelativeTrialStep,
593                    C::RelativeTrustRadius => N::RelativeTrustRadius,
594                    C::AbsoluteScaledGradient => N::AbsoluteScaledGradient,
595                    C::NoFreeParameters => N::NoFreeParameters,
596                    _ => return None,
597                })
598            })
599            .collect()
600    }
601    /// Stop because the supplied numerical predicate passed.
602    pub fn converged(criterion: ConvergenceCriterion<F>) -> Self {
603        Self::Converged(Convergence::new(criterion))
604    }
605    /// Whether this decision establishes its reported convergence predicates.
606    pub fn is_converged(&self) -> bool {
607        matches!(self, Self::Converged(_))
608    }
609    /// Whether this decision reports a numerical failure.
610    pub fn is_failure(&self) -> bool {
611        matches!(self, Self::Failed(_))
612    }
613    /// Whether an outer algorithm may consume this result under its explicit
614    /// partial-result policy. Cancellation, application stops, and failures
615    /// require routing rather than silently continuing.
616    pub fn can_continue_as_inner(&self, policy: PartialResultPolicy) -> bool {
617        self.is_converged()
618            || (policy == PartialResultPolicy::Consume
619                && matches!(
620                    self,
621                    Self::Limit(_) | Self::Target { .. } | Self::Stalled(_)
622                ))
623    }
624    /// Stop with a numerical failure and a published state.
625    pub fn numerical_failure(message: impl Into<String>) -> Self {
626        Self::Failed(NumericalFailure::Solver {
627            message: message.into(),
628            measurements: vec![],
629        })
630    }
631    /// Stop on a numerical safeguard without claiming convergence.
632    pub fn numerical_stall(message: impl Into<String>) -> Self {
633        Self::Stalled(Stall::Numerical {
634            message: message.into(),
635            measurements: vec![],
636        })
637    }
638    pub(crate) fn merge(self, other: Self) -> Self {
639        match (self, other) {
640            (Self::Converged(mut a), Self::Converged(b)) => {
641                a.append(b);
642                Self::Converged(a)
643            }
644            (Self::Converged(_), b) => b,
645            (_, b) if b.is_failure() => b,
646            (a, _) => a,
647        }
648    }
649}
650
651impl<F: Scalar> TerminationReport<F> {
652    /// Construct a report for a stopping decision at a publication boundary.
653    pub fn new(
654        termination: Termination<F>,
655        stage: TerminationStage,
656        iteration: u64,
657    ) -> Self {
658        Self {
659            termination,
660            stage,
661            iteration,
662        }
663    }
664
665    /// Compact compatibility code. Inspect the termination for the full report.
666    pub fn code(&self) -> TerminationCode {
667        self.termination.code()
668    }
669    /// Consume a converged inner result, or explicitly accept its partial
670    /// progress. Cancellation and application stops propagate as stops.
671    pub fn into_outer_termination(
672        self,
673        policy: PartialResultPolicy,
674    ) -> Option<Termination<F>> {
675        match self.termination {
676            Termination::Converged(_) => None,
677            Termination::Cancelled => Some(Termination::Cancelled),
678            Termination::Application(stop) => {
679                Some(Termination::Application(stop))
680            }
681            Termination::Limit(_)
682            | Termination::Target { .. }
683            | Termination::Stalled(_)
684                if policy == PartialResultPolicy::Consume =>
685            {
686                None
687            }
688            _ => Some(Termination::Failed(NumericalFailure::Inner {
689                report: Box::new(self),
690            })),
691        }
692    }
693}
694
695impl<F: Scalar> Termination<F> {
696    /// Construct a predicate report with its measured value and bound.
697    pub fn upper_bound(
698        test: ConvergenceTest,
699        value: F,
700        bound: F,
701        tolerance: F,
702        reference: Option<F>,
703    ) -> Self {
704        Self::converged(ConvergenceCriterion::upper_bound(
705            test, value, bound, tolerance, reference,
706        ))
707    }
708    /// Construct a namespaced predicate with its definition and measurements.
709    pub fn custom(
710        key: impl Into<String>,
711        definition: impl Into<String>,
712        measurements: Vec<Measurement<F>>,
713    ) -> Self {
714        Self::converged(ConvergenceCriterion::custom(
715            key,
716            definition,
717            measurements,
718        ))
719    }
720    /// A compact compatibility identifier. With simultaneous predicates, this
721    /// identifies the first reported predicate; use the full report to inspect
722    /// every condition. Its numeric value is explicitly mapped by `as_u8()`.
723    pub fn code(&self) -> TerminationCode {
724        use ConvergenceTest as C;
725        use TerminationCode as R;
726        match self {
727            Self::Converged(c) => match &c.criteria()[0].test {
728                C::AbsoluteGradientSquared => R::GradientTolerance,
729                C::RelativeGradientSquared => R::RelativeGradientTolerance,
730                C::ProjectedGradient | C::BoundClippedGradient => {
731                    R::ProjectedGradientTolerance
732                }
733                C::AbsoluteStep => R::ParamTolerance,
734                C::RelativeStep => R::RelativeParamTolerance,
735                C::AbsoluteCostChange => R::CostTolerance,
736                C::RelativeCostChange | C::RelativeCostReduction => {
737                    R::RelativeCostTolerance
738                }
739                C::Simplex | C::AbsoluteSimplexCostStdDev => {
740                    R::SimplexTolerance
741                }
742                C::DistributionSize => R::CmaEsTolerance,
743                C::Radius => R::RhoTolerance,
744                C::Mesh => R::MeshTolerance,
745                _ => R::SolverConverged,
746            },
747            Self::Limit(limits) => match limits.first() {
748                Some(ExecutionLimit::Iterations { .. }) => R::MaxIter,
749                Some(ExecutionLimit::CostEvaluations { .. }) => R::MaxCostEvals,
750                Some(ExecutionLimit::GradientEvaluations { .. }) => {
751                    R::MaxGradientEvals
752                }
753                Some(ExecutionLimit::Time { .. }) => R::MaxTime,
754                _ => R::MaxEvaluations,
755            },
756            Self::Target { .. } => R::TargetCost,
757            Self::Stalled(Stall::NoImprovement { .. }) => R::NoImprovement,
758            Self::Stalled(Stall::NoAcceptedMove { .. }) => R::NoAcceptedMove,
759            Self::Stalled(Stall::Numerical { .. }) => R::NumericalNoProgress,
760            Self::Failed(_) => R::SolverFailed,
761            Self::Cancelled => R::Cancelled,
762            Self::Application(_) => R::UserRequested,
763        }
764    }
765}
766
767/// Compatibility identifiers for bindings and compact status displays.
768///
769/// Use [`Self::as_u8`] for the stable Basin 1.x numeric mapping. The
770/// structured [`Termination`] carries the complete stopping explanation.
771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
773#[non_exhaustive]
774pub enum TerminationCode {
775    /// `state.iter() >= max_iter`.
776    MaxIter,
777    /// Cost-evaluation budget exhausted.
778    MaxCostEvals,
779    /// Gradient-evaluation budget exhausted.
780    MaxGradientEvals,
781    /// `‖∇f(x)‖ ≤ tol`.
782    GradientTolerance,
783    /// `‖∇f(x_k)‖ ≤ tol · ‖∇f(x_0)‖`: gradient norm relative to the
784    /// initial gradient (scale-invariant first-order stationarity).
785    RelativeGradientTolerance,
786    /// `‖x − π_C(x − ∇f(x))‖_∞ ≤ tol`: projected-gradient stationarity
787    /// for box-constrained problems. Collapses to the unconstrained
788    /// gradient norm when no constraint is active.
789    ProjectedGradientTolerance,
790    /// `‖x_k − x_{k−1}‖ ≤ tol`.
791    ParamTolerance,
792    /// `‖x_k − x_{k−1}‖ ≤ tol · ‖x_k‖`: scale-invariant step test
793    /// relative to the current iterate.
794    RelativeParamTolerance,
795    /// `|f_k − f_{k−1}| ≤ tol`.
796    CostTolerance,
797    /// `|f_k − f_{k−1}| ≤ tol · |f_{k−1}|`: scale-invariant cost
798    /// change test relative to the previous objective.
799    RelativeCostTolerance,
800    /// `f(x_k) ≤ target`: user-supplied target cost reached
801    /// (NLopt's `stopval`/SciPy's `f_min`).
802    TargetCost,
803    /// Best-so-far cost has not improved by more than `tol` in
804    /// `patience` consecutive iterations: the early-stopping pattern.
805    NoImprovement,
806    /// No proposal has been accepted for the configured number of iterations.
807    NoAcceptedMove,
808    /// A configured simplex-collapse or cost-dispersion test passed.
809    SimplexTolerance,
810    /// CMA-ES search distribution collapsed below TolX:
811    /// `σ · maxᵢ dᵢ < tol_x` (Hansen 2016 Appendix B.3).
812    CmaEsTolerance,
813    /// Trust-region radius or step size `ρ` reached the configured
814    /// floor: `ρ ≤ rho_end` (Powell-family trust region, Solis-Wets
815    /// mutation step).
816    RhoTolerance,
817    /// MADS poll size reached the configured floor: `Δᵖ ≤ poll_size_min`.
818    MeshTolerance,
819    /// Wall-clock time limit reached.
820    MaxTime,
821    /// Cancellation was requested through the executor's cancellation token.
822    Cancelled,
823    /// An application-specific stopping hook requested termination.
824    UserRequested,
825    /// Solver determined it has converged (e.g. fixed point reached).
826    SolverConverged,
827    /// Solver cannot make further progress (e.g. line search failure).
828    SolverFailed,
829    /// A numerical safeguard stopped the solver without establishing convergence.
830    ///
831    /// Levenberg-Marquardt reports this after a rejected finite trial whose
832    /// computed step leaves every parameter unchanged in floating-point
833    /// arithmetic. Trust-region-reflective least squares reports it when a
834    /// finite equal-cost rejection is followed by a contracted trial that
835    /// changes no parameter. Trust-region minimization reports it when the
836    /// predicted reduction is finite and non-positive but the computed
837    /// gradient norm is nonzero. The returned point may be inaccurate, heavily
838    /// damped, or limited by a small trust radius; this reason does not
839    /// establish stationarity or parameter recovery. Outer solvers may consume
840    /// the finite result and continue.
841    NumericalNoProgress,
842    /// A raw evaluation category or total-work budget was exhausted.
843    ///
844    /// Checked at iteration boundaries through the state's raw-count
845    /// capability; distinct from the legacy cost and gradient budget reasons.
846    /// Compare [`RawEvaluationState::raw_counts`](crate::RawEvaluationState::raw_counts)
847    /// with the configured limits to identify exhausted budgets.
848    MaxEvaluations,
849}
850
851impl TerminationCode {
852    /// Whether this compact identifier denotes a numerical failure.
853    /// Prefer [`TerminationReport::into_outer_termination`] with an explicit
854    /// [`PartialResultPolicy`] when routing a composed solver's result.
855    pub fn is_failure(&self) -> bool {
856        matches!(self, Self::SolverFailed)
857    }
858}
859
860impl TerminationCode {
861    /// Stable numeric binding code, preserving the Basin 1.x mapping (0–23).
862    ///
863    /// | Code | Identifier |
864    /// | --- | --- |
865    /// | 0 | `MaxIter` |
866    /// | 1 | `MaxCostEvals` |
867    /// | 2 | `MaxGradientEvals` |
868    /// | 3 | `GradientTolerance` |
869    /// | 4 | `RelativeGradientTolerance` |
870    /// | 5 | `ProjectedGradientTolerance` |
871    /// | 6 | `ParamTolerance` |
872    /// | 7 | `RelativeParamTolerance` |
873    /// | 8 | `CostTolerance` |
874    /// | 9 | `RelativeCostTolerance` |
875    /// | 10 | `TargetCost` |
876    /// | 11 | `NoImprovement` |
877    /// | 12 | `NoAcceptedMove` |
878    /// | 13 | `SimplexTolerance` |
879    /// | 14 | `CmaEsTolerance` |
880    /// | 15 | `RhoTolerance` |
881    /// | 16 | `MeshTolerance` |
882    /// | 17 | `MaxTime` |
883    /// | 18 | `Cancelled` |
884    /// | 19 | `UserRequested` |
885    /// | 20 | `SolverConverged` |
886    /// | 21 | `SolverFailed` |
887    /// | 22 | `NumericalNoProgress` |
888    /// | 23 | `MaxEvaluations` |
889    pub const fn as_u8(self) -> u8 {
890        match self {
891            Self::MaxIter => 0,
892            Self::MaxCostEvals => 1,
893            Self::MaxGradientEvals => 2,
894            Self::GradientTolerance => 3,
895            Self::RelativeGradientTolerance => 4,
896            Self::ProjectedGradientTolerance => 5,
897            Self::ParamTolerance => 6,
898            Self::RelativeParamTolerance => 7,
899            Self::CostTolerance => 8,
900            Self::RelativeCostTolerance => 9,
901            Self::TargetCost => 10,
902            Self::NoImprovement => 11,
903            Self::NoAcceptedMove => 12,
904            Self::SimplexTolerance => 13,
905            Self::CmaEsTolerance => 14,
906            Self::RhoTolerance => 15,
907            Self::MeshTolerance => 16,
908            Self::MaxTime => 17,
909            Self::Cancelled => 18,
910            Self::UserRequested => 19,
911            Self::SolverConverged => 20,
912            Self::SolverFailed => 21,
913            Self::NumericalNoProgress => 22,
914            Self::MaxEvaluations => 23,
915        }
916    }
917}