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    pub fn into_coordinates(self) -> Array1<f64> {
196        self.coordinates
197    }
198}
199
200/// Exact evidence used to accept or reject the best restart.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct LatentCoordinateStationarityEvidence {
203    pub objective_value: f64,
204    pub projected_gradient_norm: f64,
205    pub stationarity_reference: f64,
206    pub relative_gradient: f64,
207    pub tolerance: f64,
208    pub coordinate_spread: f64,
209    pub restart_index: usize,
210    pub restart_count: usize,
211    pub iteration_budget: usize,
212    pub objective_evaluations: usize,
213    pub hessian_vector_evaluations: usize,
214}
215
216impl LatentCoordinateStationarityEvidence {
217    /// Whether this evidence satisfies the optimizer's first-order contract.
218    pub fn certifies_stationarity(&self) -> bool {
219        self.relative_gradient.is_finite() && self.relative_gradient <= self.tolerance
220    }
221}
222
223impl fmt::Display for LatentCoordinateStationarityEvidence {
224    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
225        write!(
226            formatter,
227            "latent-coordinate optimization did not reach stationarity at restart {} of {}: \
228             relative gradient {:.6e} exceeds tolerance {:.6e} (projected gradient {:.6e}, \
229             stationarity reference {:.6e}, objective {:.9e}, iteration budget {})",
230            self.restart_index,
231            self.restart_count,
232            self.relative_gradient,
233            self.tolerance,
234            self.projected_gradient_norm,
235            self.stationarity_reference,
236            self.objective_value,
237            self.iteration_budget,
238        )
239    }
240}
241
242/// A latent coordinate vector that passed the required stationarity test.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct LatentCoordinateOptimizationResult {
245    pub coordinates: Array1<f64>,
246    pub evidence: LatentCoordinateStationarityEvidence,
247}
248
249/// Value and ambient Euclidean differential returned by an objective.
250#[derive(Debug, Clone, PartialEq)]
251pub struct LatentCoordinateEvaluation {
252    pub objective_value: f64,
253    pub euclidean_gradient: Array1<f64>,
254}
255
256/// A concrete likelihood supplies the value and analytic latent derivatives.
257///
258/// Objective failures remain in the associated error type all the way through
259/// [`optimize_latent_coordinates`]. An implementation must not translate a
260/// failed inner solve into an infinite value or a zero gradient.
261pub trait LatentCoordinateObjective {
262    type Error: StdError + 'static;
263
264    fn value_and_gradient(
265        &mut self,
266        coordinates: ArrayView1<'_, f64>,
267    ) -> Result<LatentCoordinateEvaluation, Self::Error>;
268
269    /// Optional analytic Riemannian Hessian-vector product.
270    ///
271    /// Returning `None` selects the trust region's Cauchy model. This hook is
272    /// intentionally analytic-only; callers must not approximate it with
273    /// finite differences in production.
274    fn hessian_vector_product(
275        &mut self,
276        _coordinates: ArrayView1<'_, f64>,
277        _tangent: ArrayView1<'_, f64>,
278    ) -> Result<Option<Array1<f64>>, Self::Error> {
279        Ok(None)
280    }
281}
282
283/// Structural request failures detected before an objective is optimized.
284#[derive(Debug, Clone, PartialEq, Error)]
285pub enum LatentCoordinateRequestError {
286    #[error("n_observations must be positive")]
287    EmptyObservations,
288    #[error("latent_dimension must be positive")]
289    EmptyLatentDimension,
290    #[error(
291        "n_observations * latent_dimension overflows usize ({n_observations} * {latent_dimension})"
292    )]
293    DimensionOverflow {
294        n_observations: usize,
295        latent_dimension: usize,
296    },
297    #[error("circle latent coordinates require latent_dimension == 1, got {latent_dimension}")]
298    CircleDimension { latent_dimension: usize },
299    #[error("sphere latent coordinates require latent_dimension >= 2, got {latent_dimension}")]
300    SphereDimension { latent_dimension: usize },
301    #[error(
302        "{origin} coordinate length must equal n_observations * latent_dimension = {expected}, got {actual}"
303    )]
304    CoordinateLength {
305        origin: &'static str,
306        expected: usize,
307        actual: usize,
308    },
309    #[error("{origin} coordinate {index} must be finite, got {value}")]
310    NonFiniteCoordinate {
311        origin: &'static str,
312        index: usize,
313        value: f64,
314    },
315    #[error("stationarity_tolerance must be finite and in (0, 1], got {value}")]
316    InvalidStationarityTolerance { value: f64 },
317    #[error("initial_trust_radius must be finite and positive, got {value}")]
318    InvalidInitialTrustRadius { value: f64 },
319    #[error(
320        "max_trust_radius must be finite and at least initial_trust_radius ({initial}), got {maximum}"
321    )]
322    InvalidMaximumTrustRadius { initial: f64, maximum: f64 },
323    #[error("restart_count must be at least one")]
324    EmptyRestarts,
325    #[error("restart_scale must be finite and positive, got {value}")]
326    InvalidRestartScale { value: f64 },
327    #[error(
328        "checkpoint shape ({checkpoint_observations}, {checkpoint_dimension}) does not match request shape ({request_observations}, {request_dimension})"
329    )]
330    CheckpointShapeMismatch {
331        checkpoint_observations: usize,
332        checkpoint_dimension: usize,
333        request_observations: usize,
334        request_dimension: usize,
335    },
336    #[error("checkpoint manifold {checkpoint:?} does not match request manifold {request:?}")]
337    CheckpointManifoldMismatch {
338        checkpoint: LatentCoordinateManifold,
339        request: LatentCoordinateManifold,
340    },
341    #[error("checkpoint stationarity reference must be finite and non-negative, got {value}")]
342    InvalidCheckpointReference { value: f64 },
343}
344
345/// Failure constructing a typed resume checkpoint.
346///
347/// A checkpoint carries the same structural contract as a fresh request
348/// ([`LatentCoordinateRequestError`]) *and* must land on the manifold it names,
349/// so it additionally surfaces the geometric feasibility failure
350/// ([`GeometryError`]) raised when the stored point cannot be retracted onto the
351/// manifold (e.g. a non-unit sphere row).
352#[derive(Debug, Clone, PartialEq, Error)]
353pub enum LatentCoordinateCheckpointError {
354    #[error(transparent)]
355    Request(#[from] LatentCoordinateRequestError),
356    #[error(transparent)]
357    Geometry(#[from] GeometryError),
358}
359
360/// Invalid numerical data returned through the objective contract.
361#[derive(Debug, Clone, PartialEq, Error)]
362pub enum LatentCoordinateObjectiveContractError {
363    #[error("objective received a non-finite coordinate at index {index}: {value}")]
364    NonFinitePoint { index: usize, value: f64 },
365    #[error("objective value must be finite, got {value}")]
366    NonFiniteValue { value: f64 },
367    #[error("objective gradient length must be {expected}, got {actual}")]
368    GradientLength { expected: usize, actual: usize },
369    #[error("objective gradient component {index} must be finite, got {value}")]
370    NonFiniteGradient { index: usize, value: f64 },
371    #[error("objective gradient norm is not representable as a finite f64")]
372    NonFiniteGradientNorm,
373    #[error("Hessian-vector product length must be {expected}, got {actual}")]
374    HessianVectorLength { expected: usize, actual: usize },
375    #[error("Hessian-vector product component {index} must be finite, got {value}")]
376    NonFiniteHessianVector { index: usize, value: f64 },
377    #[error("Hessian-vector product norm is not representable as a finite f64")]
378    NonFiniteHessianVectorNorm,
379}
380
381/// Fatal optimization errors, preserving the concrete objective error type.
382#[derive(Debug, Error)]
383pub enum LatentCoordinateOptimizationError<E: StdError + 'static> {
384    #[error(transparent)]
385    InvalidRequest(#[from] LatentCoordinateRequestError),
386    #[error("latent-coordinate objective failed during restart {restart_index}: {source}")]
387    Objective {
388        restart_index: usize,
389        #[source]
390        source: E,
391    },
392    #[error("invalid latent-coordinate objective output during restart {restart_index}: {source}")]
393    InvalidObjectiveOutput {
394        restart_index: usize,
395        #[source]
396        source: LatentCoordinateObjectiveContractError,
397    },
398    #[error("latent-coordinate geometry failed during restart {restart_index}: {source}")]
399    Geometry {
400        restart_index: usize,
401        #[source]
402        source: GeometryError,
403    },
404    #[error("{evidence}")]
405    NonConverged {
406        evidence: LatentCoordinateStationarityEvidence,
407        checkpoint: LatentCoordinateCheckpoint,
408    },
409}
410
411impl<E: StdError + 'static> LatentCoordinateOptimizationError<E> {
412    pub fn stationarity_evidence(&self) -> Option<&LatentCoordinateStationarityEvidence> {
413        match self {
414            Self::NonConverged { evidence, .. } => Some(evidence),
415            _ => None,
416        }
417    }
418
419    pub fn checkpoint(&self) -> Option<&LatentCoordinateCheckpoint> {
420        match self {
421            Self::NonConverged { checkpoint, .. } => Some(checkpoint),
422            _ => None,
423        }
424    }
425}
426
427enum ObjectiveBridgeFailure<E> {
428    Objective(E),
429    Contract(LatentCoordinateObjectiveContractError),
430}
431
432struct ObjectiveBridge<'a, O: LatentCoordinateObjective + ?Sized> {
433    objective: &'a mut O,
434    expected_dimension: usize,
435    failure: Option<ObjectiveBridgeFailure<O::Error>>,
436    objective_evaluations: usize,
437    hessian_vector_evaluations: usize,
438}
439
440impl<'a, O: LatentCoordinateObjective + ?Sized> ObjectiveBridge<'a, O> {
441    fn new(objective: &'a mut O, expected_dimension: usize) -> Self {
442        Self {
443            objective,
444            expected_dimension,
445            failure: None,
446            objective_evaluations: 0,
447            hessian_vector_evaluations: 0,
448        }
449    }
450
451    fn fail_contract<T>(
452        &mut self,
453        failure: LatentCoordinateObjectiveContractError,
454    ) -> GeometryResult<T> {
455        self.failure = Some(ObjectiveBridgeFailure::Contract(failure));
456        Err(GeometryError::InvalidPoint(
457            "latent-coordinate objective contract failed",
458        ))
459    }
460
461    fn checked_value_gradient(
462        &mut self,
463        point: ArrayView1<'_, f64>,
464    ) -> GeometryResult<LatentCoordinateEvaluation> {
465        self.objective_evaluations += 1;
466        if let Some((index, value)) = first_non_finite(point) {
467            return self.fail_contract(LatentCoordinateObjectiveContractError::NonFinitePoint {
468                index,
469                value,
470            });
471        }
472        let evaluation = match self.objective.value_and_gradient(point) {
473            Ok(evaluation) => evaluation,
474            Err(source) => {
475                self.failure = Some(ObjectiveBridgeFailure::Objective(source));
476                return Err(GeometryError::InvalidPoint(
477                    "latent-coordinate objective evaluation failed",
478                ));
479            }
480        };
481        if !evaluation.objective_value.is_finite() {
482            return self.fail_contract(LatentCoordinateObjectiveContractError::NonFiniteValue {
483                value: evaluation.objective_value,
484            });
485        }
486        if evaluation.euclidean_gradient.len() != self.expected_dimension {
487            return self.fail_contract(LatentCoordinateObjectiveContractError::GradientLength {
488                expected: self.expected_dimension,
489                actual: evaluation.euclidean_gradient.len(),
490            });
491        }
492        if let Some((index, value)) = first_non_finite(evaluation.euclidean_gradient.view()) {
493            return self.fail_contract(LatentCoordinateObjectiveContractError::NonFiniteGradient {
494                index,
495                value,
496            });
497        }
498        if !stable_euclidean_norm(evaluation.euclidean_gradient.view()).is_finite() {
499            return self
500                .fail_contract(LatentCoordinateObjectiveContractError::NonFiniteGradientNorm);
501        }
502        Ok(evaluation)
503    }
504
505    fn checked_hessian_vector_product(
506        &mut self,
507        point: ArrayView1<'_, f64>,
508        tangent: ArrayView1<'_, f64>,
509    ) -> GeometryResult<Option<Array1<f64>>> {
510        self.hessian_vector_evaluations += 1;
511        let product = match self.objective.hessian_vector_product(point, tangent) {
512            Ok(product) => product,
513            Err(source) => {
514                self.failure = Some(ObjectiveBridgeFailure::Objective(source));
515                return Err(GeometryError::InvalidPoint(
516                    "latent-coordinate objective Hessian-vector product failed",
517                ));
518            }
519        };
520        let Some(product) = product else {
521            return Ok(None);
522        };
523        if product.len() != self.expected_dimension {
524            return self.fail_contract(
525                LatentCoordinateObjectiveContractError::HessianVectorLength {
526                    expected: self.expected_dimension,
527                    actual: product.len(),
528                },
529            );
530        }
531        if let Some((index, value)) = first_non_finite(product.view()) {
532            return self.fail_contract(
533                LatentCoordinateObjectiveContractError::NonFiniteHessianVector { index, value },
534            );
535        }
536        if !stable_euclidean_norm(product.view()).is_finite() {
537            return self
538                .fail_contract(LatentCoordinateObjectiveContractError::NonFiniteHessianVectorNorm);
539        }
540        Ok(Some(product))
541    }
542}
543
544impl<O: LatentCoordinateObjective + ?Sized> RiemannianObjective for ObjectiveBridge<'_, O> {
545    fn value_gradient(&mut self, point: ArrayView1<'_, f64>) -> GeometryResult<(f64, Array1<f64>)> {
546        let evaluation = self.checked_value_gradient(point)?;
547        Ok((evaluation.objective_value, evaluation.euclidean_gradient))
548    }
549
550    fn hessian_vector_product(
551        &mut self,
552        point: ArrayView1<'_, f64>,
553        tangent: ArrayView1<'_, f64>,
554    ) -> GeometryResult<Option<Array1<f64>>> {
555        self.checked_hessian_vector_product(point, tangent)
556    }
557}
558
559struct Candidate {
560    coordinates: Array1<f64>,
561    evidence: LatentCoordinateStationarityEvidence,
562}
563
564/// Optimize a concrete analytic objective over typed latent-coordinate geometry.
565///
566/// The function returns a result only when the lowest-objective restart passes
567/// the post-hoc projected-gradient certificate. Budget exhaustion or a
568/// trust-region stall therefore yields [`LatentCoordinateOptimizationError::NonConverged`]
569/// with resume state, never a partial fit-shaped success value.
570pub fn optimize_latent_coordinates<O: LatentCoordinateObjective + ?Sized>(
571    request: LatentCoordinateOptimizationRequest,
572    objective: &mut O,
573) -> Result<LatentCoordinateOptimizationResult, LatentCoordinateOptimizationError<O::Error>> {
574    validate_request(&request)?;
575    let LatentCoordinateOptimizationRequest {
576        n_observations,
577        latent_dimension,
578        manifold: manifold_kind,
579        start,
580        options,
581    } = request;
582    let expected_dimension = n_observations * latent_dimension;
583    let (base_coordinates, resume_reference) = match start {
584        LatentCoordinateStart::Initial(coordinates) => (coordinates, None),
585        LatentCoordinateStart::Resume(checkpoint) => {
586            let reference = checkpoint.stationarity_reference;
587            (checkpoint.coordinates, Some(reference))
588        }
589    };
590    let manifold = manifold_kind
591        .build(n_observations, latent_dimension)
592        .map_err(|source| LatentCoordinateOptimizationError::Geometry {
593            restart_index: 0,
594            source,
595        })?;
596    let base_coordinates = canonicalize_and_validate_point(manifold.as_ref(), base_coordinates)
597        .map_err(|source| LatentCoordinateOptimizationError::Geometry {
598            restart_index: 0,
599            source,
600        })?;
601
602    let mut best = optimize_one_restart(
603        manifold.as_ref(),
604        objective,
605        base_coordinates.clone(),
606        0,
607        &options,
608        resume_reference,
609    )?;
610    let mut rng = rand::rngs::StdRng::seed_from_u64(options.seed);
611    for restart_index in 1..options.restart_count {
612        let noise = Array1::from_shape_fn(expected_dimension, |_| {
613            let standard_normal: f64 = StandardNormal.sample(&mut rng);
614            options.restart_scale * standard_normal
615        });
616        let tangent = manifold
617            .project_tangent(base_coordinates.view(), noise.view())
618            .map_err(|source| LatentCoordinateOptimizationError::Geometry {
619                restart_index,
620                source,
621            })?;
622        let restart_coordinates = manifold
623            .retract(base_coordinates.view(), tangent.view())
624            .map_err(|source| LatentCoordinateOptimizationError::Geometry {
625                restart_index,
626                source,
627            })?;
628        let candidate = optimize_one_restart(
629            manifold.as_ref(),
630            objective,
631            restart_coordinates,
632            restart_index,
633            &options,
634            resume_reference,
635        )?;
636        if candidate.evidence.objective_value < best.evidence.objective_value {
637            best = candidate;
638        }
639    }
640
641    if best.evidence.certifies_stationarity() {
642        return Ok(LatentCoordinateOptimizationResult {
643            coordinates: best.coordinates,
644            evidence: best.evidence,
645        });
646    }
647    let checkpoint = LatentCoordinateCheckpoint {
648        coordinates: best.coordinates,
649        n_observations,
650        latent_dimension,
651        manifold: manifold_kind,
652        stationarity_reference: best.evidence.stationarity_reference,
653        restart_index: best.evidence.restart_index,
654    };
655    Err(LatentCoordinateOptimizationError::NonConverged {
656        evidence: best.evidence,
657        checkpoint,
658    })
659}
660
661fn optimize_one_restart<O: LatentCoordinateObjective + ?Sized>(
662    manifold: &dyn RiemannianManifold,
663    objective: &mut O,
664    start: Array1<f64>,
665    restart_index: usize,
666    options: &LatentCoordinateOptimizationOptions,
667    resume_reference: Option<f64>,
668) -> Result<Candidate, LatentCoordinateOptimizationError<O::Error>> {
669    let start = canonicalize_and_validate_point(manifold, start).map_err(|source| {
670        LatentCoordinateOptimizationError::Geometry {
671            restart_index,
672            source,
673        }
674    })?;
675    let mut bridge = ObjectiveBridge::new(objective, start.len());
676    let start_evaluation_result = bridge.checked_value_gradient(start.view());
677    let start_evaluation =
678        translate_bridge_result(&mut bridge, restart_index, start_evaluation_result)?;
679    let start_gradient_norm = projected_gradient_norm(
680        manifold,
681        start.view(),
682        start_evaluation.euclidean_gradient.view(),
683    )
684    .map_err(|source| LatentCoordinateOptimizationError::Geometry {
685        restart_index,
686        source,
687    })?;
688    let stationarity_reference = resume_reference.unwrap_or(start_gradient_norm);
689    let solver_tolerance = options.stationarity_tolerance * stationarity_reference.max(1.0)
690        / start_gradient_norm.max(1.0);
691    let trust_region = RiemannianTrustRegion {
692        radius: options.initial_trust_radius,
693        max_radius: options.max_trust_radius,
694        max_iter: options.max_iterations,
695        grad_tol: solver_tolerance,
696    };
697    let optimized_result = trust_region.minimize(manifold, &mut bridge, start.view());
698    let optimized = translate_bridge_result(&mut bridge, restart_index, optimized_result)?;
699    let final_evaluation_result = bridge.checked_value_gradient(optimized.view());
700    let final_evaluation =
701        translate_bridge_result(&mut bridge, restart_index, final_evaluation_result)?;
702    let final_gradient_norm = projected_gradient_norm(
703        manifold,
704        optimized.view(),
705        final_evaluation.euclidean_gradient.view(),
706    )
707    .map_err(|source| LatentCoordinateOptimizationError::Geometry {
708        restart_index,
709        source,
710    })?;
711    let relative_gradient = relative_stationarity(final_gradient_norm, stationarity_reference);
712    let evidence = LatentCoordinateStationarityEvidence {
713        objective_value: final_evaluation.objective_value,
714        projected_gradient_norm: final_gradient_norm,
715        stationarity_reference,
716        relative_gradient,
717        tolerance: options.stationarity_tolerance,
718        coordinate_spread: coordinate_spread(optimized.view()),
719        restart_index,
720        restart_count: options.restart_count,
721        iteration_budget: options.max_iterations,
722        objective_evaluations: bridge.objective_evaluations,
723        hessian_vector_evaluations: bridge.hessian_vector_evaluations,
724    };
725    Ok(Candidate {
726        coordinates: optimized,
727        evidence,
728    })
729}
730
731fn translate_bridge_result<T, O: LatentCoordinateObjective + ?Sized>(
732    bridge: &mut ObjectiveBridge<'_, O>,
733    restart_index: usize,
734    result: GeometryResult<T>,
735) -> Result<T, LatentCoordinateOptimizationError<O::Error>> {
736    match result {
737        Ok(value) => Ok(value),
738        Err(source) => match bridge.failure.take() {
739            Some(ObjectiveBridgeFailure::Objective(source)) => {
740                Err(LatentCoordinateOptimizationError::Objective {
741                    restart_index,
742                    source,
743                })
744            }
745            Some(ObjectiveBridgeFailure::Contract(source)) => {
746                Err(LatentCoordinateOptimizationError::InvalidObjectiveOutput {
747                    restart_index,
748                    source,
749                })
750            }
751            None => Err(LatentCoordinateOptimizationError::Geometry {
752                restart_index,
753                source,
754            }),
755        },
756    }
757}
758
759fn canonicalize_and_validate_point(
760    manifold: &dyn RiemannianManifold,
761    point: Array1<f64>,
762) -> GeometryResult<Array1<f64>> {
763    let zero = Array1::<f64>::zeros(point.len());
764    let canonical = manifold.retract(point.view(), zero.view())?;
765    if first_non_finite(canonical.view()).is_some() {
766        return Err(GeometryError::InvalidPoint(
767            "latent-coordinate retraction produced a non-finite point",
768        ));
769    }
770    // For embedded manifolds this validates feasibility as well as dimension.
771    // In particular, SphereManifold rejects non-unit rows here.
772    let validation_gradient = manifold.riemannian_gradient(canonical.view(), zero.view())?;
773    if first_non_finite(validation_gradient.view()).is_some() {
774        return Err(GeometryError::InvalidPoint(
775            "latent-coordinate manifold produced a non-finite tangent vector",
776        ));
777    }
778    Ok(canonical)
779}
780
781fn projected_gradient_norm(
782    manifold: &dyn RiemannianManifold,
783    point: ArrayView1<'_, f64>,
784    euclidean_gradient: ArrayView1<'_, f64>,
785) -> GeometryResult<f64> {
786    let gradient = manifold.riemannian_gradient(point, euclidean_gradient)?;
787    let norm = stable_euclidean_norm(gradient.view());
788    if !norm.is_finite() {
789        return Err(GeometryError::InvalidPoint(
790            "latent-coordinate Riemannian gradient norm is non-finite",
791        ));
792    }
793    // Every manifold exposed by LatentCoordinateManifold carries the induced
794    // ambient identity metric, so this Euclidean norm is exactly its metric
795    // norm without allocating a dense ambient-dimension-squared tensor.
796    Ok(norm)
797}
798
799fn relative_stationarity(gradient_norm: f64, stationarity_reference: f64) -> f64 {
800    if !gradient_norm.is_finite() || !stationarity_reference.is_finite() {
801        return f64::INFINITY;
802    }
803    gradient_norm / stationarity_reference.max(1.0)
804}
805
806fn stable_euclidean_norm(values: ArrayView1<'_, f64>) -> f64 {
807    values
808        .iter()
809        .fold(0.0_f64, |norm, value| norm.hypot(*value))
810}
811
812fn first_non_finite(values: ArrayView1<'_, f64>) -> Option<(usize, f64)> {
813    values
814        .iter()
815        .copied()
816        .enumerate()
817        .find(|(_, value)| !value.is_finite())
818}
819
820fn coordinate_spread(coordinates: ArrayView1<'_, f64>) -> f64 {
821    if coordinates.is_empty() {
822        return 0.0;
823    }
824    let mut count = 0.0_f64;
825    let mut mean = 0.0_f64;
826    let mut squared_deviation = 0.0_f64;
827    for value in coordinates {
828        count += 1.0;
829        let delta = value - mean;
830        mean += delta / count;
831        squared_deviation += delta * (value - mean);
832    }
833    (squared_deviation / count).max(0.0).sqrt()
834}
835
836/// Enforce that `latent_dimension` is compatible with the manifold's intrinsic
837/// geometry: a circle carries exactly one ambient angle, a sphere `S^(k-1)`
838/// needs an ambient dimension `k >= 2`. Euclidean and toroidal latents accept
839/// any positive dimension.
840///
841/// This is the single source of the manifold–dimension contract, shared by the
842/// full-request path ([`validate_request`]), the checkpoint path
843/// ([`validate_dimensions`]), and the domain descriptor
844/// ([`LatentCoordinateManifold::axis_domains`]).
845fn validate_manifold_dimension(
846    manifold: LatentCoordinateManifold,
847    latent_dimension: usize,
848) -> Result<(), LatentCoordinateRequestError> {
849    if latent_dimension == 0 {
850        return Err(LatentCoordinateRequestError::EmptyLatentDimension);
851    }
852    match manifold {
853        LatentCoordinateManifold::Circle if latent_dimension != 1 => {
854            Err(LatentCoordinateRequestError::CircleDimension { latent_dimension })
855        }
856        LatentCoordinateManifold::Sphere if latent_dimension < 2 => {
857            Err(LatentCoordinateRequestError::SphereDimension { latent_dimension })
858        }
859        _ => Ok(()),
860    }
861}
862
863/// Validate the `(n_observations, latent_dimension, manifold)` triple and return
864/// the expected flattened coordinate length `n_observations * latent_dimension`.
865///
866/// Rejects empty extents, overflowing products, and manifold-incompatible
867/// dimensions with the same errors [`validate_request`] uses, so a checkpoint
868/// built in isolation carries an identical structural contract.
869fn validate_dimensions(
870    n_observations: usize,
871    latent_dimension: usize,
872    manifold: LatentCoordinateManifold,
873) -> Result<usize, LatentCoordinateRequestError> {
874    if n_observations == 0 {
875        return Err(LatentCoordinateRequestError::EmptyObservations);
876    }
877    if latent_dimension == 0 {
878        return Err(LatentCoordinateRequestError::EmptyLatentDimension);
879    }
880    let expected = n_observations.checked_mul(latent_dimension).ok_or(
881        LatentCoordinateRequestError::DimensionOverflow {
882            n_observations,
883            latent_dimension,
884        },
885    )?;
886    validate_manifold_dimension(manifold, latent_dimension)?;
887    Ok(expected)
888}
889
890fn validate_request(
891    request: &LatentCoordinateOptimizationRequest,
892) -> Result<(), LatentCoordinateRequestError> {
893    let expected =
894        validate_dimensions(request.n_observations, request.latent_dimension, request.manifold)?;
895    let options = &request.options;
896    if !(options.stationarity_tolerance.is_finite()
897        && options.stationarity_tolerance > 0.0
898        && options.stationarity_tolerance <= 1.0)
899    {
900        return Err(LatentCoordinateRequestError::InvalidStationarityTolerance {
901            value: options.stationarity_tolerance,
902        });
903    }
904    if !(options.initial_trust_radius.is_finite() && options.initial_trust_radius > 0.0) {
905        return Err(LatentCoordinateRequestError::InvalidInitialTrustRadius {
906            value: options.initial_trust_radius,
907        });
908    }
909    if !(options.max_trust_radius.is_finite()
910        && options.max_trust_radius >= options.initial_trust_radius)
911    {
912        return Err(LatentCoordinateRequestError::InvalidMaximumTrustRadius {
913            initial: options.initial_trust_radius,
914            maximum: options.max_trust_radius,
915        });
916    }
917    if options.restart_count == 0 {
918        return Err(LatentCoordinateRequestError::EmptyRestarts);
919    }
920    if !(options.restart_scale.is_finite() && options.restart_scale > 0.0) {
921        return Err(LatentCoordinateRequestError::InvalidRestartScale {
922            value: options.restart_scale,
923        });
924    }
925    match &request.start {
926        LatentCoordinateStart::Initial(coordinates) => {
927            validate_coordinates("initial", coordinates.view(), expected)?;
928        }
929        LatentCoordinateStart::Resume(checkpoint) => {
930            if checkpoint.n_observations != request.n_observations
931                || checkpoint.latent_dimension != request.latent_dimension
932            {
933                return Err(LatentCoordinateRequestError::CheckpointShapeMismatch {
934                    checkpoint_observations: checkpoint.n_observations,
935                    checkpoint_dimension: checkpoint.latent_dimension,
936                    request_observations: request.n_observations,
937                    request_dimension: request.latent_dimension,
938                });
939            }
940            if checkpoint.manifold != request.manifold {
941                return Err(LatentCoordinateRequestError::CheckpointManifoldMismatch {
942                    checkpoint: checkpoint.manifold,
943                    request: request.manifold,
944                });
945            }
946            if !(checkpoint.stationarity_reference.is_finite()
947                && checkpoint.stationarity_reference >= 0.0)
948            {
949                return Err(LatentCoordinateRequestError::InvalidCheckpointReference {
950                    value: checkpoint.stationarity_reference,
951                });
952            }
953            validate_coordinates("checkpoint", checkpoint.coordinates.view(), expected)?;
954        }
955    }
956    Ok(())
957}
958
959fn validate_coordinates(
960    origin: &'static str,
961    coordinates: ArrayView1<'_, f64>,
962    expected: usize,
963) -> Result<(), LatentCoordinateRequestError> {
964    if coordinates.len() != expected {
965        return Err(LatentCoordinateRequestError::CoordinateLength {
966            origin,
967            expected,
968            actual: coordinates.len(),
969        });
970    }
971    if let Some((index, value)) = first_non_finite(coordinates) {
972        return Err(LatentCoordinateRequestError::NonFiniteCoordinate {
973            origin,
974            index,
975            value,
976        });
977    }
978    Ok(())
979}
980
981#[cfg(test)]
982mod tests {
983    use ndarray::{Array1, ArrayView1, array};
984    use thiserror::Error;
985
986    use super::*;
987
988    #[derive(Debug, Error, PartialEq)]
989    enum TestObjectiveError {
990        #[error("inner REML solve failed")]
991        InnerSolve,
992    }
993
994    struct QuadraticObjective {
995        target: Array1<f64>,
996    }
997
998    impl LatentCoordinateObjective for QuadraticObjective {
999        type Error = TestObjectiveError;
1000
1001        fn value_and_gradient(
1002            &mut self,
1003            coordinates: ArrayView1<'_, f64>,
1004        ) -> Result<LatentCoordinateEvaluation, Self::Error> {
1005            let gradient = &coordinates - &self.target;
1006            let objective_value = 0.5 * gradient.dot(&gradient);
1007            Ok(LatentCoordinateEvaluation {
1008                objective_value,
1009                euclidean_gradient: gradient,
1010            })
1011        }
1012
1013        fn hessian_vector_product(
1014            &mut self,
1015            coordinates: ArrayView1<'_, f64>,
1016            tangent: ArrayView1<'_, f64>,
1017        ) -> Result<Option<Array1<f64>>, Self::Error> {
1018            assert_eq!(coordinates.len(), tangent.len());
1019            Ok(Some(tangent.to_owned()))
1020        }
1021    }
1022
1023    fn options(max_iterations: usize, restart_count: usize) -> LatentCoordinateOptimizationOptions {
1024        LatentCoordinateOptimizationOptions {
1025            max_iterations,
1026            stationarity_tolerance: 1.0e-10,
1027            initial_trust_radius: 10.0,
1028            max_trust_radius: 10.0,
1029            restart_count,
1030            restart_scale: 0.2,
1031            seed: 42,
1032        }
1033    }
1034
1035    fn euclidean_request(
1036        coordinates: Array1<f64>,
1037        options: LatentCoordinateOptimizationOptions,
1038    ) -> LatentCoordinateOptimizationRequest {
1039        LatentCoordinateOptimizationRequest {
1040            n_observations: coordinates.len(),
1041            latent_dimension: 1,
1042            manifold: LatentCoordinateManifold::Euclidean,
1043            start: LatentCoordinateStart::Initial(coordinates),
1044            options,
1045        }
1046    }
1047
1048    #[test]
1049    fn quadratic_returns_only_a_certified_result() {
1050        let request = euclidean_request(array![5.0, -3.0], options(2, 1));
1051        let mut objective = QuadraticObjective {
1052            target: array![1.0, 2.0],
1053        };
1054        let result = optimize_latent_coordinates(request, &mut objective).unwrap();
1055        assert!(result.evidence.certifies_stationarity());
1056        assert!(result.evidence.relative_gradient <= 1.0e-10);
1057        assert!(
1058            (&result.coordinates - &array![1.0, 2.0])
1059                .iter()
1060                .all(|difference| difference.abs() <= 1.0e-12)
1061        );
1062    }
1063
1064    #[test]
1065    fn exhausted_run_returns_evidence_and_a_serializable_checkpoint() {
1066        let request = euclidean_request(array![5.0], options(0, 1));
1067        let mut objective = QuadraticObjective {
1068            target: array![0.0],
1069        };
1070        let error = optimize_latent_coordinates(request, &mut objective).unwrap_err();
1071        let LatentCoordinateOptimizationError::NonConverged {
1072            evidence,
1073            checkpoint,
1074        } = error
1075        else {
1076            panic!("expected typed non-convergence");
1077        };
1078        assert!(!evidence.certifies_stationarity());
1079        assert_eq!(evidence.stationarity_reference, 5.0);
1080        assert_eq!(checkpoint.coordinates(), array![5.0].view());
1081        let encoded = serde_json::to_string(&checkpoint).unwrap();
1082        let decoded: LatentCoordinateCheckpoint = serde_json::from_str(&encoded).unwrap();
1083        assert_eq!(decoded, checkpoint);
1084    }
1085
1086    #[test]
1087    fn resume_preserves_the_original_stationarity_reference() {
1088        let first_request = euclidean_request(array![5.0], options(0, 1));
1089        let mut first_objective = QuadraticObjective {
1090            target: array![0.0],
1091        };
1092        let first_error =
1093            optimize_latent_coordinates(first_request, &mut first_objective).unwrap_err();
1094        let checkpoint = first_error.checkpoint().unwrap().clone();
1095        let resumed_request = LatentCoordinateOptimizationRequest {
1096            n_observations: 1,
1097            latent_dimension: 1,
1098            manifold: LatentCoordinateManifold::Euclidean,
1099            start: LatentCoordinateStart::Resume(checkpoint),
1100            options: options(2, 1),
1101        };
1102        let mut resumed_objective = QuadraticObjective {
1103            target: array![0.0],
1104        };
1105        let result = optimize_latent_coordinates(resumed_request, &mut resumed_objective).unwrap();
1106        assert_eq!(result.evidence.stationarity_reference, 5.0);
1107        assert!(result.evidence.certifies_stationarity());
1108    }
1109
1110    struct FailingObjective;
1111
1112    impl LatentCoordinateObjective for FailingObjective {
1113        type Error = TestObjectiveError;
1114
1115        fn value_and_gradient(
1116            &mut self,
1117            coordinates: ArrayView1<'_, f64>,
1118        ) -> Result<LatentCoordinateEvaluation, Self::Error> {
1119            assert!(coordinates.iter().all(|value| value.is_finite()));
1120            Err(TestObjectiveError::InnerSolve)
1121        }
1122    }
1123
1124    #[test]
1125    fn fatal_objective_failure_is_preserved_exactly() {
1126        let request = euclidean_request(array![1.0], options(4, 1));
1127        let error = optimize_latent_coordinates(request, &mut FailingObjective).unwrap_err();
1128        match error {
1129            LatentCoordinateOptimizationError::Objective {
1130                restart_index,
1131                source,
1132            } => {
1133                assert_eq!(restart_index, 0);
1134                assert_eq!(source, TestObjectiveError::InnerSolve);
1135            }
1136            other => panic!("expected objective error, got {other:?}"),
1137        }
1138    }
1139
1140    struct NonFiniteObjective;
1141
1142    impl LatentCoordinateObjective for NonFiniteObjective {
1143        type Error = TestObjectiveError;
1144
1145        fn value_and_gradient(
1146            &mut self,
1147            coordinates: ArrayView1<'_, f64>,
1148        ) -> Result<LatentCoordinateEvaluation, Self::Error> {
1149            Ok(LatentCoordinateEvaluation {
1150                objective_value: f64::INFINITY,
1151                euclidean_gradient: Array1::zeros(coordinates.len()),
1152            })
1153        }
1154    }
1155
1156    #[test]
1157    fn non_finite_objective_is_not_false_stationarity() {
1158        let request = euclidean_request(array![1.0], options(4, 1));
1159        let error = optimize_latent_coordinates(request, &mut NonFiniteObjective).unwrap_err();
1160        assert!(matches!(
1161            error,
1162            LatentCoordinateOptimizationError::InvalidObjectiveOutput {
1163                source: LatentCoordinateObjectiveContractError::NonFiniteValue { .. },
1164                ..
1165            }
1166        ));
1167    }
1168
1169    struct RecordingStationaryObjective {
1170        points: Vec<Array1<f64>>,
1171    }
1172
1173    impl LatentCoordinateObjective for RecordingStationaryObjective {
1174        type Error = TestObjectiveError;
1175
1176        fn value_and_gradient(
1177            &mut self,
1178            coordinates: ArrayView1<'_, f64>,
1179        ) -> Result<LatentCoordinateEvaluation, Self::Error> {
1180            self.points.push(coordinates.to_owned());
1181            Ok(LatentCoordinateEvaluation {
1182                objective_value: 0.0,
1183                euclidean_gradient: Array1::zeros(coordinates.len()),
1184            })
1185        }
1186    }
1187
1188    #[test]
1189    fn sphere_restarts_remain_on_the_product_manifold() {
1190        let request = LatentCoordinateOptimizationRequest {
1191            n_observations: 2,
1192            latent_dimension: 3,
1193            manifold: LatentCoordinateManifold::Sphere,
1194            start: LatentCoordinateStart::Initial(array![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]),
1195            options: options(0, 3),
1196        };
1197        let mut objective = RecordingStationaryObjective { points: Vec::new() };
1198        let result = optimize_latent_coordinates(request, &mut objective).unwrap();
1199        assert!(result.evidence.certifies_stationarity());
1200        assert!(!objective.points.is_empty());
1201        for point in &objective.points {
1202            for row in point.as_slice().unwrap().chunks_exact(3) {
1203                let norm = row.iter().fold(0.0_f64, |acc, value| acc.hypot(*value));
1204                assert!((norm - 1.0).abs() <= 1.0e-10);
1205            }
1206        }
1207    }
1208}