Skip to main content

gam_models/
latent_coordinate.rs

1//! Typed, Python-independent optimization of latent coordinates.
2//!
3//! This module owns the outer manifold optimization contract: request
4//! validation, deterministic restarts, trust-region execution, resumable
5//! checkpoints, and the stationarity certificate required before a caller may
6//! construct a fitted model. Concrete latent likelihoods implement
7//! [`LatentCoordinateObjective`]; no FFI type participates in the contract.
8
9use std::error::Error as StdError;
10use std::fmt;
11
12use gam_geometry::{
13    GeometryError, GeometryResult, ManifoldSpec, RiemannianManifold, RiemannianObjective,
14    RiemannianTrustRegion,
15};
16use ndarray::{Array1, ArrayView1};
17use rand::SeedableRng;
18use rand_distr::{Distribution, StandardNormal};
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22/// The per-observation geometry of the latent coordinates.
23///
24/// `Sphere` means `S^(latent_dimension - 1)` embedded in
25/// `R^latent_dimension`. The full optimization manifold is the product over
26/// observations. String aliases deliberately do not belong in this core API.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum LatentCoordinateManifold {
30    Euclidean,
31    Circle,
32    Sphere,
33    Torus,
34}
35
36/// Decoder-domain topology for one ambient latent coordinate.
37#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum LatentCoordinateAxisDomain {
40    Open,
41    Periodic { period: f64 },
42}
43
44impl LatentCoordinateManifold {
45    /// Domain descriptor consumed by latent basis construction.
46    ///
47    /// A sphere has no independently periodic ambient axis: its geometry is
48    /// carried by the unit-norm manifold constraint and retraction.
49    pub fn axis_domains(
50        self,
51        latent_dimension: usize,
52    ) -> Result<Vec<LatentCoordinateAxisDomain>, LatentCoordinateRequestError> {
53        validate_manifold_dimension(self, latent_dimension)?;
54        let domain = match self {
55            Self::Circle | Self::Torus => LatentCoordinateAxisDomain::Periodic {
56                period: std::f64::consts::TAU,
57            },
58            Self::Euclidean | Self::Sphere => LatentCoordinateAxisDomain::Open,
59        };
60        Ok(vec![domain; latent_dimension])
61    }
62
63    fn build(
64        self,
65        n_observations: usize,
66        latent_dimension: usize,
67    ) -> GeometryResult<Box<dyn RiemannianManifold>> {
68        let per_observation = match self {
69            Self::Euclidean => {
70                return ManifoldSpec::Euclidean(n_observations * latent_dimension).build();
71            }
72            Self::Circle => ManifoldSpec::Circle,
73            Self::Sphere => ManifoldSpec::Sphere {
74                intrinsic_dim: latent_dimension - 1,
75            },
76            Self::Torus => ManifoldSpec::Torus {
77                dim: latent_dimension,
78            },
79        };
80        ManifoldSpec::Product(vec![per_observation; n_observations]).build()
81    }
82}
83
84/// Controls a latent-coordinate optimization run.
85///
86/// No defaults are supplied: convergence precision, budget, radius, and
87/// restart policy are part of the statistical procedure and must be selected
88/// explicitly by the caller.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct LatentCoordinateOptimizationOptions {
91    /// Trust-region iterations available to each restart.
92    pub max_iterations: usize,
93    /// Required relative projected-gradient tolerance, in `(0, 1]`.
94    pub stationarity_tolerance: f64,
95    /// Initial trust-region radius.
96    pub initial_trust_radius: f64,
97    /// Hard upper bound on the trust-region radius.
98    pub max_trust_radius: f64,
99    /// Total number of starts, including the unperturbed start.
100    pub restart_count: usize,
101    /// Standard deviation of tangent-space perturbations for later starts.
102    pub restart_scale: f64,
103    /// Seed for deterministic restart perturbations.
104    pub seed: u64,
105}
106
107/// A fresh start or a continuation from a typed checkpoint.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum LatentCoordinateStart {
111    Initial(Array1<f64>),
112    Resume(LatentCoordinateCheckpoint),
113}
114
115/// Complete request for the generic latent-coordinate optimizer.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct LatentCoordinateOptimizationRequest {
118    pub n_observations: usize,
119    pub latent_dimension: usize,
120    pub manifold: LatentCoordinateManifold,
121    pub start: LatentCoordinateStart,
122    pub options: LatentCoordinateOptimizationOptions,
123}
124
125/// Resume state emitted only from a fully evaluated, non-stationary candidate.
126///
127/// The original stationarity reference is retained so continuation applies the
128/// same certificate across process or wall-clock boundaries instead of silently
129/// renormalizing at the checkpoint.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct LatentCoordinateCheckpoint {
132    coordinates: Array1<f64>,
133    n_observations: usize,
134    latent_dimension: usize,
135    manifold: LatentCoordinateManifold,
136    stationarity_reference: f64,
137    restart_index: usize,
138}
139
140impl LatentCoordinateCheckpoint {
141    /// Construct binding-friendly resume state while enforcing shape,
142    /// finiteness, manifold feasibility, and a valid original reference.
143    pub fn new(
144        coordinates: Array1<f64>,
145        n_observations: usize,
146        latent_dimension: usize,
147        manifold: LatentCoordinateManifold,
148        stationarity_reference: f64,
149        restart_index: usize,
150    ) -> Result<Self, LatentCoordinateCheckpointError> {
151        let expected = validate_dimensions(n_observations, latent_dimension, manifold)?;
152        validate_coordinates("checkpoint", coordinates.view(), expected)?;
153        if !(stationarity_reference.is_finite() && stationarity_reference >= 0.0) {
154            return Err(LatentCoordinateRequestError::InvalidCheckpointReference {
155                value: stationarity_reference,
156            }
157            .into());
158        }
159        let geometry = manifold.build(n_observations, latent_dimension)?;
160        let coordinates = canonicalize_and_validate_point(geometry.as_ref(), coordinates)?;
161        Ok(Self {
162            coordinates,
163            n_observations,
164            latent_dimension,
165            manifold,
166            stationarity_reference,
167            restart_index,
168        })
169    }
170
171    pub fn coordinates(&self) -> ArrayView1<'_, f64> {
172        self.coordinates.view()
173    }
174
175    pub const fn n_observations(&self) -> usize {
176        self.n_observations
177    }
178
179    pub const fn latent_dimension(&self) -> usize {
180        self.latent_dimension
181    }
182
183    pub const fn manifold(&self) -> LatentCoordinateManifold {
184        self.manifold
185    }
186
187    pub const fn stationarity_reference(&self) -> f64 {
188        self.stationarity_reference
189    }
190
191    pub const fn restart_index(&self) -> usize {
192        self.restart_index
193    }
194
195}
196
197/// Exact evidence used to accept or reject the best restart.
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct LatentCoordinateStationarityEvidence {
200    pub objective_value: f64,
201    pub projected_gradient_norm: f64,
202    pub stationarity_reference: f64,
203    pub relative_gradient: f64,
204    pub tolerance: f64,
205    pub coordinate_spread: f64,
206    pub restart_index: usize,
207    pub restart_count: usize,
208    pub iteration_budget: usize,
209    pub objective_evaluations: usize,
210    pub hessian_vector_evaluations: usize,
211}
212
213impl LatentCoordinateStationarityEvidence {
214    /// Whether this evidence satisfies the optimizer's first-order contract.
215    pub fn certifies_stationarity(&self) -> bool {
216        self.relative_gradient.is_finite() && self.relative_gradient <= self.tolerance
217    }
218}
219
220impl fmt::Display for LatentCoordinateStationarityEvidence {
221    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        write!(
223            formatter,
224            "latent-coordinate optimization did not reach stationarity at restart {} of {}: \
225             relative gradient {:.6e} exceeds tolerance {:.6e} (projected gradient {:.6e}, \
226             stationarity reference {:.6e}, objective {:.9e}, iteration budget {})",
227            self.restart_index,
228            self.restart_count,
229            self.relative_gradient,
230            self.tolerance,
231            self.projected_gradient_norm,
232            self.stationarity_reference,
233            self.objective_value,
234            self.iteration_budget,
235        )
236    }
237}
238
239/// A latent coordinate vector that passed the required stationarity test.
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct LatentCoordinateOptimizationResult {
242    pub coordinates: Array1<f64>,
243    pub evidence: LatentCoordinateStationarityEvidence,
244}
245
246/// Value and ambient Euclidean differential returned by an objective.
247#[derive(Debug, Clone, PartialEq)]
248pub struct LatentCoordinateEvaluation {
249    pub objective_value: f64,
250    pub euclidean_gradient: Array1<f64>,
251}
252
253/// A concrete likelihood supplies the value and analytic latent derivatives.
254///
255/// Objective failures remain in the associated error type all the way through
256/// [`optimize_latent_coordinates`]. An implementation must not translate a
257/// failed inner solve into an infinite value or a zero gradient.
258pub trait LatentCoordinateObjective {
259    type Error: StdError + 'static;
260
261    fn value_and_gradient(
262        &mut self,
263        coordinates: ArrayView1<'_, f64>,
264    ) -> Result<LatentCoordinateEvaluation, Self::Error>;
265
266    /// Optional analytic Riemannian Hessian-vector product.
267    ///
268    /// Returning `None` selects the trust region's Cauchy model. This hook is
269    /// intentionally analytic-only; callers must not approximate it with
270    /// finite differences in production.
271    fn hessian_vector_product(
272        &mut self,
273        _coordinates: ArrayView1<'_, f64>,
274        _tangent: ArrayView1<'_, f64>,
275    ) -> Result<Option<Array1<f64>>, Self::Error> {
276        Ok(None)
277    }
278}
279
280/// Structural request failures detected before an objective is optimized.
281#[derive(Debug, Clone, PartialEq, Error)]
282pub enum LatentCoordinateRequestError {
283    #[error("n_observations must be positive")]
284    EmptyObservations,
285    #[error("latent_dimension must be positive")]
286    EmptyLatentDimension,
287    #[error(
288        "n_observations * latent_dimension overflows usize ({n_observations} * {latent_dimension})"
289    )]
290    DimensionOverflow {
291        n_observations: usize,
292        latent_dimension: usize,
293    },
294    #[error("circle latent coordinates require latent_dimension == 1, got {latent_dimension}")]
295    CircleDimension { latent_dimension: usize },
296    #[error("sphere latent coordinates require latent_dimension >= 2, got {latent_dimension}")]
297    SphereDimension { latent_dimension: usize },
298    #[error(
299        "{origin} coordinate length must equal n_observations * latent_dimension = {expected}, got {actual}"
300    )]
301    CoordinateLength {
302        origin: &'static str,
303        expected: usize,
304        actual: usize,
305    },
306    #[error("{origin} coordinate {index} must be finite, got {value}")]
307    NonFiniteCoordinate {
308        origin: &'static str,
309        index: usize,
310        value: f64,
311    },
312    #[error("stationarity_tolerance must be finite and in (0, 1], got {value}")]
313    InvalidStationarityTolerance { value: f64 },
314    #[error("initial_trust_radius must be finite and positive, got {value}")]
315    InvalidInitialTrustRadius { value: f64 },
316    #[error(
317        "max_trust_radius must be finite and at least initial_trust_radius ({initial}), got {maximum}"
318    )]
319    InvalidMaximumTrustRadius { initial: f64, maximum: f64 },
320    #[error("restart_count must be at least one")]
321    EmptyRestarts,
322    #[error("restart_scale must be finite and positive, got {value}")]
323    InvalidRestartScale { value: f64 },
324    #[error(
325        "checkpoint shape ({checkpoint_observations}, {checkpoint_dimension}) does not match request shape ({request_observations}, {request_dimension})"
326    )]
327    CheckpointShapeMismatch {
328        checkpoint_observations: usize,
329        checkpoint_dimension: usize,
330        request_observations: usize,
331        request_dimension: usize,
332    },
333    #[error("checkpoint manifold {checkpoint:?} does not match request manifold {request:?}")]
334    CheckpointManifoldMismatch {
335        checkpoint: LatentCoordinateManifold,
336        request: LatentCoordinateManifold,
337    },
338    #[error("checkpoint stationarity reference must be finite and non-negative, got {value}")]
339    InvalidCheckpointReference { value: f64 },
340}
341
342/// Failure constructing a typed resume checkpoint.
343///
344/// A checkpoint carries the same structural contract as a fresh request
345/// ([`LatentCoordinateRequestError`]) *and* must land on the manifold it names,
346/// so it additionally surfaces the geometric feasibility failure
347/// ([`GeometryError`]) raised when the stored point cannot be retracted onto the
348/// manifold (e.g. a non-unit sphere row).
349#[derive(Debug, Clone, PartialEq, Error)]
350pub enum LatentCoordinateCheckpointError {
351    #[error(transparent)]
352    Request(#[from] LatentCoordinateRequestError),
353    #[error(transparent)]
354    Geometry(#[from] GeometryError),
355}
356
357/// Invalid numerical data returned through the objective contract.
358#[derive(Debug, Clone, PartialEq, Error)]
359pub enum LatentCoordinateObjectiveContractError {
360    #[error("objective received a non-finite coordinate at index {index}: {value}")]
361    NonFinitePoint { index: usize, value: f64 },
362    #[error("objective value must be finite, got {value}")]
363    NonFiniteValue { value: f64 },
364    #[error("objective gradient length must be {expected}, got {actual}")]
365    GradientLength { expected: usize, actual: usize },
366    #[error("objective gradient component {index} must be finite, got {value}")]
367    NonFiniteGradient { index: usize, value: f64 },
368    #[error("objective gradient norm is not representable as a finite f64")]
369    NonFiniteGradientNorm,
370    #[error("Hessian-vector product length must be {expected}, got {actual}")]
371    HessianVectorLength { expected: usize, actual: usize },
372    #[error("Hessian-vector product component {index} must be finite, got {value}")]
373    NonFiniteHessianVector { index: usize, value: f64 },
374    #[error("Hessian-vector product norm is not representable as a finite f64")]
375    NonFiniteHessianVectorNorm,
376}
377
378/// Fatal optimization errors, preserving the concrete objective error type.
379#[derive(Debug, Error)]
380pub enum LatentCoordinateOptimizationError<E: StdError + 'static> {
381    #[error(transparent)]
382    InvalidRequest(#[from] LatentCoordinateRequestError),
383    #[error("latent-coordinate objective failed during restart {restart_index}: {source}")]
384    Objective {
385        restart_index: usize,
386        #[source]
387        source: E,
388    },
389    #[error("invalid latent-coordinate objective output during restart {restart_index}: {source}")]
390    InvalidObjectiveOutput {
391        restart_index: usize,
392        #[source]
393        source: LatentCoordinateObjectiveContractError,
394    },
395    #[error("latent-coordinate geometry failed during restart {restart_index}: {source}")]
396    Geometry {
397        restart_index: usize,
398        #[source]
399        source: GeometryError,
400    },
401    #[error("{evidence}")]
402    NonConverged {
403        evidence: LatentCoordinateStationarityEvidence,
404        checkpoint: LatentCoordinateCheckpoint,
405    },
406}
407
408impl<E: StdError + 'static> LatentCoordinateOptimizationError<E> {
409    pub fn stationarity_evidence(&self) -> Option<&LatentCoordinateStationarityEvidence> {
410        match self {
411            Self::NonConverged { evidence, .. } => Some(evidence),
412            _ => None,
413        }
414    }
415
416    pub fn checkpoint(&self) -> Option<&LatentCoordinateCheckpoint> {
417        match self {
418            Self::NonConverged { checkpoint, .. } => Some(checkpoint),
419            _ => None,
420        }
421    }
422}
423
424enum ObjectiveBridgeFailure<E> {
425    Objective(E),
426    Contract(LatentCoordinateObjectiveContractError),
427}
428
429struct ObjectiveBridge<'a, O: LatentCoordinateObjective + ?Sized> {
430    objective: &'a mut O,
431    expected_dimension: usize,
432    failure: Option<ObjectiveBridgeFailure<O::Error>>,
433    objective_evaluations: usize,
434    hessian_vector_evaluations: usize,
435}
436
437impl<'a, O: LatentCoordinateObjective + ?Sized> ObjectiveBridge<'a, O> {
438    fn new(objective: &'a mut O, expected_dimension: usize) -> Self {
439        Self {
440            objective,
441            expected_dimension,
442            failure: None,
443            objective_evaluations: 0,
444            hessian_vector_evaluations: 0,
445        }
446    }
447
448    fn fail_contract<T>(
449        &mut self,
450        failure: LatentCoordinateObjectiveContractError,
451    ) -> GeometryResult<T> {
452        self.failure = Some(ObjectiveBridgeFailure::Contract(failure));
453        Err(GeometryError::InvalidPoint(
454            "latent-coordinate objective contract failed",
455        ))
456    }
457
458    fn checked_value_gradient(
459        &mut self,
460        point: ArrayView1<'_, f64>,
461    ) -> GeometryResult<LatentCoordinateEvaluation> {
462        self.objective_evaluations += 1;
463        if let Some((index, value)) = first_non_finite(point) {
464            return self.fail_contract(LatentCoordinateObjectiveContractError::NonFinitePoint {
465                index,
466                value,
467            });
468        }
469        let evaluation = match self.objective.value_and_gradient(point) {
470            Ok(evaluation) => evaluation,
471            Err(source) => {
472                self.failure = Some(ObjectiveBridgeFailure::Objective(source));
473                return Err(GeometryError::InvalidPoint(
474                    "latent-coordinate objective evaluation failed",
475                ));
476            }
477        };
478        if !evaluation.objective_value.is_finite() {
479            return self.fail_contract(LatentCoordinateObjectiveContractError::NonFiniteValue {
480                value: evaluation.objective_value,
481            });
482        }
483        if evaluation.euclidean_gradient.len() != self.expected_dimension {
484            return self.fail_contract(LatentCoordinateObjectiveContractError::GradientLength {
485                expected: self.expected_dimension,
486                actual: evaluation.euclidean_gradient.len(),
487            });
488        }
489        if let Some((index, value)) = first_non_finite(evaluation.euclidean_gradient.view()) {
490            return self.fail_contract(LatentCoordinateObjectiveContractError::NonFiniteGradient {
491                index,
492                value,
493            });
494        }
495        if !stable_euclidean_norm(evaluation.euclidean_gradient.view()).is_finite() {
496            return self
497                .fail_contract(LatentCoordinateObjectiveContractError::NonFiniteGradientNorm);
498        }
499        Ok(evaluation)
500    }
501
502    fn checked_hessian_vector_product(
503        &mut self,
504        point: ArrayView1<'_, f64>,
505        tangent: ArrayView1<'_, f64>,
506    ) -> GeometryResult<Option<Array1<f64>>> {
507        self.hessian_vector_evaluations += 1;
508        let product = match self.objective.hessian_vector_product(point, tangent) {
509            Ok(product) => product,
510            Err(source) => {
511                self.failure = Some(ObjectiveBridgeFailure::Objective(source));
512                return Err(GeometryError::InvalidPoint(
513                    "latent-coordinate objective Hessian-vector product failed",
514                ));
515            }
516        };
517        let Some(product) = product else {
518            return Ok(None);
519        };
520        if product.len() != self.expected_dimension {
521            return self.fail_contract(
522                LatentCoordinateObjectiveContractError::HessianVectorLength {
523                    expected: self.expected_dimension,
524                    actual: product.len(),
525                },
526            );
527        }
528        if let Some((index, value)) = first_non_finite(product.view()) {
529            return self.fail_contract(
530                LatentCoordinateObjectiveContractError::NonFiniteHessianVector { index, value },
531            );
532        }
533        if !stable_euclidean_norm(product.view()).is_finite() {
534            return self
535                .fail_contract(LatentCoordinateObjectiveContractError::NonFiniteHessianVectorNorm);
536        }
537        Ok(Some(product))
538    }
539}
540
541impl<O: LatentCoordinateObjective + ?Sized> RiemannianObjective for ObjectiveBridge<'_, O> {
542    fn value_gradient(&mut self, point: ArrayView1<'_, f64>) -> GeometryResult<(f64, Array1<f64>)> {
543        let evaluation = self.checked_value_gradient(point)?;
544        Ok((evaluation.objective_value, evaluation.euclidean_gradient))
545    }
546
547    fn hessian_vector_product(
548        &mut self,
549        point: ArrayView1<'_, f64>,
550        tangent: ArrayView1<'_, f64>,
551    ) -> GeometryResult<Option<Array1<f64>>> {
552        self.checked_hessian_vector_product(point, tangent)
553    }
554}
555
556struct Candidate {
557    coordinates: Array1<f64>,
558    evidence: LatentCoordinateStationarityEvidence,
559}
560
561/// Optimize a concrete analytic objective over typed latent-coordinate geometry.
562///
563/// The function returns a result only when the lowest-objective restart passes
564/// the post-hoc projected-gradient certificate. Budget exhaustion or a
565/// trust-region stall therefore yields [`LatentCoordinateOptimizationError::NonConverged`]
566/// with resume state, never a partial fit-shaped success value.
567pub fn optimize_latent_coordinates<O: LatentCoordinateObjective + ?Sized>(
568    request: LatentCoordinateOptimizationRequest,
569    objective: &mut O,
570) -> Result<LatentCoordinateOptimizationResult, LatentCoordinateOptimizationError<O::Error>> {
571    validate_request(&request)?;
572    let LatentCoordinateOptimizationRequest {
573        n_observations,
574        latent_dimension,
575        manifold: manifold_kind,
576        start,
577        options,
578    } = request;
579    let expected_dimension = n_observations * latent_dimension;
580    let (base_coordinates, resume_reference) = match start {
581        LatentCoordinateStart::Initial(coordinates) => (coordinates, None),
582        LatentCoordinateStart::Resume(checkpoint) => {
583            let reference = checkpoint.stationarity_reference;
584            (checkpoint.coordinates, Some(reference))
585        }
586    };
587    let manifold = manifold_kind
588        .build(n_observations, latent_dimension)
589        .map_err(|source| LatentCoordinateOptimizationError::Geometry {
590            restart_index: 0,
591            source,
592        })?;
593    let base_coordinates = canonicalize_and_validate_point(manifold.as_ref(), base_coordinates)
594        .map_err(|source| LatentCoordinateOptimizationError::Geometry {
595            restart_index: 0,
596            source,
597        })?;
598
599    let mut best = optimize_one_restart(
600        manifold.as_ref(),
601        objective,
602        base_coordinates.clone(),
603        0,
604        &options,
605        resume_reference,
606    )?;
607    let mut rng = rand::rngs::StdRng::seed_from_u64(options.seed);
608    for restart_index in 1..options.restart_count {
609        let noise = Array1::from_shape_fn(expected_dimension, |_| {
610            let standard_normal: f64 = StandardNormal.sample(&mut rng);
611            options.restart_scale * standard_normal
612        });
613        let tangent = manifold
614            .project_tangent(base_coordinates.view(), noise.view())
615            .map_err(|source| LatentCoordinateOptimizationError::Geometry {
616                restart_index,
617                source,
618            })?;
619        let restart_coordinates = manifold
620            .retract(base_coordinates.view(), tangent.view())
621            .map_err(|source| LatentCoordinateOptimizationError::Geometry {
622                restart_index,
623                source,
624            })?;
625        let candidate = optimize_one_restart(
626            manifold.as_ref(),
627            objective,
628            restart_coordinates,
629            restart_index,
630            &options,
631            resume_reference,
632        )?;
633        if candidate.evidence.objective_value < best.evidence.objective_value {
634            best = candidate;
635        }
636    }
637
638    if best.evidence.certifies_stationarity() {
639        return Ok(LatentCoordinateOptimizationResult {
640            coordinates: best.coordinates,
641            evidence: best.evidence,
642        });
643    }
644    let checkpoint = LatentCoordinateCheckpoint {
645        coordinates: best.coordinates,
646        n_observations,
647        latent_dimension,
648        manifold: manifold_kind,
649        stationarity_reference: best.evidence.stationarity_reference,
650        restart_index: best.evidence.restart_index,
651    };
652    Err(LatentCoordinateOptimizationError::NonConverged {
653        evidence: best.evidence,
654        checkpoint,
655    })
656}
657
658fn optimize_one_restart<O: LatentCoordinateObjective + ?Sized>(
659    manifold: &dyn RiemannianManifold,
660    objective: &mut O,
661    start: Array1<f64>,
662    restart_index: usize,
663    options: &LatentCoordinateOptimizationOptions,
664    resume_reference: Option<f64>,
665) -> Result<Candidate, LatentCoordinateOptimizationError<O::Error>> {
666    let start = canonicalize_and_validate_point(manifold, start).map_err(|source| {
667        LatentCoordinateOptimizationError::Geometry {
668            restart_index,
669            source,
670        }
671    })?;
672    let mut bridge = ObjectiveBridge::new(objective, start.len());
673    let start_evaluation_result = bridge.checked_value_gradient(start.view());
674    let start_evaluation =
675        translate_bridge_result(&mut bridge, restart_index, start_evaluation_result)?;
676    let start_gradient_norm = projected_gradient_norm(
677        manifold,
678        start.view(),
679        start_evaluation.euclidean_gradient.view(),
680    )
681    .map_err(|source| LatentCoordinateOptimizationError::Geometry {
682        restart_index,
683        source,
684    })?;
685    let stationarity_reference = resume_reference.unwrap_or(start_gradient_norm);
686    let solver_tolerance = options.stationarity_tolerance * stationarity_reference.max(1.0)
687        / start_gradient_norm.max(1.0);
688    let trust_region = RiemannianTrustRegion {
689        radius: options.initial_trust_radius,
690        max_radius: options.max_trust_radius,
691        max_iter: options.max_iterations,
692        grad_tol: solver_tolerance,
693    };
694    // Take the terminal iterate even when it fails the trust region's own
695    // first-order certificate. `minimize` reports that case as
696    // `GeometryError::NonConvergence`, which `translate_bridge_result` maps to
697    // `LatentCoordinateOptimizationError::Geometry` — a variant carrying neither
698    // stationarity evidence nor a checkpoint. That laundered a plain
699    // non-convergence into a fatal geometry failure and made the
700    // `NonConverged { evidence, checkpoint }` path below unreachable whenever the
701    // trust region did not certify, i.e. in exactly the case a checkpoint is for:
702    // `checkpoint()` returned `None` and the run could not be resumed. The
703    // certification decision belongs to `evidence.certifies_stationarity()` in
704    // the caller, which measures the projected gradient against
705    // `stationarity_reference` under the ORIGINAL reference (preserved across a
706    // resume); a genuine failure — non-finite value, invalid radius, objective or
707    // manifold error — is still an error and still propagates here.
708    let optimized_result =
709        trust_region.minimize_reporting_termination(manifold, &mut bridge, start.view());
710    let optimized = translate_bridge_result(&mut bridge, restart_index, optimized_result)?.point;
711    let final_evaluation_result = bridge.checked_value_gradient(optimized.view());
712    let final_evaluation =
713        translate_bridge_result(&mut bridge, restart_index, final_evaluation_result)?;
714    let final_gradient_norm = projected_gradient_norm(
715        manifold,
716        optimized.view(),
717        final_evaluation.euclidean_gradient.view(),
718    )
719    .map_err(|source| LatentCoordinateOptimizationError::Geometry {
720        restart_index,
721        source,
722    })?;
723    let relative_gradient = relative_stationarity(final_gradient_norm, stationarity_reference);
724    let evidence = LatentCoordinateStationarityEvidence {
725        objective_value: final_evaluation.objective_value,
726        projected_gradient_norm: final_gradient_norm,
727        stationarity_reference,
728        relative_gradient,
729        tolerance: options.stationarity_tolerance,
730        coordinate_spread: coordinate_spread(optimized.view()),
731        restart_index,
732        restart_count: options.restart_count,
733        iteration_budget: options.max_iterations,
734        objective_evaluations: bridge.objective_evaluations,
735        hessian_vector_evaluations: bridge.hessian_vector_evaluations,
736    };
737    Ok(Candidate {
738        coordinates: optimized,
739        evidence,
740    })
741}
742
743fn translate_bridge_result<T, O: LatentCoordinateObjective + ?Sized>(
744    bridge: &mut ObjectiveBridge<'_, O>,
745    restart_index: usize,
746    result: GeometryResult<T>,
747) -> Result<T, LatentCoordinateOptimizationError<O::Error>> {
748    match result {
749        Ok(value) => Ok(value),
750        Err(source) => match bridge.failure.take() {
751            Some(ObjectiveBridgeFailure::Objective(source)) => {
752                Err(LatentCoordinateOptimizationError::Objective {
753                    restart_index,
754                    source,
755                })
756            }
757            Some(ObjectiveBridgeFailure::Contract(source)) => {
758                Err(LatentCoordinateOptimizationError::InvalidObjectiveOutput {
759                    restart_index,
760                    source,
761                })
762            }
763            None => Err(LatentCoordinateOptimizationError::Geometry {
764                restart_index,
765                source,
766            }),
767        },
768    }
769}
770
771fn canonicalize_and_validate_point(
772    manifold: &dyn RiemannianManifold,
773    point: Array1<f64>,
774) -> GeometryResult<Array1<f64>> {
775    let zero = Array1::<f64>::zeros(point.len());
776    let canonical = manifold.retract(point.view(), zero.view())?;
777    if first_non_finite(canonical.view()).is_some() {
778        return Err(GeometryError::InvalidPoint(
779            "latent-coordinate retraction produced a non-finite point",
780        ));
781    }
782    // For embedded manifolds this validates feasibility as well as dimension.
783    // In particular, SphereManifold rejects non-unit rows here.
784    let validation_gradient = manifold.riemannian_gradient(canonical.view(), zero.view())?;
785    if first_non_finite(validation_gradient.view()).is_some() {
786        return Err(GeometryError::InvalidPoint(
787            "latent-coordinate manifold produced a non-finite tangent vector",
788        ));
789    }
790    Ok(canonical)
791}
792
793fn projected_gradient_norm(
794    manifold: &dyn RiemannianManifold,
795    point: ArrayView1<'_, f64>,
796    euclidean_gradient: ArrayView1<'_, f64>,
797) -> GeometryResult<f64> {
798    let gradient = manifold.riemannian_gradient(point, euclidean_gradient)?;
799    let norm = stable_euclidean_norm(gradient.view());
800    if !norm.is_finite() {
801        return Err(GeometryError::InvalidPoint(
802            "latent-coordinate Riemannian gradient norm is non-finite",
803        ));
804    }
805    // Every manifold exposed by LatentCoordinateManifold carries the induced
806    // ambient identity metric, so this Euclidean norm is exactly its metric
807    // norm without allocating a dense ambient-dimension-squared tensor.
808    Ok(norm)
809}
810
811fn relative_stationarity(gradient_norm: f64, stationarity_reference: f64) -> f64 {
812    if !gradient_norm.is_finite() || !stationarity_reference.is_finite() {
813        return f64::INFINITY;
814    }
815    gradient_norm / stationarity_reference.max(1.0)
816}
817
818fn stable_euclidean_norm(values: ArrayView1<'_, f64>) -> f64 {
819    values
820        .iter()
821        .fold(0.0_f64, |norm, value| norm.hypot(*value))
822}
823
824fn first_non_finite(values: ArrayView1<'_, f64>) -> Option<(usize, f64)> {
825    values
826        .iter()
827        .copied()
828        .enumerate()
829        .find(|(_, value)| !value.is_finite())
830}
831
832fn coordinate_spread(coordinates: ArrayView1<'_, f64>) -> f64 {
833    if coordinates.is_empty() {
834        return 0.0;
835    }
836    let mut count = 0.0_f64;
837    let mut mean = 0.0_f64;
838    let mut squared_deviation = 0.0_f64;
839    for value in coordinates {
840        count += 1.0;
841        let delta = value - mean;
842        mean += delta / count;
843        squared_deviation += delta * (value - mean);
844    }
845    (squared_deviation / count).max(0.0).sqrt()
846}
847
848/// Enforce that `latent_dimension` is compatible with the manifold's intrinsic
849/// geometry: a circle carries exactly one ambient angle, a sphere `S^(k-1)`
850/// needs an ambient dimension `k >= 2`. Euclidean and toroidal latents accept
851/// any positive dimension.
852///
853/// This is the single source of the manifold–dimension contract, shared by the
854/// full-request path ([`validate_request`]), the checkpoint path
855/// ([`validate_dimensions`]), and the domain descriptor
856/// ([`LatentCoordinateManifold::axis_domains`]).
857fn validate_manifold_dimension(
858    manifold: LatentCoordinateManifold,
859    latent_dimension: usize,
860) -> Result<(), LatentCoordinateRequestError> {
861    if latent_dimension == 0 {
862        return Err(LatentCoordinateRequestError::EmptyLatentDimension);
863    }
864    match manifold {
865        LatentCoordinateManifold::Circle if latent_dimension != 1 => {
866            Err(LatentCoordinateRequestError::CircleDimension { latent_dimension })
867        }
868        LatentCoordinateManifold::Sphere if latent_dimension < 2 => {
869            Err(LatentCoordinateRequestError::SphereDimension { latent_dimension })
870        }
871        _ => Ok(()),
872    }
873}
874
875/// Validate the `(n_observations, latent_dimension, manifold)` triple and return
876/// the expected flattened coordinate length `n_observations * latent_dimension`.
877///
878/// Rejects empty extents, overflowing products, and manifold-incompatible
879/// dimensions with the same errors [`validate_request`] uses, so a checkpoint
880/// built in isolation carries an identical structural contract.
881fn validate_dimensions(
882    n_observations: usize,
883    latent_dimension: usize,
884    manifold: LatentCoordinateManifold,
885) -> Result<usize, LatentCoordinateRequestError> {
886    if n_observations == 0 {
887        return Err(LatentCoordinateRequestError::EmptyObservations);
888    }
889    if latent_dimension == 0 {
890        return Err(LatentCoordinateRequestError::EmptyLatentDimension);
891    }
892    let expected = n_observations.checked_mul(latent_dimension).ok_or(
893        LatentCoordinateRequestError::DimensionOverflow {
894            n_observations,
895            latent_dimension,
896        },
897    )?;
898    validate_manifold_dimension(manifold, latent_dimension)?;
899    Ok(expected)
900}
901
902fn validate_request(
903    request: &LatentCoordinateOptimizationRequest,
904) -> Result<(), LatentCoordinateRequestError> {
905    let expected =
906        validate_dimensions(request.n_observations, request.latent_dimension, request.manifold)?;
907    let options = &request.options;
908    if !(options.stationarity_tolerance.is_finite()
909        && options.stationarity_tolerance > 0.0
910        && options.stationarity_tolerance <= 1.0)
911    {
912        return Err(LatentCoordinateRequestError::InvalidStationarityTolerance {
913            value: options.stationarity_tolerance,
914        });
915    }
916    if !(options.initial_trust_radius.is_finite() && options.initial_trust_radius > 0.0) {
917        return Err(LatentCoordinateRequestError::InvalidInitialTrustRadius {
918            value: options.initial_trust_radius,
919        });
920    }
921    if !(options.max_trust_radius.is_finite()
922        && options.max_trust_radius >= options.initial_trust_radius)
923    {
924        return Err(LatentCoordinateRequestError::InvalidMaximumTrustRadius {
925            initial: options.initial_trust_radius,
926            maximum: options.max_trust_radius,
927        });
928    }
929    if options.restart_count == 0 {
930        return Err(LatentCoordinateRequestError::EmptyRestarts);
931    }
932    if !(options.restart_scale.is_finite() && options.restart_scale > 0.0) {
933        return Err(LatentCoordinateRequestError::InvalidRestartScale {
934            value: options.restart_scale,
935        });
936    }
937    match &request.start {
938        LatentCoordinateStart::Initial(coordinates) => {
939            validate_coordinates("initial", coordinates.view(), expected)?;
940        }
941        LatentCoordinateStart::Resume(checkpoint) => {
942            if checkpoint.n_observations != request.n_observations
943                || checkpoint.latent_dimension != request.latent_dimension
944            {
945                return Err(LatentCoordinateRequestError::CheckpointShapeMismatch {
946                    checkpoint_observations: checkpoint.n_observations,
947                    checkpoint_dimension: checkpoint.latent_dimension,
948                    request_observations: request.n_observations,
949                    request_dimension: request.latent_dimension,
950                });
951            }
952            if checkpoint.manifold != request.manifold {
953                return Err(LatentCoordinateRequestError::CheckpointManifoldMismatch {
954                    checkpoint: checkpoint.manifold,
955                    request: request.manifold,
956                });
957            }
958            if !(checkpoint.stationarity_reference.is_finite()
959                && checkpoint.stationarity_reference >= 0.0)
960            {
961                return Err(LatentCoordinateRequestError::InvalidCheckpointReference {
962                    value: checkpoint.stationarity_reference,
963                });
964            }
965            validate_coordinates("checkpoint", checkpoint.coordinates.view(), expected)?;
966        }
967    }
968    Ok(())
969}
970
971fn validate_coordinates(
972    origin: &'static str,
973    coordinates: ArrayView1<'_, f64>,
974    expected: usize,
975) -> Result<(), LatentCoordinateRequestError> {
976    if coordinates.len() != expected {
977        return Err(LatentCoordinateRequestError::CoordinateLength {
978            origin,
979            expected,
980            actual: coordinates.len(),
981        });
982    }
983    if let Some((index, value)) = first_non_finite(coordinates) {
984        return Err(LatentCoordinateRequestError::NonFiniteCoordinate {
985            origin,
986            index,
987            value,
988        });
989    }
990    Ok(())
991}
992
993#[cfg(test)]
994mod tests {
995    use ndarray::{Array1, ArrayView1, array};
996    use thiserror::Error;
997
998    use super::*;
999
1000    #[derive(Debug, Error, PartialEq)]
1001    enum TestObjectiveError {
1002        #[error("inner REML solve failed")]
1003        InnerSolve,
1004    }
1005
1006    struct QuadraticObjective {
1007        target: Array1<f64>,
1008    }
1009
1010    impl LatentCoordinateObjective for QuadraticObjective {
1011        type Error = TestObjectiveError;
1012
1013        fn value_and_gradient(
1014            &mut self,
1015            coordinates: ArrayView1<'_, f64>,
1016        ) -> Result<LatentCoordinateEvaluation, Self::Error> {
1017            let gradient = &coordinates - &self.target;
1018            let objective_value = 0.5 * gradient.dot(&gradient);
1019            Ok(LatentCoordinateEvaluation {
1020                objective_value,
1021                euclidean_gradient: gradient,
1022            })
1023        }
1024
1025        fn hessian_vector_product(
1026            &mut self,
1027            coordinates: ArrayView1<'_, f64>,
1028            tangent: ArrayView1<'_, f64>,
1029        ) -> Result<Option<Array1<f64>>, Self::Error> {
1030            assert_eq!(coordinates.len(), tangent.len());
1031            Ok(Some(tangent.to_owned()))
1032        }
1033    }
1034
1035    fn options(max_iterations: usize, restart_count: usize) -> LatentCoordinateOptimizationOptions {
1036        LatentCoordinateOptimizationOptions {
1037            max_iterations,
1038            stationarity_tolerance: 1.0e-10,
1039            initial_trust_radius: 10.0,
1040            max_trust_radius: 10.0,
1041            restart_count,
1042            restart_scale: 0.2,
1043            seed: 42,
1044        }
1045    }
1046
1047    fn euclidean_request(
1048        coordinates: Array1<f64>,
1049        options: LatentCoordinateOptimizationOptions,
1050    ) -> LatentCoordinateOptimizationRequest {
1051        LatentCoordinateOptimizationRequest {
1052            n_observations: coordinates.len(),
1053            latent_dimension: 1,
1054            manifold: LatentCoordinateManifold::Euclidean,
1055            start: LatentCoordinateStart::Initial(coordinates),
1056            options,
1057        }
1058    }
1059
1060    #[test]
1061    fn quadratic_returns_only_a_certified_result() {
1062        let request = euclidean_request(array![5.0, -3.0], options(2, 1));
1063        let mut objective = QuadraticObjective {
1064            target: array![1.0, 2.0],
1065        };
1066        let result = optimize_latent_coordinates(request, &mut objective).unwrap();
1067        assert!(result.evidence.certifies_stationarity());
1068        assert!(result.evidence.relative_gradient <= 1.0e-10);
1069        assert!(
1070            (&result.coordinates - &array![1.0, 2.0])
1071                .iter()
1072                .all(|difference| difference.abs() <= 1.0e-12)
1073        );
1074    }
1075
1076    #[test]
1077    fn exhausted_run_returns_evidence_and_a_serializable_checkpoint() {
1078        let request = euclidean_request(array![5.0], options(0, 1));
1079        let mut objective = QuadraticObjective {
1080            target: array![0.0],
1081        };
1082        let error = optimize_latent_coordinates(request, &mut objective).unwrap_err();
1083        // Name the variant that arrived. "expected typed non-convergence" alone
1084        // cannot distinguish the two ways this fails — a run that certified when
1085        // it should not have, versus a non-convergence reported under a variant
1086        // carrying neither evidence nor a checkpoint — and those have opposite
1087        // fixes. Rendered before the destructuring move so the else arm can
1088        // report it.
1089        let reported = format!("{error:?}");
1090        let LatentCoordinateOptimizationError::NonConverged {
1091            evidence,
1092            checkpoint,
1093        } = error
1094        else {
1095            panic!("expected typed non-convergence, got {reported}");
1096        };
1097        assert!(!evidence.certifies_stationarity());
1098        assert_eq!(evidence.stationarity_reference, 5.0);
1099        assert_eq!(checkpoint.coordinates(), array![5.0].view());
1100        let encoded = serde_json::to_string(&checkpoint).unwrap();
1101        let decoded: LatentCoordinateCheckpoint = serde_json::from_str(&encoded).unwrap();
1102        assert_eq!(decoded, checkpoint);
1103    }
1104
1105    #[test]
1106    fn resume_preserves_the_original_stationarity_reference() {
1107        let first_request = euclidean_request(array![5.0], options(0, 1));
1108        let mut first_objective = QuadraticObjective {
1109            target: array![0.0],
1110        };
1111        let first_error =
1112            optimize_latent_coordinates(first_request, &mut first_objective).unwrap_err();
1113        let checkpoint = first_error.checkpoint().unwrap().clone();
1114        let resumed_request = LatentCoordinateOptimizationRequest {
1115            n_observations: 1,
1116            latent_dimension: 1,
1117            manifold: LatentCoordinateManifold::Euclidean,
1118            start: LatentCoordinateStart::Resume(checkpoint),
1119            options: options(2, 1),
1120        };
1121        let mut resumed_objective = QuadraticObjective {
1122            target: array![0.0],
1123        };
1124        let result = optimize_latent_coordinates(resumed_request, &mut resumed_objective).unwrap();
1125        assert_eq!(result.evidence.stationarity_reference, 5.0);
1126        assert!(result.evidence.certifies_stationarity());
1127    }
1128
1129    struct FailingObjective;
1130
1131    impl LatentCoordinateObjective for FailingObjective {
1132        type Error = TestObjectiveError;
1133
1134        fn value_and_gradient(
1135            &mut self,
1136            coordinates: ArrayView1<'_, f64>,
1137        ) -> Result<LatentCoordinateEvaluation, Self::Error> {
1138            assert!(coordinates.iter().all(|value| value.is_finite()));
1139            Err(TestObjectiveError::InnerSolve)
1140        }
1141    }
1142
1143    #[test]
1144    fn fatal_objective_failure_is_preserved_exactly() {
1145        let request = euclidean_request(array![1.0], options(4, 1));
1146        let error = optimize_latent_coordinates(request, &mut FailingObjective).unwrap_err();
1147        match error {
1148            LatentCoordinateOptimizationError::Objective {
1149                restart_index,
1150                source,
1151            } => {
1152                assert_eq!(restart_index, 0);
1153                assert_eq!(source, TestObjectiveError::InnerSolve);
1154            }
1155            other => panic!("expected objective error, got {other:?}"),
1156        }
1157    }
1158
1159    struct NonFiniteObjective;
1160
1161    impl LatentCoordinateObjective for NonFiniteObjective {
1162        type Error = TestObjectiveError;
1163
1164        fn value_and_gradient(
1165            &mut self,
1166            coordinates: ArrayView1<'_, f64>,
1167        ) -> Result<LatentCoordinateEvaluation, Self::Error> {
1168            Ok(LatentCoordinateEvaluation {
1169                objective_value: f64::INFINITY,
1170                euclidean_gradient: Array1::zeros(coordinates.len()),
1171            })
1172        }
1173    }
1174
1175    #[test]
1176    fn non_finite_objective_is_not_false_stationarity() {
1177        let request = euclidean_request(array![1.0], options(4, 1));
1178        let error = optimize_latent_coordinates(request, &mut NonFiniteObjective).unwrap_err();
1179        assert!(matches!(
1180            error,
1181            LatentCoordinateOptimizationError::InvalidObjectiveOutput {
1182                source: LatentCoordinateObjectiveContractError::NonFiniteValue { .. },
1183                ..
1184            }
1185        ));
1186    }
1187
1188    struct RecordingStationaryObjective {
1189        points: Vec<Array1<f64>>,
1190    }
1191
1192    impl LatentCoordinateObjective for RecordingStationaryObjective {
1193        type Error = TestObjectiveError;
1194
1195        fn value_and_gradient(
1196            &mut self,
1197            coordinates: ArrayView1<'_, f64>,
1198        ) -> Result<LatentCoordinateEvaluation, Self::Error> {
1199            self.points.push(coordinates.to_owned());
1200            Ok(LatentCoordinateEvaluation {
1201                objective_value: 0.0,
1202                euclidean_gradient: Array1::zeros(coordinates.len()),
1203            })
1204        }
1205    }
1206
1207    #[test]
1208    fn sphere_restarts_remain_on_the_product_manifold() {
1209        let request = LatentCoordinateOptimizationRequest {
1210            n_observations: 2,
1211            latent_dimension: 3,
1212            manifold: LatentCoordinateManifold::Sphere,
1213            start: LatentCoordinateStart::Initial(array![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]),
1214            options: options(0, 3),
1215        };
1216        let mut objective = RecordingStationaryObjective { points: Vec::new() };
1217        let result = optimize_latent_coordinates(request, &mut objective).unwrap();
1218        assert!(result.evidence.certifies_stationarity());
1219        assert!(!objective.points.is_empty());
1220        for point in &objective.points {
1221            for row in point.as_slice().unwrap().chunks_exact(3) {
1222                let norm = row.iter().fold(0.0_f64, |acc, value| acc.hypot(*value));
1223                assert!((norm - 1.0).abs() <= 1.0e-10);
1224            }
1225        }
1226    }
1227}