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                    // #2080 — name the waypoint. This leg is always `s = 1`, and
534                    // `rho_target_at(1.0)` is bitwise the legal upper box (pinned
535                    // by `literal_endpoint_bits_survive_without_affine_rounding`),
536                    // so the refusal is a property of the fixture rather than of
537                    // whatever seed asked for the walk. Reporting `s` and the
538                    // evaluated rho saves the next reader deriving that from
539                    // source, which is how it had to be established the first
540                    // time: two seeds refusing to seven identical digits.
541                    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/// Build a coupled path from the rho box and the typed scalar contract supplied
571/// by the objective.
572///
573/// `rho_target` is ρ\* (the real objective); `rho_entry` is the objective's
574/// legal upper-box endpoint. Each is evaluated at the same `s` as the scalar
575/// contract.
576#[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        // Log-ρ at s = 1 is the supplied legal upper-box endpoint.
650        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        // Halfway down the path, every leg is halfway (in its natural coord)
670        // between entry and target.
671        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            // The trait documents the committed waypoint as the full inner
824            // state produced by the value evaluation AT `rho`, so a commit must
825            // name the rho this objective was last evaluated at.
826            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}