1use 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#[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#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct LatentCoordinateOptimizationOptions {
91 pub max_iterations: usize,
93 pub stationarity_tolerance: f64,
95 pub initial_trust_radius: f64,
97 pub max_trust_radius: f64,
99 pub restart_count: usize,
101 pub restart_scale: f64,
103 pub seed: u64,
105}
106
107#[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#[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#[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 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#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct LatentCoordinateOptimizationResult {
242 pub coordinates: Array1<f64>,
243 pub evidence: LatentCoordinateStationarityEvidence,
244}
245
246#[derive(Debug, Clone, PartialEq)]
248pub struct LatentCoordinateEvaluation {
249 pub objective_value: f64,
250 pub euclidean_gradient: Array1<f64>,
251}
252
253pub 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 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#[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#[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#[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#[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
561pub 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 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 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 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
848fn 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
875fn 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 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}