1use ndarray::Array1;
45
46use crate::estimate::reml::continuation::{ContinuationState, eval_step};
47use crate::inner_status::InnerFailure;
48use crate::rho_optimizer::{OuterEvalOrder, OuterObjective};
49
50#[derive(Debug, Clone, Copy)]
56struct LegEndpoints {
57 at_entry: f64,
59 at_target: f64,
61}
62
63impl LegEndpoints {
64 #[must_use]
66 fn new(at_entry: f64, at_target: f64) -> Self {
67 Self {
68 at_entry,
69 at_target,
70 }
71 }
72
73 #[must_use]
78 fn at(&self, s: f64) -> f64 {
79 let s = s.clamp(0.0, 1.0);
80 if s.to_bits() == 0.0_f64.to_bits() {
86 return self.at_target;
87 }
88 if s.to_bits() == 1.0_f64.to_bits() {
89 return self.at_entry;
90 }
91 self.at_target + s * (self.at_entry - self.at_target)
92 }
93}
94
95#[derive(Debug, Clone, PartialEq)]
102pub struct ContinuationScalarState {
103 pub assignment_temperature: f64,
104 pub isometry_weights: Vec<f64>,
105}
106
107impl ContinuationScalarState {
108 pub fn new(assignment_temperature: f64, isometry_weights: Vec<f64>) -> Result<Self, String> {
109 if !(assignment_temperature.is_finite() && assignment_temperature > 0.0) {
110 return Err(format!(
111 "continuation assignment temperature must be finite and positive; got \
112 {assignment_temperature}"
113 ));
114 }
115 if let Some((index, weight)) = isometry_weights
116 .iter()
117 .copied()
118 .enumerate()
119 .find(|(_, weight)| !(weight.is_finite() && *weight >= 0.0))
120 {
121 return Err(format!(
122 "continuation isometry weight[{index}] must be finite and non-negative; got \
123 {weight}"
124 ));
125 }
126 Ok(Self {
127 assignment_temperature,
128 isometry_weights,
129 })
130 }
131
132 #[must_use]
133 pub fn bitwise_eq(&self, other: &Self) -> bool {
134 self.assignment_temperature.to_bits() == other.assignment_temperature.to_bits()
135 && self.isometry_weights.len() == other.isometry_weights.len()
136 && self
137 .isometry_weights
138 .iter()
139 .zip(&other.isometry_weights)
140 .all(|(left, right)| left.to_bits() == right.to_bits())
141 }
142}
143
144#[derive(Debug, Clone, PartialEq)]
148pub struct ContinuationScalarContract {
149 entry: ContinuationScalarState,
150 target: ContinuationScalarState,
151}
152
153impl ContinuationScalarContract {
154 pub fn new(
155 entry: ContinuationScalarState,
156 target: ContinuationScalarState,
157 ) -> Result<Self, String> {
158 if entry.isometry_weights.len() != target.isometry_weights.len() {
159 return Err(format!(
160 "continuation entry/target isometry dimensions differ: {} != {}",
161 entry.isometry_weights.len(),
162 target.isometry_weights.len()
163 ));
164 }
165 if entry.assignment_temperature < target.assignment_temperature {
166 return Err(format!(
167 "continuation entry temperature {} is sharper than literal target {}",
168 entry.assignment_temperature, target.assignment_temperature
169 ));
170 }
171 if let Some((index, (&entry_weight, &target_weight))) = entry
172 .isometry_weights
173 .iter()
174 .zip(&target.isometry_weights)
175 .enumerate()
176 .find(|(_, (entry_weight, target_weight))| entry_weight > target_weight)
177 {
178 return Err(format!(
179 "continuation entry isometry weight[{index}]={entry_weight} exceeds literal target {target_weight}"
180 ));
181 }
182 Ok(Self { entry, target })
183 }
184
185 #[must_use]
186 pub fn entry(&self) -> &ContinuationScalarState {
187 &self.entry
188 }
189
190 #[must_use]
191 pub fn target(&self) -> &ContinuationScalarState {
192 &self.target
193 }
194
195 #[must_use]
196 pub fn at(&self, s: f64) -> ContinuationScalarState {
197 let s = s.clamp(0.0, 1.0);
198 let temperature = LegEndpoints::new(
199 self.entry.assignment_temperature,
200 self.target.assignment_temperature,
201 )
202 .at(s);
203 let isometry_weights = self
204 .entry
205 .isometry_weights
206 .iter()
207 .zip(&self.target.isometry_weights)
208 .map(|(&entry, &target)| LegEndpoints::new(entry, target).at(s))
209 .collect();
210 ContinuationScalarState {
211 assignment_temperature: temperature,
212 isometry_weights,
213 }
214 }
215}
216
217#[derive(Debug, Clone)]
220struct CoupledSchedules {
221 rho_entry: Array1<f64>,
224 rho_target: Array1<f64>,
226 scalars: ContinuationScalarContract,
228}
229
230impl CoupledSchedules {
231 #[must_use]
234 fn scalar_targets_at(&self, s: f64) -> ContinuationScalarState {
235 self.scalars.at(s)
236 }
237
238 #[must_use]
241 fn rho_target_at(&self, s: f64) -> Array1<f64> {
242 assert_eq!(
243 self.rho_entry.len(),
244 self.rho_target.len(),
245 "ContinuationPath: ρ entry/target dimension mismatch"
246 );
247 let s = s.clamp(0.0, 1.0);
248 if s.to_bits() == 0.0_f64.to_bits() {
252 return self.rho_target.clone();
253 }
254 if s.to_bits() == 1.0_f64.to_bits() {
255 return self.rho_entry.clone();
256 }
257 let mut out = self.rho_target.clone();
258 for i in 0..out.len() {
259 out[i] = self.rho_target[i] + s * (self.rho_entry[i] - self.rho_target[i]);
260 }
261 out
262 }
263}
264
265#[derive(Debug, Clone)]
275pub(crate) enum ContinuationStep {
276 Entered { state: ContinuationState },
279 Descended { s: f64, state: ContinuationState },
282 Arrived { state: ContinuationState },
286 Refined { s: f64, reason: RefinementReason },
290}
291
292#[derive(Debug, Clone)]
295pub(crate) enum RefinementReason {
296 WaypointStruggled(InnerFailure),
300}
301
302#[derive(Debug, Clone)]
314pub struct ContinuationPath {
315 schedules: CoupledSchedules,
316 s: f64,
319 s_step: f64,
324 warm: Option<ContinuationState>,
328}
329
330impl ContinuationPath {
331 #[must_use]
334 fn enter(schedules: CoupledSchedules) -> Self {
335 Self {
336 schedules,
337 s: 1.0,
338 s_step: 1.0,
339 warm: None,
340 }
341 }
342
343 pub fn heavy_entry_for_rho(
348 rho_target: Array1<f64>,
349 bounds_upper: Array1<f64>,
350 scalars: ContinuationScalarContract,
351 ) -> Result<Self, gam_problem::EstimationError> {
352 if rho_target.len() != bounds_upper.len() {
353 return Err(gam_problem::EstimationError::RemlOptimizationFailed(
354 format!(
355 "reactive continuation rho target/bounds dimensions differ: {} != {}",
356 rho_target.len(),
357 bounds_upper.len()
358 ),
359 ));
360 }
361 for (index, (&target, &entry)) in rho_target.iter().zip(bounds_upper.iter()).enumerate() {
362 if !(target.is_finite() && entry.is_finite() && entry >= target) {
363 return Err(gam_problem::EstimationError::RemlOptimizationFailed(
364 format!(
365 "reactive continuation rho endpoint[{index}] must be finite with legal \
366 upper entry >= target; entry={entry}, target={target}"
367 ),
368 ));
369 }
370 }
371 let schedules = couple_schedules(bounds_upper.clone(), rho_target, scalars);
375 Ok(Self::enter(schedules))
376 }
377
378 #[must_use]
380 pub fn s(&self) -> f64 {
381 self.s
382 }
383
384 #[must_use]
387 pub fn current_scalar_targets(&self) -> ContinuationScalarState {
388 self.schedules.scalar_targets_at(self.s)
389 }
390
391 fn refine_step(&mut self) {
395 self.s_step *= 0.5;
396 }
397
398 pub(crate) fn step(
413 &mut self,
414 obj: &mut dyn OuterObjective,
415 initial_beta: &Array1<f64>,
416 ) -> Result<ContinuationStep, gam_problem::EstimationError> {
417 let entering = self.warm.is_none();
422 let s_next = if entering {
423 1.0
424 } else {
425 (self.s - self.s_step).max(0.0)
426 };
427 if !entering && !(s_next < self.s) {
428 return Err(gam_problem::EstimationError::RemlOptimizationFailed(
429 format!(
430 "reactive domain continuation cannot form a smaller representable waypoint \
431 below accepted s={:.17e}",
432 self.s
433 ),
434 ));
435 }
436
437 obj.begin_reactive_domain_waypoint()?;
441
442 let scalar_state = self.schedules.scalar_targets_at(s_next);
446 if let Err(install_error) = obj.install_reactive_domain_scalar_state(&scalar_state) {
447 return match obj.rollback_reactive_domain_waypoint() {
448 Ok(()) => Err(install_error),
449 Err(rollback_error) => Err(gam_problem::EstimationError::RemlOptimizationFailed(
450 format!(
451 "reactive coupled waypoint installation failed ({install_error}); \
452 full-state rollback also failed ({rollback_error})"
453 ),
454 )),
455 };
456 }
457
458 let rho_waypoint = self.schedules.rho_target_at(s_next);
463 let (beta_seed, accepted_steps) = match self.warm.as_ref() {
464 Some(start) => (
465 start
466 .last_eval
467 .inner_beta_hint
468 .clone()
469 .unwrap_or_else(|| start.last_beta.clone()),
470 start.steps_accepted,
471 ),
472 None => (initial_beta.clone(), 0),
473 };
474 let evaluation = eval_step(
475 obj,
476 &rho_waypoint,
477 &beta_seed,
478 OuterEvalOrder::Value,
479 )
480 .and_then(|eval| {
481 if eval.cost.is_finite() {
482 Ok(eval)
483 } else {
484 Err(InnerFailure::LikelihoodFailure(format!(
485 "reactive coupled waypoint at s={s_next:.17e} returned non-finite evidence {}",
486 eval.cost
487 )))
488 }
489 });
490
491 Ok(match evaluation {
492 Ok(eval) => {
493 if let Err(commit_error) = obj.commit_reactive_domain_waypoint(&rho_waypoint) {
494 return match obj.rollback_reactive_domain_waypoint() {
495 Ok(()) => Err(commit_error),
496 Err(rollback_error) => Err(
497 gam_problem::EstimationError::RemlOptimizationFailed(format!(
498 "reactive coupled waypoint commit failed ({commit_error}); \
499 full-state rollback also failed ({rollback_error})"
500 )),
501 ),
502 };
503 }
504 let state = ContinuationState {
505 last_rho: rho_waypoint,
506 last_eval: eval,
507 last_beta: beta_seed,
508 steps_accepted: accepted_steps + 1,
509 };
510 self.warm = Some(state.clone());
511 self.s = s_next;
512 self.s_step = (self.s_step * 2.0).min(self.s);
515 if entering {
516 ContinuationStep::Entered { state }
517 } else if self.s <= 0.0 {
518 ContinuationStep::Arrived { state }
519 } else {
520 ContinuationStep::Descended { s: self.s, state }
521 }
522 }
523 Err(failure) => {
524 obj.rollback_reactive_domain_waypoint()
525 .map_err(|rollback_error| {
526 gam_problem::EstimationError::RemlOptimizationFailed(format!(
527 "reactive coupled waypoint failed ({}), and full-state rollback failed \
528 ({rollback_error})",
529 failure.message()
530 ))
531 })?;
532 if entering {
533 return Err(gam_problem::EstimationError::RemlOptimizationFailed(
542 format!(
543 "reactive domain entry failed at the objective-owned scalar entry \
544 (seed-independent waypoint s={s_next}, rho={rho_waypoint:?}): {}",
545 failure.message()
546 ),
547 ));
548 }
549 self.refine_step();
550 let refined_next = (self.s - self.s_step).max(0.0);
551 if !(refined_next < self.s) {
552 return Err(gam_problem::EstimationError::RemlOptimizationFailed(
553 format!(
554 "reactive domain continuation cannot form a smaller representable \
555 waypoint below s={:.17e} after coupled-waypoint non-convergence: {}",
556 self.s,
557 failure.message()
558 ),
559 ));
560 }
561 ContinuationStep::Refined {
562 s: self.s,
563 reason: RefinementReason::WaypointStruggled(failure),
564 }
565 }
566 })
567 }
568}
569
570#[must_use]
577fn couple_schedules(
578 rho_entry: Array1<f64>,
579 rho_target: Array1<f64>,
580 scalars: ContinuationScalarContract,
581) -> CoupledSchedules {
582 CoupledSchedules {
583 rho_entry,
584 rho_target,
585 scalars,
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 fn scalar_contract() -> ContinuationScalarContract {
594 ContinuationScalarContract::new(
595 ContinuationScalarState::new(2.0, vec![0.01, 0.02]).expect("valid entry"),
596 ContinuationScalarState::new(0.1, vec![1.0, 2.0]).expect("valid target"),
597 )
598 .expect("matching scalar dimensions")
599 }
600
601 fn schedules() -> CoupledSchedules {
602 couple_schedules(
603 Array1::from_vec(vec![5.0, 5.0]),
604 Array1::from_vec(vec![0.0, 0.0]),
605 scalar_contract(),
606 )
607 }
608
609 #[test]
610 fn literal_endpoint_bits_survive_without_affine_rounding() {
611 let scalar_entry =
612 ContinuationScalarState::new(2.0, vec![-0.0, 0.01]).expect("valid entry");
613 let scalar_target =
614 ContinuationScalarState::new(0.1, vec![1.0, 2.0]).expect("valid target");
615 let contract = ContinuationScalarContract::new(scalar_entry.clone(), scalar_target.clone())
616 .expect("ordered scalar endpoints");
617 assert!(contract.at(1.0).bitwise_eq(&scalar_entry));
618 assert!(contract.at(0.0).bitwise_eq(&scalar_target));
619
620 let rho_entry = Array1::from_vec(vec![0.01, 0.02]);
621 let rho_target = Array1::from_vec(vec![-0.0, -0.1]);
622 let schedules = couple_schedules(rho_entry.clone(), rho_target.clone(), contract);
623 assert!(
624 schedules
625 .rho_target_at(1.0)
626 .iter()
627 .zip(rho_entry.iter())
628 .all(|(actual, literal)| actual.to_bits() == literal.to_bits())
629 );
630 assert!(
631 schedules
632 .rho_target_at(0.0)
633 .iter()
634 .zip(rho_target.iter())
635 .all(|(actual, literal)| actual.to_bits() == literal.to_bits())
636 );
637 }
638
639 #[test]
640 fn entry_is_the_heavy_smoothing_regime() {
641 let path = ContinuationPath::enter(schedules());
642 assert_eq!(
643 path.s(),
644 1.0,
645 "entry must be s = 1 (heavy-smoothing regime)"
646 );
647 let targets = path.current_scalar_targets();
648 assert!(targets.bitwise_eq(scalar_contract().entry()));
649 let rho = path.schedules.rho_target_at(path.s());
651 assert!(rho.iter().all(|entry| entry.to_bits() == 5.0_f64.to_bits()));
652 }
653
654 #[test]
655 fn target_endpoint_is_the_real_objective() {
656 let sch = schedules();
657 let targets0 = sch.scalar_targets_at(0.0);
658 assert!(targets0.bitwise_eq(scalar_contract().target()));
659 let rho0 = sch.rho_target_at(0.0);
660 assert!(
661 (rho0[0]).abs() < 1e-12 && (rho0[1]).abs() < 1e-12,
662 "s=0 ρ = ρ*"
663 );
664 }
665
666 #[test]
667 fn legs_move_in_lockstep_along_s() {
668 let sch = schedules();
669 let mid = sch.scalar_targets_at(0.5);
672 assert!((mid.assignment_temperature - (0.1 + 0.5 * (2.0 - 0.1))).abs() < 1e-12);
673 assert!((mid.isometry_weights[0] - (1.0 + 0.5 * (0.01 - 1.0))).abs() < 1e-12);
674 assert!((mid.isometry_weights[1] - (2.0 + 0.5 * (0.02 - 2.0))).abs() < 1e-12);
675 let rho_mid = sch.rho_target_at(0.5);
676 assert!((rho_mid[0] - 2.5).abs() < 1e-12);
677 }
678
679 #[test]
680 fn heavy_entry_starts_in_the_heavy_regime() {
681 let path = ContinuationPath::heavy_entry_for_rho(
682 Array1::zeros(1),
683 Array1::from_elem(1, 10.0),
684 scalar_contract(),
685 )
686 .expect("finite ordered rho endpoints");
687 assert_eq!(path.s(), 1.0, "heavy_entry must enter at s = 1");
688 }
689
690 #[test]
691 fn heavy_entry_refuses_nonfinite_or_reversed_rho_endpoints() {
692 let nonfinite = ContinuationPath::heavy_entry_for_rho(
693 Array1::zeros(1),
694 Array1::from_vec(vec![f64::INFINITY]),
695 scalar_contract(),
696 )
697 .expect_err("non-finite legal entry must be typed refusal");
698 assert!(nonfinite.to_string().contains("must be finite"));
699
700 let reversed = ContinuationPath::heavy_entry_for_rho(
701 Array1::from_vec(vec![2.0]),
702 Array1::from_vec(vec![1.0]),
703 scalar_contract(),
704 )
705 .expect_err("entry below target must be typed refusal");
706 assert!(reversed.to_string().contains("entry >= target"));
707 }
708
709 #[derive(Default)]
710 struct RecordingObjective {
711 orders: Vec<OuterEvalOrder>,
712 rho_evaluated: Vec<Array1<f64>>,
713 seed_count: usize,
714 installed: Vec<ContinuationScalarState>,
715 installed_current: Option<ContinuationScalarState>,
716 full_state_marker: usize,
717 checkpoint_full_state_marker: Option<usize>,
718 checkpoint_installed_current: Option<Option<ContinuationScalarState>>,
719 fail_literal_target_once: bool,
720 trial_entry_markers: Vec<usize>,
721 }
722
723 impl OuterObjective for RecordingObjective {
724 fn capability(&self) -> crate::rho_optimizer::OuterCapability {
725 crate::rho_optimizer::OuterCapability {
726 gradient: gam_problem::Derivative::Analytic,
727 hessian: crate::rho_optimizer::DeclaredHessianForm::Unavailable,
728 n_params: 2,
729 psi_dim: 0,
730 fixed_point_available: false,
731 barrier_config: None,
732 prefer_gradient_only: false,
733 disable_fixed_point: false,
734 }
735 }
736
737 fn eval_cost(
738 &mut self,
739 rho: &Array1<f64>,
740 ) -> Result<f64, crate::model_types::EstimationError> {
741 Ok(rho.iter().map(|v| v * v).sum())
742 }
743
744 fn eval(
745 &mut self,
746 rho: &Array1<f64>,
747 ) -> Result<gam_problem::OuterEval, crate::model_types::EstimationError> {
748 Ok(gam_problem::OuterEval {
749 cost: self.eval_cost(rho)?,
750 gradient: Array1::zeros(rho.len()),
751 hessian: gam_problem::HessianValue::Unavailable,
752 inner_beta_hint: Some(Array1::from_vec(vec![1.0, self.seed_count as f64])),
753 })
754 }
755
756 fn eval_with_order(
757 &mut self,
758 rho: &Array1<f64>,
759 order: OuterEvalOrder,
760 ) -> Result<gam_problem::OuterEval, crate::model_types::EstimationError> {
761 self.orders.push(order);
762 self.rho_evaluated.push(rho.clone());
763 self.trial_entry_markers.push(self.full_state_marker);
764 if self.fail_literal_target_once
765 && self
766 .installed_current
767 .as_ref()
768 .is_some_and(|state| state.bitwise_eq(scalar_contract().target()))
769 {
770 self.fail_literal_target_once = false;
771 self.full_state_marker = usize::MAX;
772 return Ok(gam_problem::OuterEval::infeasible(rho.len()));
773 }
774 let mut eval = self.eval(rho)?;
775 self.full_state_marker += 1;
776 if matches!(order, OuterEvalOrder::Value) {
777 eval.gradient = Array1::zeros(0);
778 eval.hessian = gam_problem::HessianValue::Unavailable;
779 }
780 Ok(eval)
781 }
782
783 fn reset(&mut self) {}
784
785 fn seed_inner_state(
786 &mut self,
787 beta: &Array1<f64>,
788 ) -> Result<crate::rho_optimizer::SeedOutcome, crate::model_types::EstimationError>
789 {
790 self.seed_count += beta.len().max(1);
791 Ok(crate::rho_optimizer::SeedOutcome::Installed)
792 }
793
794 fn reactive_domain_scalar_contract(
795 &self,
796 ) -> Result<Option<ContinuationScalarContract>, crate::model_types::EstimationError>
797 {
798 Ok(Some(scalar_contract()))
799 }
800
801 fn install_reactive_domain_scalar_state(
802 &mut self,
803 state: &ContinuationScalarState,
804 ) -> Result<(), crate::model_types::EstimationError> {
805 self.installed.push(state.clone());
806 self.installed_current = Some(state.clone());
807 Ok(())
808 }
809
810 fn begin_reactive_domain_waypoint(
811 &mut self,
812 ) -> Result<(), crate::model_types::EstimationError> {
813 assert!(self.checkpoint_full_state_marker.is_none());
814 self.checkpoint_full_state_marker = Some(self.full_state_marker);
815 self.checkpoint_installed_current = Some(self.installed_current.clone());
816 Ok(())
817 }
818
819 fn commit_reactive_domain_waypoint(
820 &mut self,
821 rho: &Array1<f64>,
822 ) -> Result<(), crate::model_types::EstimationError> {
823 assert_eq!(
827 self.rho_evaluated.last(),
828 Some(rho),
829 "commit_reactive_domain_waypoint must name the last evaluated rho"
830 );
831 self.checkpoint_full_state_marker
832 .take()
833 .expect("active waypoint checkpoint");
834 self.checkpoint_installed_current
835 .take()
836 .expect("active scalar checkpoint");
837 Ok(())
838 }
839
840 fn rollback_reactive_domain_waypoint(
841 &mut self,
842 ) -> Result<(), crate::model_types::EstimationError> {
843 self.full_state_marker = self
844 .checkpoint_full_state_marker
845 .take()
846 .expect("active waypoint checkpoint");
847 self.installed_current = self
848 .checkpoint_installed_current
849 .take()
850 .expect("active scalar checkpoint");
851 Ok(())
852 }
853 }
854
855 #[test]
856 fn coupled_path_waypoints_request_value_only_evals() {
857 let mut path = ContinuationPath::enter(schedules());
858 let mut obj = RecordingObjective::default();
859 let initial_beta = Array1::zeros(0);
860
861 let step = path
862 .step(&mut obj, &initial_beta)
863 .expect("the heavy entry must solve");
864 assert!(
865 matches!(step, ContinuationStep::Entered { .. }),
866 "the first coupled-path call must solve the literal entry waypoint"
867 );
868 assert!(
869 obj.installed
870 .first()
871 .expect("entry installation")
872 .bitwise_eq(scalar_contract().entry())
873 );
874 assert_eq!(
875 obj.rho_evaluated.first(),
876 Some(&Array1::from_vec(vec![5.0, 5.0]))
877 );
878 assert!(
879 !obj.orders.is_empty(),
880 "the coupled path should evaluate at least one rho waypoint"
881 );
882 assert!(
883 obj.orders
884 .iter()
885 .all(|order| matches!(order, OuterEvalOrder::Value)),
886 "reactive domain-entry waypoints must not request outer gradients: {:?}",
887 obj.orders
888 );
889 }
890
891 #[test]
892 fn refinement_halves_distance_without_moving_the_accepted_waypoint() {
893 let mut path = ContinuationPath::enter(schedules());
894 path.s = 0.5;
895 path.s_step = 0.25;
896 path.refine_step();
897 assert_eq!(path.s.to_bits(), 0.5_f64.to_bits());
898 assert_eq!(path.s_step.to_bits(), 0.125_f64.to_bits());
899 }
900
901 #[test]
902 fn arrival_is_a_successful_literal_target_solve() {
903 let mut path = ContinuationPath::enter(schedules());
904 let mut obj = RecordingObjective::default();
905 let initial_beta = Array1::zeros(0);
906 assert!(matches!(
907 path.step(&mut obj, &initial_beta).expect("entry solve"),
908 ContinuationStep::Entered { .. }
909 ));
910 let arrived = path
911 .step(&mut obj, &initial_beta)
912 .expect("literal target solve");
913 let state = match arrived {
914 ContinuationStep::Arrived { state } => state,
915 other => panic!("expected exact-target arrival, got {other:?}"),
916 };
917 assert_eq!(path.s().to_bits(), 0.0_f64.to_bits());
918 assert!(state.last_eval.cost.is_finite());
919 assert!(
920 obj.installed
921 .last()
922 .expect("target installation")
923 .bitwise_eq(scalar_contract().target())
924 );
925 assert_eq!(obj.rho_evaluated.last(), Some(&Array1::zeros(2)));
926 }
927
928 #[test]
929 fn failed_waypoint_rolls_back_full_state_before_refined_retry() {
930 let mut path = ContinuationPath::enter(schedules());
931 let mut obj = RecordingObjective {
932 full_state_marker: 7,
933 fail_literal_target_once: true,
934 ..Default::default()
935 };
936 let initial_beta = Array1::zeros(0);
937
938 assert!(matches!(
939 path.step(&mut obj, &initial_beta).expect("entry solve"),
940 ContinuationStep::Entered { .. }
941 ));
942 assert_eq!(obj.full_state_marker, 8, "entry state must commit");
943
944 assert!(matches!(
945 path.step(&mut obj, &initial_beta)
946 .expect("non-finite target must refine"),
947 ContinuationStep::Refined {
948 reason: RefinementReason::WaypointStruggled(_),
949 ..
950 }
951 ));
952 assert_eq!(path.s().to_bits(), 1.0_f64.to_bits());
953 assert_eq!(
954 obj.full_state_marker, 8,
955 "failed trial mutation must roll back the complete accepted state"
956 );
957 assert!(
958 obj.installed_current
959 .as_ref()
960 .expect("restored accepted scalar")
961 .bitwise_eq(scalar_contract().entry()),
962 "rollback must restore the accepted scalar state too"
963 );
964
965 assert!(matches!(
966 path.step(&mut obj, &initial_beta).expect("refined midpoint"),
967 ContinuationStep::Descended { s, .. } if s.to_bits() == 0.5_f64.to_bits()
968 ));
969 assert_eq!(
970 obj.trial_entry_markers.last(),
971 Some(&8),
972 "refined retry must start from the restored accepted state"
973 );
974 assert_eq!(obj.full_state_marker, 9, "refined midpoint must commit");
975 }
976
977 #[test]
978 fn unrepresentable_descent_is_typed_without_evaluating_again() {
979 let mut path = ContinuationPath::enter(schedules());
980 let mut obj = RecordingObjective::default();
981 let initial_beta = Array1::zeros(0);
982 path.step(&mut obj, &initial_beta).expect("entry solve");
983 let evals_after_entry = obj.rho_evaluated.len();
984 let installs_after_entry = obj.installed.len();
985 path.s_step = f64::MIN_POSITIVE;
986
987 let error = path
988 .step(&mut obj, &initial_beta)
989 .expect_err("rounded-away descent must be a typed refusal");
990 assert!(error.to_string().contains("smaller representable waypoint"));
991 assert_eq!(obj.rho_evaluated.len(), evals_after_entry);
992 assert_eq!(obj.installed.len(), installs_after_entry);
993 }
994}