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}