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(
534 format!(
535 "reactive domain entry failed at the objective-owned scalar entry: {}",
536 failure.message()
537 ),
538 ));
539 }
540 self.refine_step();
541 let refined_next = (self.s - self.s_step).max(0.0);
542 if !(refined_next < self.s) {
543 return Err(gam_problem::EstimationError::RemlOptimizationFailed(
544 format!(
545 "reactive domain continuation cannot form a smaller representable \
546 waypoint below s={:.17e} after coupled-waypoint non-convergence: {}",
547 self.s,
548 failure.message()
549 ),
550 ));
551 }
552 ContinuationStep::Refined {
553 s: self.s,
554 reason: RefinementReason::WaypointStruggled(failure),
555 }
556 }
557 })
558 }
559}
560
561#[must_use]
568fn couple_schedules(
569 rho_entry: Array1<f64>,
570 rho_target: Array1<f64>,
571 scalars: ContinuationScalarContract,
572) -> CoupledSchedules {
573 CoupledSchedules {
574 rho_entry,
575 rho_target,
576 scalars,
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn scalar_contract() -> ContinuationScalarContract {
585 ContinuationScalarContract::new(
586 ContinuationScalarState::new(2.0, vec![0.01, 0.02]).expect("valid entry"),
587 ContinuationScalarState::new(0.1, vec![1.0, 2.0]).expect("valid target"),
588 )
589 .expect("matching scalar dimensions")
590 }
591
592 fn schedules() -> CoupledSchedules {
593 couple_schedules(
594 Array1::from_vec(vec![5.0, 5.0]),
595 Array1::from_vec(vec![0.0, 0.0]),
596 scalar_contract(),
597 )
598 }
599
600 #[test]
601 fn literal_endpoint_bits_survive_without_affine_rounding() {
602 let scalar_entry =
603 ContinuationScalarState::new(2.0, vec![-0.0, 0.01]).expect("valid entry");
604 let scalar_target =
605 ContinuationScalarState::new(0.1, vec![1.0, 2.0]).expect("valid target");
606 let contract = ContinuationScalarContract::new(scalar_entry.clone(), scalar_target.clone())
607 .expect("ordered scalar endpoints");
608 assert!(contract.at(1.0).bitwise_eq(&scalar_entry));
609 assert!(contract.at(0.0).bitwise_eq(&scalar_target));
610
611 let rho_entry = Array1::from_vec(vec![0.01, 0.02]);
612 let rho_target = Array1::from_vec(vec![-0.0, -0.1]);
613 let schedules = couple_schedules(rho_entry.clone(), rho_target.clone(), contract);
614 assert!(
615 schedules
616 .rho_target_at(1.0)
617 .iter()
618 .zip(rho_entry.iter())
619 .all(|(actual, literal)| actual.to_bits() == literal.to_bits())
620 );
621 assert!(
622 schedules
623 .rho_target_at(0.0)
624 .iter()
625 .zip(rho_target.iter())
626 .all(|(actual, literal)| actual.to_bits() == literal.to_bits())
627 );
628 }
629
630 #[test]
631 fn entry_is_the_heavy_smoothing_regime() {
632 let path = ContinuationPath::enter(schedules());
633 assert_eq!(
634 path.s(),
635 1.0,
636 "entry must be s = 1 (heavy-smoothing regime)"
637 );
638 let targets = path.current_scalar_targets();
639 assert!(targets.bitwise_eq(scalar_contract().entry()));
640 let rho = path.schedules.rho_target_at(path.s());
642 assert!(rho.iter().all(|entry| entry.to_bits() == 5.0_f64.to_bits()));
643 }
644
645 #[test]
646 fn target_endpoint_is_the_real_objective() {
647 let sch = schedules();
648 let targets0 = sch.scalar_targets_at(0.0);
649 assert!(targets0.bitwise_eq(scalar_contract().target()));
650 let rho0 = sch.rho_target_at(0.0);
651 assert!(
652 (rho0[0]).abs() < 1e-12 && (rho0[1]).abs() < 1e-12,
653 "s=0 ρ = ρ*"
654 );
655 }
656
657 #[test]
658 fn legs_move_in_lockstep_along_s() {
659 let sch = schedules();
660 let mid = sch.scalar_targets_at(0.5);
663 assert!((mid.assignment_temperature - (0.1 + 0.5 * (2.0 - 0.1))).abs() < 1e-12);
664 assert!((mid.isometry_weights[0] - (1.0 + 0.5 * (0.01 - 1.0))).abs() < 1e-12);
665 assert!((mid.isometry_weights[1] - (2.0 + 0.5 * (0.02 - 2.0))).abs() < 1e-12);
666 let rho_mid = sch.rho_target_at(0.5);
667 assert!((rho_mid[0] - 2.5).abs() < 1e-12);
668 }
669
670 #[test]
671 fn heavy_entry_starts_in_the_heavy_regime() {
672 let path = ContinuationPath::heavy_entry_for_rho(
673 Array1::zeros(1),
674 Array1::from_elem(1, 10.0),
675 scalar_contract(),
676 )
677 .expect("finite ordered rho endpoints");
678 assert_eq!(path.s(), 1.0, "heavy_entry must enter at s = 1");
679 }
680
681 #[test]
682 fn heavy_entry_refuses_nonfinite_or_reversed_rho_endpoints() {
683 let nonfinite = ContinuationPath::heavy_entry_for_rho(
684 Array1::zeros(1),
685 Array1::from_vec(vec![f64::INFINITY]),
686 scalar_contract(),
687 )
688 .expect_err("non-finite legal entry must be typed refusal");
689 assert!(nonfinite.to_string().contains("must be finite"));
690
691 let reversed = ContinuationPath::heavy_entry_for_rho(
692 Array1::from_vec(vec![2.0]),
693 Array1::from_vec(vec![1.0]),
694 scalar_contract(),
695 )
696 .expect_err("entry below target must be typed refusal");
697 assert!(reversed.to_string().contains("entry >= target"));
698 }
699
700 #[derive(Default)]
701 struct RecordingObjective {
702 orders: Vec<OuterEvalOrder>,
703 rho_evaluated: Vec<Array1<f64>>,
704 seed_count: usize,
705 installed: Vec<ContinuationScalarState>,
706 installed_current: Option<ContinuationScalarState>,
707 full_state_marker: usize,
708 checkpoint_full_state_marker: Option<usize>,
709 checkpoint_installed_current: Option<Option<ContinuationScalarState>>,
710 fail_literal_target_once: bool,
711 trial_entry_markers: Vec<usize>,
712 }
713
714 impl OuterObjective for RecordingObjective {
715 fn capability(&self) -> crate::rho_optimizer::OuterCapability {
716 crate::rho_optimizer::OuterCapability {
717 gradient: gam_problem::Derivative::Analytic,
718 hessian: crate::rho_optimizer::DeclaredHessianForm::Unavailable,
719 n_params: 2,
720 psi_dim: 0,
721 fixed_point_available: false,
722 barrier_config: None,
723 prefer_gradient_only: false,
724 disable_fixed_point: false,
725 }
726 }
727
728 fn eval_cost(
729 &mut self,
730 rho: &Array1<f64>,
731 ) -> Result<f64, crate::model_types::EstimationError> {
732 Ok(rho.iter().map(|v| v * v).sum())
733 }
734
735 fn eval(
736 &mut self,
737 rho: &Array1<f64>,
738 ) -> Result<gam_problem::OuterEval, crate::model_types::EstimationError> {
739 Ok(gam_problem::OuterEval {
740 cost: self.eval_cost(rho)?,
741 gradient: Array1::zeros(rho.len()),
742 hessian: gam_problem::HessianValue::Unavailable,
743 inner_beta_hint: Some(Array1::from_vec(vec![1.0, self.seed_count as f64])),
744 })
745 }
746
747 fn eval_with_order(
748 &mut self,
749 rho: &Array1<f64>,
750 order: OuterEvalOrder,
751 ) -> Result<gam_problem::OuterEval, crate::model_types::EstimationError> {
752 self.orders.push(order);
753 self.rho_evaluated.push(rho.clone());
754 self.trial_entry_markers.push(self.full_state_marker);
755 if self.fail_literal_target_once
756 && self
757 .installed_current
758 .as_ref()
759 .is_some_and(|state| state.bitwise_eq(scalar_contract().target()))
760 {
761 self.fail_literal_target_once = false;
762 self.full_state_marker = usize::MAX;
763 return Ok(gam_problem::OuterEval::infeasible(rho.len()));
764 }
765 let mut eval = self.eval(rho)?;
766 self.full_state_marker += 1;
767 if matches!(order, OuterEvalOrder::Value) {
768 eval.gradient = Array1::zeros(0);
769 eval.hessian = gam_problem::HessianValue::Unavailable;
770 }
771 Ok(eval)
772 }
773
774 fn reset(&mut self) {}
775
776 fn seed_inner_state(
777 &mut self,
778 beta: &Array1<f64>,
779 ) -> Result<crate::rho_optimizer::SeedOutcome, crate::model_types::EstimationError>
780 {
781 self.seed_count += beta.len().max(1);
782 Ok(crate::rho_optimizer::SeedOutcome::Installed)
783 }
784
785 fn reactive_domain_scalar_contract(
786 &self,
787 ) -> Result<Option<ContinuationScalarContract>, crate::model_types::EstimationError>
788 {
789 Ok(Some(scalar_contract()))
790 }
791
792 fn install_reactive_domain_scalar_state(
793 &mut self,
794 state: &ContinuationScalarState,
795 ) -> Result<(), crate::model_types::EstimationError> {
796 self.installed.push(state.clone());
797 self.installed_current = Some(state.clone());
798 Ok(())
799 }
800
801 fn begin_reactive_domain_waypoint(
802 &mut self,
803 ) -> Result<(), crate::model_types::EstimationError> {
804 assert!(self.checkpoint_full_state_marker.is_none());
805 self.checkpoint_full_state_marker = Some(self.full_state_marker);
806 self.checkpoint_installed_current = Some(self.installed_current.clone());
807 Ok(())
808 }
809
810 fn commit_reactive_domain_waypoint(
811 &mut self,
812 _: &Array1<f64>,
813 ) -> Result<(), crate::model_types::EstimationError> {
814 self.checkpoint_full_state_marker
815 .take()
816 .expect("active waypoint checkpoint");
817 self.checkpoint_installed_current
818 .take()
819 .expect("active scalar checkpoint");
820 Ok(())
821 }
822
823 fn rollback_reactive_domain_waypoint(
824 &mut self,
825 ) -> Result<(), crate::model_types::EstimationError> {
826 self.full_state_marker = self
827 .checkpoint_full_state_marker
828 .take()
829 .expect("active waypoint checkpoint");
830 self.installed_current = self
831 .checkpoint_installed_current
832 .take()
833 .expect("active scalar checkpoint");
834 Ok(())
835 }
836 }
837
838 #[test]
839 fn coupled_path_waypoints_request_value_only_evals() {
840 let mut path = ContinuationPath::enter(schedules());
841 let mut obj = RecordingObjective::default();
842 let initial_beta = Array1::zeros(0);
843
844 let step = path
845 .step(&mut obj, &initial_beta)
846 .expect("the heavy entry must solve");
847 assert!(
848 matches!(step, ContinuationStep::Entered { .. }),
849 "the first coupled-path call must solve the literal entry waypoint"
850 );
851 assert!(
852 obj.installed
853 .first()
854 .expect("entry installation")
855 .bitwise_eq(scalar_contract().entry())
856 );
857 assert_eq!(
858 obj.rho_evaluated.first(),
859 Some(&Array1::from_vec(vec![5.0, 5.0]))
860 );
861 assert!(
862 !obj.orders.is_empty(),
863 "the coupled path should evaluate at least one rho waypoint"
864 );
865 assert!(
866 obj.orders
867 .iter()
868 .all(|order| matches!(order, OuterEvalOrder::Value)),
869 "reactive domain-entry waypoints must not request outer gradients: {:?}",
870 obj.orders
871 );
872 }
873
874 #[test]
875 fn refinement_halves_distance_without_moving_the_accepted_waypoint() {
876 let mut path = ContinuationPath::enter(schedules());
877 path.s = 0.5;
878 path.s_step = 0.25;
879 path.refine_step();
880 assert_eq!(path.s.to_bits(), 0.5_f64.to_bits());
881 assert_eq!(path.s_step.to_bits(), 0.125_f64.to_bits());
882 }
883
884 #[test]
885 fn arrival_is_a_successful_literal_target_solve() {
886 let mut path = ContinuationPath::enter(schedules());
887 let mut obj = RecordingObjective::default();
888 let initial_beta = Array1::zeros(0);
889 assert!(matches!(
890 path.step(&mut obj, &initial_beta).expect("entry solve"),
891 ContinuationStep::Entered { .. }
892 ));
893 let arrived = path
894 .step(&mut obj, &initial_beta)
895 .expect("literal target solve");
896 let state = match arrived {
897 ContinuationStep::Arrived { state } => state,
898 other => panic!("expected exact-target arrival, got {other:?}"),
899 };
900 assert_eq!(path.s().to_bits(), 0.0_f64.to_bits());
901 assert!(state.last_eval.cost.is_finite());
902 assert!(
903 obj.installed
904 .last()
905 .expect("target installation")
906 .bitwise_eq(scalar_contract().target())
907 );
908 assert_eq!(obj.rho_evaluated.last(), Some(&Array1::zeros(2)));
909 }
910
911 #[test]
912 fn failed_waypoint_rolls_back_full_state_before_refined_retry() {
913 let mut path = ContinuationPath::enter(schedules());
914 let mut obj = RecordingObjective {
915 full_state_marker: 7,
916 fail_literal_target_once: true,
917 ..Default::default()
918 };
919 let initial_beta = Array1::zeros(0);
920
921 assert!(matches!(
922 path.step(&mut obj, &initial_beta).expect("entry solve"),
923 ContinuationStep::Entered { .. }
924 ));
925 assert_eq!(obj.full_state_marker, 8, "entry state must commit");
926
927 assert!(matches!(
928 path.step(&mut obj, &initial_beta)
929 .expect("non-finite target must refine"),
930 ContinuationStep::Refined {
931 reason: RefinementReason::WaypointStruggled(_),
932 ..
933 }
934 ));
935 assert_eq!(path.s().to_bits(), 1.0_f64.to_bits());
936 assert_eq!(
937 obj.full_state_marker, 8,
938 "failed trial mutation must roll back the complete accepted state"
939 );
940 assert!(
941 obj.installed_current
942 .as_ref()
943 .expect("restored accepted scalar")
944 .bitwise_eq(scalar_contract().entry()),
945 "rollback must restore the accepted scalar state too"
946 );
947
948 assert!(matches!(
949 path.step(&mut obj, &initial_beta).expect("refined midpoint"),
950 ContinuationStep::Descended { s, .. } if s.to_bits() == 0.5_f64.to_bits()
951 ));
952 assert_eq!(
953 obj.trial_entry_markers.last(),
954 Some(&8),
955 "refined retry must start from the restored accepted state"
956 );
957 assert_eq!(obj.full_state_marker, 9, "refined midpoint must commit");
958 }
959
960 #[test]
961 fn unrepresentable_descent_is_typed_without_evaluating_again() {
962 let mut path = ContinuationPath::enter(schedules());
963 let mut obj = RecordingObjective::default();
964 let initial_beta = Array1::zeros(0);
965 path.step(&mut obj, &initial_beta).expect("entry solve");
966 let evals_after_entry = obj.rho_evaluated.len();
967 let installs_after_entry = obj.installed.len();
968 path.s_step = f64::MIN_POSITIVE;
969
970 let error = path
971 .step(&mut obj, &initial_beta)
972 .expect_err("rounded-away descent must be a typed refusal");
973 assert!(error.to_string().contains("smaller representable waypoint"));
974 assert_eq!(obj.rho_evaluated.len(), evals_after_entry);
975 assert_eq!(obj.installed.len(), installs_after_entry);
976 }
977}