Skip to main content

gam_solve/
continuation_path.rs

1//! Reactive three-leg continuation for a non-finite K≥2 SAE outer seed.
2//!
3//! # Three endpoint legs coupled by one scalar parameter
4//!
5//! One objective evaluation installs and solves all three homotopy legs at the
6//! same `s`:
7//!
8//! 1. **log-ρ** — the objective's legal upper-box endpoint down to its literal
9//!    target. At large penalty strength the penalized Hessian dominates the
10//!    likelihood Hessian.
11//! 2. **Assignment temperature τ** — diffuse softmax / IBP relaxation (high τ)
12//!    sharpened toward the objective's literal target. High τ makes the
13//!    assignment map smooth and far from the combinatorial argmax cliff.
14//! 3. **Isometry weights** — zero entry weights ramped to the objective's
15//!    literal per-penalty vector. A loose gauge leaves the decoder free to find
16//!    a good fit before the gauge pins it.
17//!
18//! [`ContinuationPath`] advances all three **in lockstep** along a single
19//! scalar path parameter `s ∈ [1 → 0]`. `s = 1` is the *entry regime*: legal
20//! upper-box ρ, high τ, and loose isometry. `s = 0` is the real objective: target ρ\*,
21//! sharp τ, tight isometry. The path walks `s` monotonically down, advancing
22//! the three literal waypoint values together.
23//!
24//! # Entry is always the heavy-smoothing regime
25//!
26//! There is no "solve cold at the real objective" entry. The only entry is
27//! `s = 1`, where every leg is at its smoothing extreme. A K≥2 SAE joint fit
28//! either reaches the literal target through accepted coupled waypoints or
29//! returns a typed refusal; it never fabricates arrival.
30//!
31//! # The accepted path is monotone; failed attempts refine their distance
32//!
33//! If a downward step's inner solve struggles, the last accepted waypoint is
34//! retained and only the next attempted distance is halved. No independent
35//! waypoint count, wall-clock deadline, or evaluation ceiling can fabricate an
36//! arrival. A successful solve at the literal `s = 0` target is the only
37//! [`ContinuationStep::Arrived`] value. If repeated refinement can no longer
38//! produce a strictly smaller representable waypoint, [`ContinuationPath::step`]
39//! returns a typed non-convergence error.
40//!
41//! Full objective checkpoints, rather than coefficient-only fallback state,
42//! make each accepted waypoint independent of mutations from refused trials.
43
44use ndarray::Array1;
45
46use crate::estimate::reml::continuation::{ContinuationState, eval_step};
47use crate::inner_status::InnerFailure;
48use crate::rho_optimizer::{OuterEvalOrder, OuterObjective};
49
50/// The endpoints of one coupled annealing leg, in path-parameter terms.
51/// `at_entry` is the value at `s = 1` (heavy-smoothing regime); `at_target`
52/// is the value at `s = 0` (real objective). Interpolation is in the leg's
53/// coordinate: ρ is already stored as log-precision, while temperature and
54/// isometry weights are literal objective scalars.
55#[derive(Debug, Clone, Copy)]
56struct LegEndpoints {
57    /// Value at `s = 1`: the smoothing-extreme entry regime.
58    at_entry: f64,
59    /// Value at `s = 0`: the real-objective target.
60    at_target: f64,
61}
62
63impl LegEndpoints {
64    /// Construct from an entry value and a target value.
65    #[must_use]
66    fn new(at_entry: f64, at_target: f64) -> Self {
67        Self {
68            at_entry,
69            at_target,
70        }
71    }
72
73    /// Linear interpolation in the leg's natural coordinate at path parameter
74    /// `s ∈ [0, 1]`: `s = 1 → at_entry`, `s = 0 → at_target`. The caller passes
75    /// values in the coordinate the objective consumes, so a convex blend is
76    /// also the literal waypoint value installed or evaluated.
77    #[must_use]
78    fn at(&self, s: f64) -> f64 {
79        let s = s.clamp(0.0, 1.0);
80        // Endpoint waypoints are literal objective state, not merely values
81        // numerically close to it. The affine expression below can lose an
82        // endpoint bit through cancellation (for example, target +
83        // (entry - target) need not be bitwise-identical to entry), which
84        // breaks transactional restoration of the exact accepted waypoint.
85        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/// Literal scalar state of an objective at one coupled-domain waypoint.
96///
97/// The assignment temperature is a single global scalar. Isometry weights are
98/// retained one-per-registered penalty: collapsing them into one synthetic
99/// number would lose the objective's actual target state when several
100/// isometry penalties carry different weights.
101#[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/// Objective-owned scalar endpoints for reactive domain entry. The target is
145/// the literal state of the real objective; the entry is a smoother state
146/// derived by that objective from its own routing and penalty geometry.
147#[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/// Endpoint state that [`ContinuationPath`] owns. It computes every leg from
218/// the same `s`; there are no independently advancing schedule objects.
219#[derive(Debug, Clone)]
220struct CoupledSchedules {
221    /// Log-ρ endpoints, **per-component** (one entry per outer coordinate).
222    /// Entry is the objective's legal upper box and target is literal ρ\*.
223    rho_entry: Array1<f64>,
224    /// ρ\* — the real-objective smoothing vector at `s = 0`.
225    rho_target: Array1<f64>,
226    /// Typed scalar entry/target state supplied by the objective itself.
227    scalars: ContinuationScalarContract,
228}
229
230impl CoupledSchedules {
231    /// The coupled lockstep target value of every scalar leg at path parameter
232    /// `s`. ρ is a vector and is returned by [`Self::rho_target_at`].
233    #[must_use]
234    fn scalar_targets_at(&self, s: f64) -> ContinuationScalarState {
235        self.scalars.at(s)
236    }
237
238    /// Exact log-ρ waypoint at path parameter `s`: a convex blend per
239    /// component from the legal upper-box entry to literal ρ\*.
240    #[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        // Preserve the literal box and target vectors at the two contract
249        // endpoints. Besides making the scalar and rho legs symmetric, this
250        // prevents affine-rounding from changing a bound bit at entry.
251        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// ─────────────────────────────────────────────────────────────────────────
266//  Typed outcomes for accepted progress and attempted-distance refinement.
267// ─────────────────────────────────────────────────────────────────────────
268
269/// Outcome of one [`ContinuationPath`] waypoint step. The defining structural
270/// property: **there is no false-arrival arm.** A successful step enters,
271/// descends, arrives with its solved target state, or reports that the next
272/// attempted distance was refined. Non-convergence is returned by
273/// [`ContinuationPath::step`] as a typed error rather than encoded as arrival.
274#[derive(Debug, Clone)]
275pub(crate) enum ContinuationStep {
276    /// The objective installed and solved the literal heavy scalar entry at
277    /// `s = 1`. Carries the accepted waypoint state that warms the first descent.
278    Entered { state: ContinuationState },
279    /// `s` was lowered toward `0` and the inner solve at the new waypoint
280    /// succeeded. Carries the accepted waypoint state and the new `s`.
281    Descended { s: f64, state: ContinuationState },
282    /// `s` reached `0`: the path arrived at the real objective (ρ\*, τ_min,
283    /// tight isometry). Terminal-but-successful; the criterion is the real
284    /// objective's, identical for cold and warm entry (#969).
285    Arrived { state: ContinuationState },
286    /// The attempted waypoint did not solve, so the accepted `s` remains
287    /// unchanged and the next attempted distance is smaller. Carries the last
288    /// accepted `s` and the evidence that requested refinement.
289    Refined { s: f64, reason: RefinementReason },
290}
291
292/// Why the next waypoint distance was refined. Purely diagnostic: the last
293/// accepted waypoint remains installed and no progress is fabricated.
294#[derive(Debug, Clone)]
295pub(crate) enum RefinementReason {
296    /// The exact coupled waypoint evaluation did not converge. The underlying
297    /// failure is kept for logging; the path retries from the last accepted
298    /// state with a smaller attempted distance.
299    WaypointStruggled(InnerFailure),
300}
301
302// ─────────────────────────────────────────────────────────────────────────
303//  The ContinuationPath object.
304// ─────────────────────────────────────────────────────────────────────────
305
306/// Coupled continuation path. Owns the three endpoint legs and the scalar path
307/// parameter `s`, and drives the K≥2 SAE joint fit down the coupled
308/// homotopy. Entry is always the legal heavy endpoint at `s = 1`, and
309/// only a solved literal target can produce arrival.
310///
311/// [`ContinuationPath::step`] transactionally installs and evaluates one exact
312/// waypoint at a time. The path itself owns the accepted warm trajectory.
313#[derive(Debug, Clone)]
314pub struct ContinuationPath {
315    schedules: CoupledSchedules,
316    /// Last successfully solved path parameter. Starts at `1.0` (entry regime)
317    /// and walks monotonically toward `0.0`.
318    s: f64,
319    /// Current descent step in `s`. A failed attempted waypoint halves it; a
320    /// successful waypoint doubles it up to the remaining distance. There is
321    /// no numeric floor: inability to form a strictly smaller representable
322    /// waypoint is a typed non-convergence result.
323    s_step: f64,
324    /// The most recent converged waypoint state. `None` until the first leg
325    /// converges; every later waypoint starts from this accepted full objective
326    /// state and beta hint. A failed attempted waypoint never overwrites it.
327    warm: Option<ContinuationState>,
328}
329
330impl ContinuationPath {
331    /// Build at the legal heavy endpoint `s = 1`. Reactive continuation cannot
332    /// begin cold at the literal target; failure to solve this entry is typed.
333    #[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    /// Build from a concrete log-ρ target and the objective's legal upper box.
344    /// The upper box is the heavy entry endpoint; the scalar endpoints come
345    /// from the typed objective contract. Every endpoint must be finite, and
346    /// each upper entry must be at least its target.
347    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        // The legal upper box is the literal heavy-penalty endpoint. This uses
372        // objective-owned geometry rather than a private log-offset heuristic,
373        // and makes rho move under the same `s` as temperature and isometry.
374        let schedules = couple_schedules(bounds_upper.clone(), rho_target, scalars);
375        Ok(Self::enter(schedules))
376    }
377
378    /// Current path parameter `s ∈ [0, 1]`.
379    #[must_use]
380    pub fn s(&self) -> f64 {
381        self.s
382    }
383
384    /// The scalar leg targets (τ, isometry weight) at the current `s`. The
385    /// wiring agent installs these before the inner solve at this waypoint.
386    #[must_use]
387    pub fn current_scalar_targets(&self) -> ContinuationScalarState {
388        self.schedules.scalar_targets_at(self.s)
389    }
390
391    /// Refine the next descent after a failed attempted waypoint. `s` remains
392    /// the last successfully solved waypoint; only the attempted distance is
393    /// halved, so the accepted path is monotone and never fabricates progress.
394    fn refine_step(&mut self) {
395        self.s_step *= 0.5;
396    }
397
398    /// Take one waypoint step down the coupled homotopy.
399    ///
400    /// 1. Lower `s` by the current step toward `0`.
401    /// 2. Install the exact scalar state and evaluate the exact log-ρ waypoint,
402    ///    with the last accepted inner β carried warm.
403    /// 3. On evaluation success: [`ContinuationStep::Descended`] (or
404    ///    [`ContinuationStep::Arrived`] if `s` reached `0`).
405    /// 4. On error or non-finite evidence: retain the last successful waypoint and halve the
406    ///    attempted distance. If no strictly smaller representable waypoint
407    ///    remains, return a typed non-convergence error.
408    ///
409    /// `obj` is the SAE joint outer objective (`SaeManifoldOuterObjective`,
410    /// which is an [`OuterObjective`]). `initial_beta` warms the inner solve;
411    /// pass the empty array for a cold entry.
412    pub(crate) fn step(
413        &mut self,
414        obj: &mut dyn OuterObjective,
415        initial_beta: &Array1<f64>,
416    ) -> Result<ContinuationStep, gam_problem::EstimationError> {
417        // The cold leg solves the literal heavy entry at s=1 before any
418        // descent. Every later leg lowers s by one path step. This makes the
419        // heavy entry an evaluated waypoint rather than an unevaluated
420        // endpoint that the old implementation skipped on its first call.
421        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        // Snapshot the complete accepted objective state before installing a
438        // trial. A coefficient hint alone cannot restore latent coordinates,
439        // routing logits, or decoder frames after a failed inner solve.
440        obj.begin_reactive_domain_waypoint()?;
441
442        // Install the objective-owned scalar state before evaluating rho. This
443        // is the actual coupling seam: mutating private schedule copies would
444        // leave the evaluated objective unchanged.
445        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        // One path attempt is exactly one objective evaluation at the shared
459        // `(rho(s), scalar(s))` waypoint. There is no nested rho scheduler: its
460        // step floors, retry counts, and private clock would decouple rho from
461        // the scalar legs. The last accepted beta is the only warm payload.
462        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                // Successful progress permits a naturally larger next step,
513                // bounded only by the distance remaining to the literal target.
514                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/// Build a coupled path from the rho box and the typed scalar contract supplied
562/// by the objective.
563///
564/// `rho_target` is ρ\* (the real objective); `rho_entry` is the objective's
565/// legal upper-box endpoint. Each is evaluated at the same `s` as the scalar
566/// contract.
567#[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        // Log-ρ at s = 1 is the supplied legal upper-box endpoint.
641        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        // Halfway down the path, every leg is halfway (in its natural coord)
661        // between entry and target.
662        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}