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 pub fn into_coordinates(self) -> Array1<f64> {
196 self.coordinates
197 }
198}
199
200#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct LatentCoordinateOptimizationResult {
245 pub coordinates: Array1<f64>,
246 pub evidence: LatentCoordinateStationarityEvidence,
247}
248
249#[derive(Debug, Clone, PartialEq)]
251pub struct LatentCoordinateEvaluation {
252 pub objective_value: f64,
253 pub euclidean_gradient: Array1<f64>,
254}
255
256pub 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 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#[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#[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#[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#[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
564pub 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 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 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
836fn 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
863fn 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}