Skip to main content

gam_solve/
continuation_path.rs

1//! Object 1 — the `ContinuationPath`: one object that couples the three
2//! annealing schedules that today live separately and uncoupled, so a K≥2 SAE
3//! joint fit always arrives via a regime where the inner problem is a
4//! contraction — never solved cold.
5//!
6//! # The three schedules, coupled along one scalar path parameter `s`
7//!
8//! Three homotopy legs presently advance on their own clocks:
9//!
10//! 1. **ρ-anneal** — heavy oversmoothing penalty ρ₀ ≫ ρ\* down to the target
11//!    ρ\*. Owned by the spine
12//!    [`crate::estimate::reml::continuation::fit_with_continuation`]
13//!    (the callable ρ-anneal primitive, promoted from a private warm-start
14//!    helper). At large ρ the penalized Hessian dominates the likelihood
15//!    Hessian, so the inner P-IRLS / arrow-Schur solve is strongly convex —
16//!    a contraction.
17//! 2. **Assignment temperature τ** — diffuse softmax / IBP relaxation (high τ)
18//!    sharpened toward the near-discrete MAP active set (low τ). Owned by
19//!    `gam_sae::manifold::GumbelTemperatureSchedule`. High τ makes
20//!    the assignment map smooth and far from the combinatorial argmax cliff.
21//! 3. **Isometry weight** — loose analytic isometry gauge (small w) ramped to
22//!    the tight target weight. Owned by
23//!    [`gam_terms::analytic_penalties::ScalarWeightSchedule`] on
24//!    [`gam_terms::analytic_penalties::IsometryPenalty`]. A loose gauge
25//!    leaves the decoder free to find a good fit before the gauge pins it.
26//!
27//! [`ContinuationPath`] advances all three **in lockstep** along a single
28//! scalar path parameter `s ∈ [1 → 0]`. `s = 1` is the *entry regime*: large
29//! ρ, high τ, loose-but-rising isometry — the regime in which the joint inner
30//! solve is provably a contraction. `s = 0` is the *real objective*: target ρ\*,
31//! sharp τ, tight isometry. The path walks `s` monotonically down, advancing
32//! the underlying schedules at each waypoint so the inner problem is never
33//! asked to jump from cold to the real objective.
34//!
35//! # Entry is always the heavy-smoothing regime
36//!
37//! There is no "solve cold at the real objective" entry. The only entry is
38//! `s = 1`, where every leg is at its smoothing extreme and the inner solve is
39//! a contraction. A K≥2 SAE joint fit therefore always *arrives* at ρ\* / τ_min
40//! / tight-isometry along a continuous descent from a regime where convergence
41//! is guaranteed.
42//!
43//! # The tail is a homotopy FLOOR, not a gate
44//!
45//! If a downward step's inner solve struggles, the path does **not** reject:
46//! it re-enters a heavier regime (raises `s` back toward 1 by a back-off
47//! fraction) and re-descends with a finer step. This is a *floor* the iterate
48//! bounces off, never a trapdoor it falls through. The structural guarantee is
49//! encoded in the type: [`ContinuationStep`] — the per-step outcome enum — has
50//! **no `Reject` / `Failed` / `NoUsableSeed` arm**. The worst a step can report
51//! is [`ContinuationStep::Reentered`] (bounced off the floor, re-descending),
52//! which is progress toward, not abandonment of, the fit. There is no value of
53//! the outcome type that means "give up".
54//!
55//! # How this absorbs #969 (warm-invariance) and #976 (hardening)
56//!
57//! * **#969 — warm-invariance.** A cold entry (no warm β) and a warm entry
58//!   (β carried from a previous fit / cache) both enter at `s = 1`, where the
59//!   inner solve is a contraction with a *unique* fixed point. A contraction
60//!   forgets its initial condition, so both entries are funneled to the SAME
61//!   `s = 1` iterate, and from there walk the SAME coupled schedule to the
62//!   SAME criterion at `s = 0`. Warm entry only *shortens* the walk (its β is
63//!   already near the `s = 1` fixed point); it cannot change the destination.
64//!   The path therefore makes "cold and warm reach the same criterion" a
65//!   structural property rather than a tolerance the caller must check.
66//! * **#976 — hardening.** Two hooks the wiring agent (editing
67//!   `rho_optimizer.rs` / `atom_selection.rs`) calls per inner iteration:
68//!   a **trust-region cap on the assignment logits**
69//!   ([`LogitTrustRegion`]) so a single Newton step can never fling the
70//!   relaxed assignment across the argmax cliff; and an **active-mass-floor
71//!   breach signal** ([`ActiveMassFloor`] / [`MassFloorBreach`]) that, when the
72//!   per-row active mass collapses toward the uniform saddle, triggers a
73//!   *re-seed from the scaffold* (the pristine seeded geometry) — recorded in
74//!   the [`ReseedLedger`], **never fatal**. A breach is a ledger entry and a
75//!   regime re-entry, not an error return.
76//!
77//! This module owns the coupling object and the hook *interfaces / return
78//! types*. The wiring agent implements the call sites against these types.
79
80use ndarray::{Array1, ArrayView2};
81
82use crate::estimate::reml::continuation::{
83    ContinuationFailure, ContinuationState, PATH_BUDGET, continue_path_from, fit_with_continuation,
84};
85use crate::rho_optimizer::{OuterEvalOrder, OuterObjective};
86use gam_terms::analytic_penalties::ScalarWeightSchedule;
87use gam_problem::schedule::{GumbelTemperatureSchedule, ScheduleKind};
88
89/// Number of lockstep waypoints the path visits as `s` walks `1 → 0`. Each
90/// waypoint advances every leg one notch and runs one ρ-anneal spine pass.
91/// Chosen so the geometric schedules have room to descend an order of
92/// magnitude or two per leg without a single step that crosses the contraction
93/// boundary; the homotopy floor absorbs any waypoint that still over-reaches.
94pub const CONTINUATION_WAYPOINTS: usize = 8;
95
96/// Back-off fraction applied to `s` when a waypoint's inner solve struggles:
97/// `s ← min(1, s + REENTRY_BACKOFF)`. Re-entering a heavier regime and
98/// re-descending with a halved step is the *floor* behavior — there is no
99/// rejection alternative.
100///
101/// Exactly **one waypoint** of the lockstep grid (`1/CONTINUATION_WAYPOINTS`):
102/// a bounce off the homotopy floor re-enters the *previous* waypoint's heavier
103/// regime, the lightest regime already proven solvable on this walk. Combined
104/// with the halved re-descent step, a one-notch bounce costs ~2 walk legs
105/// (the re-entry plus one finer re-descent). The previous two-notch back-off
106/// (0.25) cost ~4 legs per bounce, which starved the bounded walk budget under
107/// repeated mass-floor bounces and left the K≥2 joint fit stranded mid-path —
108/// handed to the solver half-annealed, the routing-collapse signature.
109pub const REENTRY_BACKOFF: f64 = 1.0 / CONTINUATION_WAYPOINTS as f64;
110
111/// Total leg budget for one coupled walk (`rho_optimizer.rs` drives
112/// [`ContinuationPath::step`] at most this many times per seed). Two legs per
113/// waypoint: a clean walk uses `CONTINUATION_WAYPOINTS` descents, and each
114/// homotopy-floor bounce costs ~2 extra legs at the one-notch
115/// [`REENTRY_BACKOFF`] (the re-entry leg plus one finer re-descent leg), so a
116/// 2× budget tolerates ~`CONTINUATION_WAYPOINTS/2` bounces before the walk is
117/// cut off — enough for the expected near-cliff re-entries while keeping the
118/// total inner-solve count bounded. The previous 1.5× budget tolerated only
119/// ~1 two-notch bounce, so any mass-floor bounce ended the walk un-arrived.
120pub const CONTINUATION_WALK_BUDGET: usize = 2 * CONTINUATION_WAYPOINTS;
121
122/// Eval budget for one **warm** waypoint leg. A warm leg starts at the
123/// previous waypoint's converged state and walks one waypoint of ρ, so it
124/// needs a handful of evals, not the full cold spine: the coupled path's
125/// waypoints ARE the anneal. (Re-running the whole ρ₀→target spine per
126/// waypoint multiplies the walk's cost by the spine budget — the K=2 existence
127/// fixture burned 7 CPU-hours exactly that way before warm legs existed.)
128pub const WARM_LEG_EVAL_BUDGET: usize = 8;
129
130/// Hard ceiling on *budgeted* spine evals across one coupled walk — the #968
131/// termination guarantee made structural. A clean walk budgets
132/// `PATH_BUDGET + (CONTINUATION_WAYPOINTS − 1) · WARM_LEG_EVAL_BUDGET` (one
133/// cold entry spine, then warm legs); the ceiling leaves ~3× that for
134/// homotopy-floor bounces. At the ceiling the path **arrives with its best
135/// converged state** instead of spending another leg: a walk cannot spin.
136pub const WALK_EVAL_CEILING: usize =
137    3 * (PATH_BUDGET + CONTINUATION_WAYPOINTS * WARM_LEG_EVAL_BUDGET);
138
139/// Floor on the per-waypoint descent step in `s`. Below this the path is
140/// taking near-zero steps; it does not give up — it pins `s` at its current
141/// (heavier) regime and keeps re-descending from there. The floor is a
142/// *behavior*, never an exit.
143pub const S_STEP_FLOOR: f64 = 1.0 / 256.0;
144
145/// The endpoints of one coupled annealing leg, in path-parameter terms.
146/// `at_entry` is the value at `s = 1` (heavy-smoothing regime); `at_target`
147/// is the value at `s = 0` (real objective). Interpolation is in the leg's
148/// own natural geometry (log-space for ρ and τ, linear-in-weight for the
149/// isometry gauge, matching each schedule's `current_*` law).
150#[derive(Debug, Clone, Copy)]
151pub struct LegEndpoints {
152    /// Value at `s = 1`: the smoothing-extreme entry regime.
153    pub at_entry: f64,
154    /// Value at `s = 0`: the real-objective target.
155    pub at_target: f64,
156}
157
158impl LegEndpoints {
159    /// Construct from an entry value and a target value.
160    #[must_use]
161    pub fn new(at_entry: f64, at_target: f64) -> Self {
162        Self {
163            at_entry,
164            at_target,
165        }
166    }
167
168    /// Linear interpolation in the leg's natural coordinate at path parameter
169    /// `s ∈ [0, 1]`: `s = 1 → at_entry`, `s = 0 → at_target`. The caller passes
170    /// values already in the leg's natural geometry (e.g. log τ, log λ), so a
171    /// plain convex blend is the right law and matches the schedules'
172    /// `current_*` interpolation.
173    #[must_use]
174    pub fn at(&self, s: f64) -> f64 {
175        let s = s.clamp(0.0, 1.0);
176        self.at_target + s * (self.at_entry - self.at_target)
177    }
178}
179
180/// The coupled schedule state that [`ContinuationPath`] owns. Each leg is the
181/// concrete schedule object the rest of the codebase already advances; the
182/// path holds them so they can only ever move together.
183#[derive(Debug, Clone)]
184pub struct CoupledSchedules {
185    /// ρ-anneal endpoints, **per-component** in ρ-space (one entry per
186    /// smoothing parameter). The entry vector is the oversmoothed ρ₀; the
187    /// target is ρ\*. The actual descent is executed by the ρ-anneal spine
188    /// ([`fit_with_continuation`]); these endpoints fix where `s` places the
189    /// spine's `target` waypoint along the coupled walk.
190    pub rho_entry: Array1<f64>,
191    /// ρ\* — the real-objective smoothing vector at `s = 0`.
192    pub rho_target: Array1<f64>,
193    /// Legal upper bound on ρ (the spine clamps ρ₀ into this box).
194    pub rho_bounds_upper: Array1<f64>,
195    /// Assignment-temperature schedule (τ leg). Consumed, not re-implemented:
196    /// the path reads `tau_start` / `tau_min` as its τ endpoints and advances
197    /// the schedule in lockstep with `s`.
198    pub temperature: GumbelTemperatureSchedule,
199    /// Isometry-weight schedule (gauge leg). Consumed: `w_start` / `w_end` are
200    /// the isometry endpoints; advanced in lockstep with `s`.
201    pub isometry: ScalarWeightSchedule,
202}
203
204impl CoupledSchedules {
205    /// τ endpoints as `LegEndpoints` in the schedule's natural coordinate.
206    /// `s = 1` → `tau_start` (diffuse), `s = 0` → `tau_min` (sharp).
207    #[must_use]
208    pub fn temperature_endpoints(&self) -> LegEndpoints {
209        LegEndpoints::new(self.temperature.tau_start, self.temperature.tau_min)
210    }
211
212    /// Isometry-weight endpoints. `s = 1` → `w_start` (loose), `s = 0` →
213    /// `w_end` (tight).
214    #[must_use]
215    pub fn isometry_endpoints(&self) -> LegEndpoints {
216        LegEndpoints::new(self.isometry.w_start, self.isometry.w_end)
217    }
218
219    /// The coupled lockstep target value of every scalar leg at path parameter
220    /// `s`. ρ is a vector and rides the spine, so it is not returned here; the
221    /// two scalar legs (τ, isometry weight) are.
222    #[must_use]
223    pub fn scalar_targets_at(&self, s: f64) -> ScalarLegTargets {
224        ScalarLegTargets {
225            tau: self.temperature_endpoints().at(s),
226            isometry_weight: self.isometry_endpoints().at(s),
227        }
228    }
229
230    /// The ρ target the spine should anneal toward at path parameter `s`:
231    /// a convex blend (per component) of the oversmoothed entry ρ₀ and ρ\*.
232    /// At `s = 1` this is ρ₀ itself (so the spine's own oversmoothing offset
233    /// stacks the path into the deepest contraction); at `s = 0` it is ρ\*.
234    #[must_use]
235    pub fn rho_target_at(&self, s: f64) -> Array1<f64> {
236        assert_eq!(
237            self.rho_entry.len(),
238            self.rho_target.len(),
239            "ContinuationPath: ρ entry/target dimension mismatch"
240        );
241        let s = s.clamp(0.0, 1.0);
242        let mut out = self.rho_target.clone();
243        for i in 0..out.len() {
244            out[i] = self.rho_target[i] + s * (self.rho_entry[i] - self.rho_target[i]);
245        }
246        out
247    }
248}
249
250/// The lockstep target values of the two scalar legs at a given `s`. Handed to
251/// the wiring agent so it can install τ on the SAE term and the isometry weight
252/// on the gauge penalty before the spine pass at this waypoint.
253#[derive(Debug, Clone, Copy)]
254pub struct ScalarLegTargets {
255    /// Assignment temperature τ at this waypoint.
256    pub tau: f64,
257    /// Isometry gauge weight at this waypoint.
258    pub isometry_weight: f64,
259}
260
261// ─────────────────────────────────────────────────────────────────────────
262//  Hardening hook interfaces (#976). Defined here; implemented at the call
263//  sites by the wiring agent (rho_optimizer.rs / atom_selection.rs).
264// ─────────────────────────────────────────────────────────────────────────
265
266/// Per-iteration trust-region cap on the assignment logits.
267///
268/// The wiring agent calls [`LogitTrustRegion::cap_step`] on each candidate
269/// Newton step in assignment-logit space before it is applied, so a single
270/// step can never fling the relaxed assignment across the argmax cliff (the
271/// discontinuity the τ anneal exists to avoid). The cap is an ∞-norm radius on
272/// the logit increment, tied to the current τ: hotter τ (diffuse) tolerates a
273/// larger logit move; colder τ (sharp) clamps tighter, because near the cliff a
274/// small logit change is a large assignment change.
275#[derive(Debug, Clone, Copy)]
276pub struct LogitTrustRegion {
277    /// ∞-norm radius on the logit increment at the current waypoint.
278    pub radius: f64,
279}
280
281/// Outcome of applying the logit trust-region cap to a proposed step. The
282/// wiring agent applies the returned (possibly shrunk) step. There is no
283/// "reject" outcome — the cap only *scales* the step.
284#[derive(Debug, Clone, Copy)]
285pub enum LogitStepCap {
286    /// The proposed step was within the radius; apply it unchanged.
287    Within,
288    /// The proposed step exceeded the radius; scale it by `scale ∈ (0, 1)` so
289    /// its ∞-norm equals `radius`, then apply.
290    Scaled { scale: f64 },
291}
292
293impl LogitTrustRegion {
294    /// Build the per-waypoint logit trust region from the current τ. Hotter τ
295    /// ⇒ larger radius (the assignment map is gentle); colder τ ⇒ tighter
296    /// radius (near the argmax cliff). The radius is `τ · LOGIT_TR_TAU_GAIN`
297    /// clamped to `[LOGIT_TR_MIN, LOGIT_TR_MAX]`.
298    #[must_use]
299    pub fn for_tau(tau: f64) -> Self {
300        const LOGIT_TR_TAU_GAIN: f64 = 4.0;
301        const LOGIT_TR_MIN: f64 = 1.0e-2;
302        const LOGIT_TR_MAX: f64 = 8.0;
303        let radius = (tau * LOGIT_TR_TAU_GAIN).clamp(LOGIT_TR_MIN, LOGIT_TR_MAX);
304        Self { radius }
305    }
306
307    /// Decide how to cap a proposed logit increment given its ∞-norm. The
308    /// wiring agent passes the step's ∞-norm; this returns whether to apply it
309    /// unchanged or scaled to the radius. Never rejects.
310    #[must_use]
311    pub fn cap_step(&self, step_inf_norm: f64) -> LogitStepCap {
312        if !step_inf_norm.is_finite() || step_inf_norm <= self.radius || step_inf_norm == 0.0 {
313            LogitStepCap::Within
314        } else {
315            LogitStepCap::Scaled {
316                scale: self.radius / step_inf_norm,
317            }
318        }
319    }
320}
321
322/// Active-mass-floor watcher (#976). The wiring agent calls
323/// [`ActiveMassFloor::check`] with the per-row mean active assignment mass each
324/// inner iteration. When the mass collapses toward the uniform saddle (below
325/// the floor), `check` returns a [`MassFloorBreach`] the caller records in the
326/// [`ReseedLedger`] and acts on by re-seeding from the scaffold. A breach is
327/// **never fatal** — there is no error return.
328#[derive(Debug, Clone, Copy)]
329pub struct ActiveMassFloor {
330    /// Mean active mass below which the assignment is judged to have collapsed
331    /// toward the near-uniform saddle and a scaffold re-seed is triggered.
332    pub floor: f64,
333}
334
335impl ActiveMassFloor {
336    /// Default floor: the **failure boundary**, not the healthy operating
337    /// point. The SAE routing-collapse quality oracle plants a healthy
338    /// codes'-units active mass of ~`0.2` and asserts recovery of at least
339    /// half of it; the floor therefore sits at `0.5 × 0.2 = 0.1` — breach
340    /// exactly when the fit enters the region the quality assertion already
341    /// calls collapsed. Placing the floor *at* the healthy operating mass
342    /// (the previous `0.2`) made a healthy converging IBP-MAP fit oscillate
343    /// across the floor, and every spurious breach re-seeds from the scaffold
344    /// (`obj.reset()`) and re-enters a heavier regime — re-seed thrash that
345    /// discards converged routing mass each bounce and pins the fit near the
346    /// cold seed: itself a collapse mechanism. Genuine saddle collapse
347    /// (~`0.03` observed mass) is still far below this floor.
348    pub const DEFAULT_FLOOR: f64 = 0.1;
349
350    #[must_use]
351    pub fn default_floor() -> Self {
352        Self {
353            floor: Self::DEFAULT_FLOOR,
354        }
355    }
356
357    /// Check the observed mean active mass against the floor. Returns
358    /// `Some(MassFloorBreach)` when collapsed (caller re-seeds from scaffold +
359    /// logs to the ledger), `None` when healthy. Never an error.
360    #[must_use]
361    pub fn check(&self, mean_active_mass: f64) -> Option<MassFloorBreach> {
362        if mean_active_mass.is_finite() && mean_active_mass >= self.floor {
363            None
364        } else {
365            Some(MassFloorBreach {
366                observed_mean_mass: mean_active_mass,
367                floor: self.floor,
368            })
369        }
370    }
371}
372
373/// A recorded active-mass-floor breach. Carries the observed mass and the floor
374/// it fell below. The wiring agent's response is a re-seed-from-scaffold, not a
375/// failure: this is appended to the [`ReseedLedger`] and the path re-enters a
376/// heavier regime.
377#[derive(Debug, Clone, Copy)]
378pub struct MassFloorBreach {
379    pub observed_mean_mass: f64,
380    pub floor: f64,
381}
382
383/// Append-only ledger of scaffold re-seeds triggered by active-mass-floor
384/// breaches. Non-fatal by construction: the ledger only *records*; it never
385/// holds a terminal/abort state. The wiring agent threads one ledger through
386/// the joint fit and queries [`ReseedLedger::reseed_count`] for diagnostics.
387#[derive(Debug, Clone, Default)]
388pub struct ReseedLedger {
389    entries: Vec<ReseedEvent>,
390}
391
392/// One scaffold re-seed event: the path parameter `s` at which the breach was
393/// observed and the breach payload. Lets diagnostics see whether re-seeds
394/// cluster at sharp-τ waypoints (the expected near-cliff regime).
395#[derive(Debug, Clone, Copy)]
396pub struct ReseedEvent {
397    pub s: f64,
398    pub breach: MassFloorBreach,
399}
400
401impl ReseedLedger {
402    #[must_use]
403    pub fn new() -> Self {
404        Self {
405            entries: Vec::new(),
406        }
407    }
408
409    /// Record a scaffold re-seed triggered at path parameter `s`. Returns
410    /// nothing fatal — recording a breach is routine homotopy bookkeeping.
411    pub fn record(&mut self, s: f64, breach: MassFloorBreach) {
412        self.entries.push(ReseedEvent { s, breach });
413    }
414
415    #[must_use]
416    pub fn reseed_count(&self) -> usize {
417        self.entries.len()
418    }
419
420    #[must_use]
421    pub fn events(&self) -> &[ReseedEvent] {
422        &self.entries
423    }
424}
425
426// ─────────────────────────────────────────────────────────────────────────
427//  Regime-escalation view of re-entry (#969 seed-cascade demotion).
428//
429//  The seed cascade in `rho_optimizer.rs` observes the path through a coarser
430//  lens than the per-waypoint `s`: it only needs to know "which heavier regime
431//  did this seed get demoted to". `PathRegime` is that coarse view — a band of
432//  the path parameter `s` — and `PathDemotionReason` records *why* the cascade
433//  asked for the demotion. A demotion is exactly a re-entry into a heavier
434//  regime (it routes onto the same `reenter_heavier` mechanism as a spine
435//  struggle or a mass-floor breach); there is NO rejection / disqualification
436//  arm, mirroring `ContinuationStep`.
437// ─────────────────────────────────────────────────────────────────────────
438
439/// The coarse "which heavy-smoothing regime is the path currently entering at"
440/// view the seed cascade reports against. Banded from the live path parameter
441/// `s ∈ [0, 1]`: heavier regime ⇒ larger `s` ⇒ deeper into the contraction
442/// basin. Every variant is a *re-entry* the cascade re-evaluates a seed at;
443/// none of them is a rejection.
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum PathRegime {
446    /// `s` near the real objective (`s ≤ 1/4`): the path is at or close to ρ*,
447    /// the lightest smoothing the path ever sits at. The nominal entry band
448    /// only on a fully-descended path.
449    Target,
450    /// Mid-path (`1/4 < s ≤ 3/4`): partially annealed, intermediate smoothing.
451    Annealing,
452    /// Heavy-smoothing entry band (`s > 3/4`): the deepest contraction regime,
453    /// where the joint inner solve is provably a contraction. The band a fresh
454    /// `heavy_entry` starts in and the band repeated demotions converge toward.
455    Heavy,
456}
457
458impl PathRegime {
459    /// Band the live path parameter `s` into the coarse regime the seed cascade
460    /// reports. Monotone in `s`: larger `s` ⇒ heavier regime.
461    #[must_use]
462    fn from_s(s: f64) -> Self {
463        let s = s.clamp(0.0, 1.0);
464        if s > 0.75 {
465            PathRegime::Heavy
466        } else if s > 0.25 {
467            PathRegime::Annealing
468        } else {
469            PathRegime::Target
470        }
471    }
472}
473
474/// Why the seed cascade asked the path to demote a seed to a heavier regime.
475/// Purely a diagnostic tag carried into the demotion ledger — every variant
476/// resolves to "re-enter the same seed at a heavier `s`", never to a rejection.
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub enum PathDemotionReason {
479    /// A uniform structural diagnosis (rank / alias / active-set defect seen
480    /// consistently across seeds) that the legacy contract would have used to
481    /// short-circuit the cascade. For a continuation-entry objective it instead
482    /// demotes to a heavier regime and keeps evaluating.
483    UniformStructural,
484    /// The continuation pre-warm refused to reach a seed at the current regime
485    /// (a structural refusal of the seed's joint design). Demoted to a heavier
486    /// regime so the joint solver gets a feasible basin the current regime could
487    /// not reach.
488    PrewarmStructural,
489}
490
491// ─────────────────────────────────────────────────────────────────────────
492//  The per-step outcome enum. Note: NO Reject / Failed / NoUsableSeed arm.
493// ─────────────────────────────────────────────────────────────────────────
494
495/// Outcome of one [`ContinuationPath`] waypoint step. The defining structural
496/// property: **there is no rejection arm.** A step either descends, arrives, or
497/// bounces off the homotopy floor back into a heavier regime. None of these
498/// means "give up"; the tail is a floor, not a gate. The absence of a `Reject`
499/// variant is the whole point — the type cannot represent "no usable seed".
500#[derive(Debug, Clone)]
501pub(crate) enum ContinuationStep {
502    /// `s` was lowered toward `0` and the inner solve at the new waypoint
503    /// succeeded. Carries the accepted spine state and the new `s`.
504    Descended { s: f64, state: ContinuationState },
505    /// `s` reached `0`: the path arrived at the real objective (ρ\*, τ_min,
506    /// tight isometry). Terminal-but-successful; the criterion is the real
507    /// objective's, identical for cold and warm entry (#969).
508    Arrived { state: ContinuationState },
509    /// The inner solve at the attempted waypoint struggled, so the path
510    /// re-entered a heavier regime (`s` raised back toward `1` by the back-off
511    /// fraction) and will re-descend with a finer step. This is the homotopy
512    /// floor in action — progress toward the fit, never abandonment. Carries
513    /// the heavier `s` to descend from next and the underlying spine signal
514    /// that prompted the back-off (for diagnostics only; it is **not** an
515    /// error the path surfaces upward).
516    Reentered { s: f64, reason: ReentryReason },
517}
518
519/// Why a waypoint re-entered a heavier regime. Purely diagnostic — every
520/// variant resolves to "re-descend from a heavier `s`", never to a rejection.
521#[derive(Debug, Clone)]
522pub(crate) enum ReentryReason {
523    /// The ρ-anneal spine could not complete the descent to this waypoint's ρ
524    /// target from the current regime. The underlying `ContinuationFailure` is
525    /// kept for logging; the path's response is unconditionally to re-enter a
526    /// heavier regime, because at the heaviest regime the inner solve is a
527    /// contraction and *must* converge.
528    SpineStruggled(ContinuationFailure),
529    /// The active-mass floor was breached at this waypoint; a scaffold re-seed
530    /// was recorded and the path re-enters a heavier regime to let τ re-diffuse
531    /// the assignment before re-sharpening.
532    MassFloorBreached(MassFloorBreach),
533    /// The descent step in `s` underflowed `S_STEP_FLOOR`; the path pins `s` at
534    /// the current heavier regime and keeps re-descending from there rather
535    /// than taking vanishing steps. Still not a rejection — the floor holds.
536    StepUnderflow,
537}
538
539// ─────────────────────────────────────────────────────────────────────────
540//  The ContinuationPath object.
541// ─────────────────────────────────────────────────────────────────────────
542
543/// Object 1 — the coupled continuation path. Owns the three schedules and the
544/// scalar path parameter `s`, and drives the K≥2 SAE joint fit down the coupled
545/// homotopy. Entry is always `s = 1` (heavy-smoothing contraction regime); the
546/// tail is a homotopy floor with no rejection exit.
547///
548/// The wiring agent drives the path one waypoint at a time:
549/// `let step = path.step(obj, &mut ledger);` and, per [`ContinuationStep`],
550/// installs the next waypoint's [`ScalarLegTargets`] (τ on the SAE term,
551/// isometry weight on the gauge penalty) and applies the [`LogitTrustRegion`] /
552/// [`ActiveMassFloor`] hooks inside the inner solve.
553#[derive(Debug, Clone)]
554pub struct ContinuationPath {
555    schedules: CoupledSchedules,
556    /// Current path parameter. Starts at `1.0` (entry regime) and walks toward
557    /// `0.0`. Re-entry raises it back toward `1.0`; descent lowers it.
558    s: f64,
559    /// Current descent step in `s`. Halved on re-entry, restored on a clean
560    /// descent. Floored at [`S_STEP_FLOOR`] (a behavior, not an exit).
561    s_step: f64,
562    /// Logit trust region and active-mass floor recomputed per waypoint from
563    /// the current τ.
564    logit_tr: LogitTrustRegion,
565    mass_floor: ActiveMassFloor,
566    /// Path-owned re-seed ledger for breaches reported through the bare,
567    /// no-ledger hardening hook ([`ContinuationPath::note_active_mass_breach`]).
568    /// The richer ledger-threading API ([`ContinuationPath::note_mass_breach`])
569    /// is unchanged; this internal ledger backs the inner-loop call site that
570    /// does not thread its own ledger. Append-only, never fatal.
571    reseed_ledger: ReseedLedger,
572    /// The most recent converged waypoint state. `None` until the first leg
573    /// converges (that leg runs the full cold spine); every later waypoint is
574    /// a WARM leg from here — the structural fix for the per-waypoint
575    /// cold-spine cost blowup. Kept across re-entries (a heavier waypoint is
576    /// still downstream of a converged lighter-ρ state in walk distance).
577    warm: Option<ContinuationState>,
578    /// Budgeted spine evals spent so far (cold legs budget the full spine,
579    /// warm legs budget [`WARM_LEG_EVAL_BUDGET`]). Compared against
580    /// [`WALK_EVAL_CEILING`] for the #968 structural-termination guarantee.
581    evals_budgeted: usize,
582}
583
584impl ContinuationPath {
585    /// Build the coupled path. `s` is initialized to `1.0` — the heavy-smoothing
586    /// entry regime where the joint inner solve is a contraction. The path can
587    /// **only** be entered here; there is no constructor that starts cold at the
588    /// real objective. This is what makes warm-invariance (#969) structural: any
589    /// entry, warm or cold, funnels through the `s = 1` contraction fixed point.
590    #[must_use]
591    pub fn enter(schedules: CoupledSchedules) -> Self {
592        let entry_targets = schedules.scalar_targets_at(1.0);
593        let logit_tr = LogitTrustRegion::for_tau(entry_targets.tau);
594        Self {
595            schedules,
596            s: 1.0,
597            s_step: 1.0 / CONTINUATION_WAYPOINTS as f64,
598            logit_tr,
599            mass_floor: ActiveMassFloor::default_floor(),
600            reseed_ledger: ReseedLedger::new(),
601            warm: None,
602            evals_budgeted: 0,
603        }
604    }
605
606    /// No-argument heavy-smoothing entry for a continuation-entry objective
607    /// (the seed cascade ctor). Builds the default coupled schedules — a
608    /// single-component oversmoothed ρ leg, the standard diffuse→sharp τ leg and
609    /// the loose→tight isometry gauge leg — and enters at `s = 1`, the
610    /// heavy-smoothing contraction regime. The seed cascade only reads the
611    /// coarse [`PathRegime`] and the logit step radius from the path; the
612    /// concrete ρ vector is replaced by the spine's own per-component target at
613    /// each waypoint via [`ContinuationPath::current_rho_target`], so the
614    /// single-component default here is the entry placeholder, not a constraint
615    /// on the real fit's dimensionality.
616    #[must_use]
617    pub fn heavy_entry() -> Self {
618        Self::enter(default_coupled_schedules())
619    }
620
621    /// Heavy-smoothing entry coupled to a CONCRETE ρ target and legal box. The
622    /// seed cascade rebuilds the path per-seed with this once it knows the
623    /// objective's real ρ dimension (the no-argument [`ContinuationPath::heavy_entry`]
624    /// is a dimension-1 placeholder used only before the seed is in hand). The
625    /// ρ leg rides the spine from the spine's own oversmoothed ρ₀ down to
626    /// `rho_target` (the real objective ρ\*); `bounds_upper` is the legal ρ box.
627    /// The τ / isometry legs use the standard diffuse→sharp / loose→tight
628    /// default endpoints. Enters at `s = 1`, the heavy-smoothing contraction
629    /// regime. `rho_target` and `bounds_upper` must share length.
630    #[must_use]
631    pub fn heavy_entry_for_rho(rho_target: Array1<f64>, bounds_upper: Array1<f64>) -> Self {
632        assert_eq!(
633            rho_target.len(),
634            bounds_upper.len(),
635            "ContinuationPath::heavy_entry_for_rho: ρ target/bounds dim mismatch"
636        );
637        // Passing `rho_target` as both entry and target lets the spine own the
638        // entire oversmoothing offset (it builds ρ₀ = ρ* + OVERSMOOTH_OFFSET_INIT
639        // internally and anneals down), while the path simply rides at `s` along
640        // ρ*. This keeps a single source of truth for the ρ anneal — the spine —
641        // and the path couples the τ / isometry legs against that shared walk.
642        let schedules = couple_schedules(
643            rho_target.clone(),
644            rho_target,
645            bounds_upper,
646            default_temperature_schedule(),
647            default_isometry_schedule(),
648        );
649        Self::enter(schedules)
650    }
651
652    /// The coarse heavy-smoothing regime the path is currently entering at. The
653    /// seed cascade reports this in its demotion ledger and final diagnosis. A
654    /// fresh [`ContinuationPath::heavy_entry`] is in [`PathRegime::Heavy`].
655    #[must_use]
656    pub fn enter_regime(&self) -> PathRegime {
657        PathRegime::from_s(self.s)
658    }
659
660    /// Demote the seed cascade to a heavier path regime with a recorded reason
661    /// and return the regime re-entered at. This is the regime-escalation view
662    /// of re-entry: it routes onto the same [`ContinuationPath::reenter_heavier`]
663    /// mechanism a spine struggle or a mass-floor breach uses (raise `s` toward
664    /// the entry regime, refine the step), so a structural diagnosis becomes a
665    /// heavier-regime RE-ENTRY of the same seed — **never** a rejection. The
666    /// `reason` is a diagnostic tag the caller records alongside the returned
667    /// regime; the demotion mechanism is identical for every reason.
668    pub fn demote_with_reason(&mut self, reason: PathDemotionReason) -> PathRegime {
669        // The reason is diagnostic only: every demotion is a re-entry into a
670        // heavier regime. Naming it explicitly keeps the value live (no silent
671        // discard) while documenting that the escalation path is reason-agnostic.
672        match reason {
673            PathDemotionReason::UniformStructural | PathDemotionReason::PrewarmStructural => {
674                self.reenter_heavier();
675            }
676        }
677        self.enter_regime()
678    }
679
680    /// The base radius the per-iteration assignment-logit trust region is built
681    /// from (`rho_optimizer.rs` / `atom_selection.rs` hardening hook). This is
682    /// the ∞-norm logit step radius at the current waypoint; heavier regimes
683    /// (after a demotion / re-entry) cool τ and so hand back a tighter radius,
684    /// shrinking every atom's logit cap with no separate knob.
685    #[must_use]
686    pub fn logit_step_radius(&self) -> f64 {
687        self.logit_tr.radius
688    }
689
690    /// Bare active-mass-floor breach hook for the inner-loop call site that does
691    /// not thread its own [`ReseedLedger`]. Records the breach in the
692    /// path-owned ledger at the current `s` and re-enters a heavier regime —
693    /// the same non-fatal response as [`ContinuationPath::note_mass_breach`],
694    /// without requiring the caller to carry a ledger. Returns the heavier
695    /// [`PathRegime`] re-entered at so the call site can report it. **Never
696    /// fatal** — a breach is a re-entry, never a rejection.
697    pub fn note_active_mass_breach(&mut self) -> PathRegime {
698        let breach = MassFloorBreach {
699            observed_mean_mass: self.mass_floor.floor,
700            floor: self.mass_floor.floor,
701        };
702        // Single source of truth for the breach response: route through
703        // `note_mass_breach` so the record-then-re-enter logic is not
704        // duplicated. The bare hook differs only in *which* ledger it threads —
705        // the path-owned one — so we lend that ledger to the shared driver and
706        // hand it back afterwards.
707        let mut owned = std::mem::take(&mut self.reseed_ledger);
708        let step = self.note_mass_breach(breach, &mut owned);
709        self.reseed_ledger = owned;
710        // The shared driver always re-enters a heavier regime (never rejects);
711        // the bare hook's contract is the coarse regime it landed in. Match the
712        // step exhaustively so the outcome is observed (no silent discard) and
713        // the "every breach is progress" invariant is documented at the use
714        // site: every arm resolves to the heavier live regime.
715        match step {
716            ContinuationStep::Reentered { .. }
717            | ContinuationStep::Descended { .. }
718            | ContinuationStep::Arrived { .. } => self.enter_regime(),
719        }
720    }
721
722    /// Number of scaffold re-seeds recorded through the bare
723    /// [`ContinuationPath::note_active_mass_breach`] hook (diagnostics).
724    #[must_use]
725    pub fn reseed_count(&self) -> usize {
726        self.reseed_ledger.reseed_count()
727    }
728
729    /// Current path parameter `s ∈ [0, 1]`.
730    #[must_use]
731    pub fn s(&self) -> f64 {
732        self.s
733    }
734
735    /// The scalar leg targets (τ, isometry weight) at the current `s`. The
736    /// wiring agent installs these before the inner solve at this waypoint.
737    #[must_use]
738    pub fn current_scalar_targets(&self) -> ScalarLegTargets {
739        self.schedules.scalar_targets_at(self.s)
740    }
741
742    /// The ρ target the spine should anneal toward at the current `s`.
743    #[must_use]
744    pub fn current_rho_target(&self) -> Array1<f64> {
745        self.schedules.rho_target_at(self.s)
746    }
747
748    /// The per-waypoint logit trust region (from the current τ). The wiring
749    /// agent caps each assignment-logit Newton step with this.
750    #[must_use]
751    pub fn logit_trust_region(&self) -> LogitTrustRegion {
752        self.logit_tr
753    }
754
755    /// The active-mass floor for this path. The wiring agent calls
756    /// [`ActiveMassFloor::check`] with the observed mean active mass each inner
757    /// iteration and, on breach, records a scaffold re-seed in the ledger and
758    /// reports it back via [`ContinuationPath::note_mass_breach`].
759    #[must_use]
760    pub fn active_mass_floor(&self) -> ActiveMassFloor {
761        self.mass_floor
762    }
763
764    /// Record an active-mass-floor breach into the ledger and re-enter a
765    /// heavier regime. Returns the [`ContinuationStep::Reentered`] the wiring
766    /// agent should act on. **Never fatal** — a breach is a re-entry, never a
767    /// rejection. This is the hook the wiring agent calls when
768    /// [`ActiveMassFloor::check`] returns `Some` from inside the inner solve.
769    pub(crate) fn note_mass_breach(
770        &mut self,
771        breach: MassFloorBreach,
772        ledger: &mut ReseedLedger,
773    ) -> ContinuationStep {
774        ledger.record(self.s, breach);
775        self.reenter_heavier();
776        ContinuationStep::Reentered {
777            s: self.s,
778            reason: ReentryReason::MassFloorBreached(breach),
779        }
780    }
781
782    /// Raise `s` back toward the entry regime by the back-off fraction and
783    /// halve the descent step (finer re-descent). Floors the step at
784    /// [`S_STEP_FLOOR`]; underflow does not abandon the path, it pins the
785    /// heavier regime. Recomputes the τ-tied logit trust region for the
786    /// heavier regime.
787    fn reenter_heavier(&mut self) {
788        self.s = (self.s + REENTRY_BACKOFF).min(1.0);
789        self.s_step = (self.s_step * 0.5).max(S_STEP_FLOOR);
790        self.logit_tr = LogitTrustRegion::for_tau(self.schedules.scalar_targets_at(self.s).tau);
791    }
792
793    /// Whether the path has arrived at (or below) the real objective `s = 0`.
794    /// The outer driver stops driving [`ContinuationPath::step`] once this is
795    /// true and hands the warm iterate to the normal optimizer at ρ\*.
796    #[must_use]
797    pub fn arrived(&self) -> bool {
798        self.s <= 0.0
799    }
800
801    /// Take one waypoint step down the coupled homotopy.
802    ///
803    /// 1. Lower `s` by the current step toward `0`.
804    /// 2. Advance the τ and isometry schedules to the new waypoint (lockstep).
805    /// 3. Run the ρ-anneal **spine** ([`fit_with_continuation`]) toward the new
806    ///    `s`'s ρ target, with the inner β carried warm.
807    /// 4. On spine success: [`ContinuationStep::Descended`] (or
808    ///    [`ContinuationStep::Arrived`] if `s` reached `0`).
809    /// 5. On spine struggle: re-enter a heavier regime and return
810    ///    [`ContinuationStep::Reentered`]. **No rejection branch exists.**
811    ///
812    /// `obj` is the SAE joint outer objective (`SaeManifoldOuterObjective`,
813    /// which is an [`OuterObjective`]). `initial_beta` warms the inner solve;
814    /// pass the empty array for cold entry (warm-invariance, #969, guarantees
815    /// the same destination either way).
816    pub(crate) fn step(
817        &mut self,
818        obj: &mut dyn OuterObjective,
819        initial_beta: &Array1<f64>,
820    ) -> ContinuationStep {
821        // #968 hard ceiling: total budgeted spine evals across the walk are
822        // bounded. At the ceiling the path hands its best converged state to
823        // the real optimizer (legs advanced to the target regime) instead of
824        // spending another leg — termination is structural, not statistical.
825        // With no converged state yet the walk keeps trying (the consumer's
826        // own `CONTINUATION_WALK_BUDGET` loop bounds that case).
827        if self.evals_budgeted >= WALK_EVAL_CEILING {
828            if let Some(state) = self.warm.clone() {
829                log::warn!(
830                    "[PATH] walk eval ceiling {WALK_EVAL_CEILING} reached at s={:.4}; arriving \
831                     with the best converged waypoint state (scalar legs advanced to target)",
832                    self.s
833                );
834                self.advance_scalar_legs_to(0.0);
835                self.s = 0.0;
836                return ContinuationStep::Arrived { state };
837            }
838        }
839
840        // Descent step in s, floored. If the step has already underflowed, the
841        // path pins the heavier regime and re-descends from there — still no
842        // rejection.
843        if self.s_step < S_STEP_FLOOR {
844            self.reenter_heavier();
845            return ContinuationStep::Reentered {
846                s: self.s,
847                reason: ReentryReason::StepUnderflow,
848            };
849        }
850
851        let s_next = (self.s - self.s_step).max(0.0);
852
853        // Advance the coupled scalar legs to the new waypoint. The schedule
854        // objects are stepped in lockstep so τ and the isometry weight track
855        // exactly the same path parameter the ρ leg is about to anneal to.
856        self.advance_scalar_legs_to(s_next);
857
858        // The ρ leg rides the spine: anneal from the spine's own oversmoothed
859        // ρ₀ down to this waypoint's ρ target. At s = 1 the waypoint ρ target
860        // is ρ₀ itself, so the spine's oversmoothing stacks into the deepest
861        // contraction; at s = 0 it is ρ*.
862        let rho_target = self.schedules.rho_target_at(s_next);
863        // First leg (no converged waypoint yet): the full oversmoothed spine —
864        // the documented deepest-contraction entry. Every later waypoint is a
865        // WARM leg from the previous waypoint's converged state. The coupled
866        // path's waypoints ARE the anneal; re-running the whole ρ₀→target
867        // spine per waypoint multiplies the walk's cost by the spine budget
868        // (the K=2 existence fixture burned 7 CPU-hours exactly that way).
869        let spine = match self.warm.clone() {
870            Some(start) => {
871                self.evals_budgeted += WARM_LEG_EVAL_BUDGET;
872                continue_path_from(
873                    obj,
874                    start,
875                    &rho_target,
876                    OuterEvalOrder::ValueAndGradient,
877                    WARM_LEG_EVAL_BUDGET,
878                )
879            }
880            None => {
881                self.evals_budgeted += PATH_BUDGET;
882                fit_with_continuation(
883                    obj,
884                    &rho_target,
885                    &self.schedules.rho_bounds_upper,
886                    initial_beta,
887                    OuterEvalOrder::ValueAndGradient,
888                )
889            }
890        };
891
892        match spine {
893            Ok(state) => {
894                self.warm = Some(state.clone());
895                self.s = s_next;
896                // Clean descent: restore the nominal step (grow back toward the
897                // coarse schedule) and refresh the τ-tied logit trust region.
898                self.s_step = (1.0 / CONTINUATION_WAYPOINTS as f64).min(self.s.max(S_STEP_FLOOR));
899                self.logit_tr =
900                    LogitTrustRegion::for_tau(self.schedules.scalar_targets_at(self.s).tau);
901                if self.s <= 0.0 {
902                    ContinuationStep::Arrived { state }
903                } else {
904                    ContinuationStep::Descended { s: self.s, state }
905                }
906            }
907            Err(failure) => {
908                // The homotopy FLOOR: never reject. Re-enter a heavier regime
909                // and re-descend with a finer step. At the heaviest regime the
910                // inner solve is a contraction and must converge, so the floor
911                // is reachable in finitely many back-offs.
912                self.reenter_heavier();
913                ContinuationStep::Reentered {
914                    s: self.s,
915                    reason: ReentryReason::SpineStruggled(failure),
916                }
917            }
918        }
919    }
920
921    /// Advance the τ and isometry schedule objects so their live values match
922    /// the lockstep targets at `s_next`. Consumes the schedules' own
923    /// `current_*` laws by selecting the schedule iteration whose output is
924    /// closest to the coupled target, keeping a single source of truth for each
925    /// leg's interpolation (no parallel re-derivation of the decay law).
926    fn advance_scalar_legs_to(&mut self, s_next: f64) {
927        let targets = self.schedules.scalar_targets_at(s_next);
928        // τ: walk the schedule's iteration counter to the step whose
929        // `current_tau` first reaches (≤) the coupled target, so the live τ on
930        // the SAE term equals the coupled-path value. Monotone-decreasing, so a
931        // forward scan from the current count is correct and terminates at
932        // tau_min.
933        Self::advance_temperature_to(&mut self.schedules.temperature, targets.tau);
934        Self::advance_isometry_to(&mut self.schedules.isometry, targets.isometry_weight);
935        self.logit_tr = LogitTrustRegion::for_tau(targets.tau);
936    }
937
938    /// Step `schedule.iter_count` forward until `current_tau` is ≤ `target_tau`
939    /// (τ is monotone non-increasing in iter). Leaves the counter pointing at
940    /// the waypoint so the SAE term reads the coupled τ. Bounded by the
941    /// schedule's own `tau_min` floor — never spins past it.
942    fn advance_temperature_to(schedule: &mut GumbelTemperatureSchedule, target_tau: f64) {
943        // Guard: a malformed schedule can't make progress; clamp to one step so
944        // the live τ is still the schedule's current value, never NaN.
945        let max_scan = temperature_scan_budget(schedule);
946        let mut scanned = 0;
947        while scanned < max_scan && schedule.current_tau(schedule.iter_count) > target_tau {
948            schedule.iter_count += 1;
949            scanned += 1;
950        }
951    }
952
953    /// Step `schedule.iter_count` forward until `current_weight` is ≥
954    /// `target_weight` (isometry weight is monotone non-decreasing in iter when
955    /// `w_end ≥ w_start`, the tightening direction). Bounded by `w_end`.
956    fn advance_isometry_to(schedule: &mut ScalarWeightSchedule, target_weight: f64) {
957        let max_scan = isometry_scan_budget(schedule);
958        let mut scanned = 0;
959        while scanned < max_scan && schedule.current_weight(schedule.iter_count) < target_weight {
960            schedule.iter_count += 1;
961            scanned += 1;
962        }
963    }
964}
965
966/// Scan budget for advancing the temperature schedule. For a `Linear` schedule
967/// the number of steps is known; for geometric / reciprocal it is bounded by a
968/// generous waypoint multiple so the lockstep scan always terminates.
969fn temperature_scan_budget(schedule: &GumbelTemperatureSchedule) -> usize {
970    const GEOMETRIC_SCAN_CAP: usize = 4096;
971    match &schedule.decay {
972        ScheduleKind::Linear { steps } => *steps + 1,
973        ScheduleKind::Geometric { .. } | ScheduleKind::ReciprocalIter => GEOMETRIC_SCAN_CAP,
974    }
975}
976
977/// Scan budget for advancing the isometry-weight schedule (mirrors
978/// [`temperature_scan_budget`]).
979fn isometry_scan_budget(schedule: &ScalarWeightSchedule) -> usize {
980    const GEOMETRIC_SCAN_CAP: usize = 4096;
981    match &schedule.kind {
982        ScheduleKind::Linear { steps } => *steps + 1,
983        ScheduleKind::Geometric { .. } | ScheduleKind::ReciprocalIter => GEOMETRIC_SCAN_CAP,
984    }
985}
986
987/// Convenience: build the standard coupled schedules for a K≥2 SAE joint fit
988/// from the ρ box and the τ / isometry schedules the term already carries.
989///
990/// `rho_target` is ρ\* (the real objective); `rho_entry` is the oversmoothed
991/// entry ρ₀ (caller supplies, or the spine derives its own offset on top —
992/// passing `rho_target` here lets the spine own the entire oversmoothing and
993/// the path simply rides at `s` along ρ\*). `rho_bounds_upper` is the legal box.
994#[must_use]
995pub fn couple_schedules(
996    rho_entry: Array1<f64>,
997    rho_target: Array1<f64>,
998    rho_bounds_upper: Array1<f64>,
999    temperature: GumbelTemperatureSchedule,
1000    isometry: ScalarWeightSchedule,
1001) -> CoupledSchedules {
1002    CoupledSchedules {
1003        rho_entry,
1004        rho_target,
1005        rho_bounds_upper,
1006        temperature,
1007        isometry,
1008    }
1009}
1010
1011/// Default coupled schedules for a no-argument [`ContinuationPath::heavy_entry`].
1012///
1013/// Builds the standard three legs at their smoothing-extreme entry values:
1014/// * ρ — a single-component oversmoothed entry `ρ₀` descending to `ρ* = 0`,
1015///   inside a generous legal box. The seed cascade's spine replaces this with
1016///   the real per-component ρ target at each waypoint, so the single component
1017///   here is only the entry placeholder.
1018/// * τ — the diffuse→sharp assignment-temperature leg (`DEFAULT_ENTRY_TAU` down
1019///   to `DEFAULT_TARGET_TAU`) over the standard waypoint count.
1020/// * isometry — the loose→tight gauge leg (`DEFAULT_ENTRY_ISOMETRY` up to the
1021///   tight target weight) over the same waypoint count.
1022///
1023/// These endpoints match the smoothing-extreme entry regime every leg is at at
1024/// `s = 1`; the path walks them down in lockstep exactly as a caller-supplied
1025/// [`CoupledSchedules`] would.
1026#[must_use]
1027fn default_coupled_schedules() -> CoupledSchedules {
1028    /// Oversmoothed entry ρ₀ for the single-component placeholder leg.
1029    const DEFAULT_ENTRY_RHO: f64 = 5.0;
1030    /// Legal ρ upper bound for the placeholder leg.
1031    const DEFAULT_RHO_UPPER: f64 = 10.0;
1032
1033    couple_schedules(
1034        Array1::from_elem(1, DEFAULT_ENTRY_RHO),
1035        Array1::zeros(1),
1036        Array1::from_elem(1, DEFAULT_RHO_UPPER),
1037        default_temperature_schedule(),
1038        default_isometry_schedule(),
1039    )
1040}
1041
1042/// The standard diffuse→sharp assignment-temperature leg (`DEFAULT_ENTRY_TAU`
1043/// down to `DEFAULT_TARGET_TAU`) over the standard waypoint count. Shared by
1044/// both [`ContinuationPath::heavy_entry`] and
1045/// [`ContinuationPath::heavy_entry_for_rho`] so the τ leg has one source.
1046#[must_use]
1047fn default_temperature_schedule() -> GumbelTemperatureSchedule {
1048    /// Diffuse entry τ (the schedule's `tau_start`) at `s = 1`. Entry
1049    /// heaviness is tied to the cold-seed logit scale: the production IBP
1050    /// residual-energy seed emits logits at gain `4.0`
1051    /// (`SAE_RESIDUAL_SEED_GAIN` in `gam-pyffi`), so seeded logits span
1052    /// roughly `±4`. Entry τ ≥ that gain keeps every seeded row in the
1053    /// near-linear band of the gate (`|logit|/τ ≤ 1`), where the assignment
1054    /// map is smooth and contractive — no row enters pre-saturated against
1055    /// the argmax cliff. The previous `2.0` entry let ±4-gain seeds start at
1056    /// `|logit|/τ = 2`, already in the saturated tail.
1057    const DEFAULT_ENTRY_TAU: f64 = 4.0;
1058    /// Target τ (`tau_min`) at `s = 0`. The `s = 0` endpoint of every leg
1059    /// must be the REAL objective's value, and the production IBP-MAP
1060    /// assignment temperature (gamfit `sae_manifold_fit`, `ibp_map` path) is
1061    /// `τ = 0.5`. The previous `0.1` target over-sharpened the leg *past*
1062    /// the real objective, which tightened the τ-tied logit trust region at
1063    /// arrival to radius `0.4` (vs `2.0` at the true operating τ) — choking
1064    /// exactly the late-walk logit growth the routing mass needs to climb
1065    /// from the diffuse entry to the planted level.
1066    const DEFAULT_TARGET_TAU: f64 = 0.5;
1067    GumbelTemperatureSchedule::new(
1068        DEFAULT_ENTRY_TAU,
1069        DEFAULT_TARGET_TAU,
1070        ScheduleKind::Linear {
1071            steps: CONTINUATION_WAYPOINTS,
1072        },
1073    )
1074    .expect("default continuation temperature schedule must be valid")
1075}
1076
1077/// The standard loose→tight isometry gauge leg (`DEFAULT_ENTRY_ISOMETRY` up to
1078/// `DEFAULT_TARGET_ISOMETRY`) over the standard waypoint count. Shared source
1079/// for the isometry leg across both heavy-entry constructors.
1080#[must_use]
1081fn default_isometry_schedule() -> ScalarWeightSchedule {
1082    /// Entry isometry weight (`w_start`) at `s = 1`; the chart pin starts fully
1083    /// off and ramps after the anchor has settled.
1084    const DEFAULT_ENTRY_ISOMETRY: f64 = 0.0;
1085    /// Tight target isometry weight (`w_end`) at `s = 0`.
1086    const DEFAULT_TARGET_ISOMETRY: f64 = 1.0;
1087    ScalarWeightSchedule::new(
1088        DEFAULT_ENTRY_ISOMETRY,
1089        DEFAULT_TARGET_ISOMETRY,
1090        ScheduleKind::Linear {
1091            steps: CONTINUATION_WAYPOINTS,
1092        },
1093    )
1094    .expect("default continuation isometry schedule must be valid")
1095}
1096
1097/// View helper: the wiring agent passes the SAE assignment matrix (rows ×
1098/// atoms) to compute the mean active mass for the [`ActiveMassFloor`] check.
1099/// Defined here so the floor's input convention has one owner.
1100#[must_use]
1101pub fn mean_active_mass(assignments: ArrayView2<'_, f64>) -> f64 {
1102    let n = assignments.nrows();
1103    if n == 0 {
1104        return 0.0;
1105    }
1106    // Per-row active mass = max assignment weight in the row (how concentrated
1107    // the routing is); the saddle is uniform (~1/K), a routed fit is ~1.
1108    let mut acc = 0.0;
1109    for row in assignments.rows() {
1110        let row_max = row.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1111        if row_max.is_finite() {
1112            acc += row_max;
1113        }
1114    }
1115    acc / n as f64
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121
1122    fn lin_temp() -> GumbelTemperatureSchedule {
1123        GumbelTemperatureSchedule::new(2.0, 0.1, ScheduleKind::Linear { steps: 8 })
1124            .expect("valid temperature schedule")
1125    }
1126
1127    fn lin_iso() -> ScalarWeightSchedule {
1128        ScalarWeightSchedule::new(0.01, 1.0, ScheduleKind::Linear { steps: 8 })
1129            .expect("valid isometry schedule")
1130    }
1131
1132    fn schedules() -> CoupledSchedules {
1133        couple_schedules(
1134            Array1::from_vec(vec![5.0, 5.0]),
1135            Array1::from_vec(vec![0.0, 0.0]),
1136            Array1::from_vec(vec![10.0, 10.0]),
1137            lin_temp(),
1138            lin_iso(),
1139        )
1140    }
1141
1142    #[test]
1143    fn entry_is_the_heavy_smoothing_regime() {
1144        let path = ContinuationPath::enter(schedules());
1145        assert_eq!(
1146            path.s(),
1147            1.0,
1148            "entry must be s = 1 (heavy-smoothing regime)"
1149        );
1150        let targets = path.current_scalar_targets();
1151        // τ at entry is the diffuse extreme (tau_start), isometry is loose
1152        // (w_start).
1153        assert!((targets.tau - 2.0).abs() < 1e-12, "entry τ = tau_start");
1154        assert!(
1155            (targets.isometry_weight - 0.01).abs() < 1e-12,
1156            "entry isometry = w_start"
1157        );
1158        // ρ target at s = 1 is the oversmoothed entry ρ₀.
1159        let rho = path.current_rho_target();
1160        assert!((rho[0] - 5.0).abs() < 1e-12 && (rho[1] - 5.0).abs() < 1e-12);
1161    }
1162
1163    #[test]
1164    fn target_endpoint_is_the_real_objective() {
1165        let sch = schedules();
1166        let targets0 = sch.scalar_targets_at(0.0);
1167        assert!(
1168            (targets0.tau - 0.1).abs() < 1e-12,
1169            "s=0 τ = tau_min (sharp)"
1170        );
1171        assert!(
1172            (targets0.isometry_weight - 1.0).abs() < 1e-12,
1173            "s=0 isometry = w_end (tight)"
1174        );
1175        let rho0 = sch.rho_target_at(0.0);
1176        assert!(
1177            (rho0[0]).abs() < 1e-12 && (rho0[1]).abs() < 1e-12,
1178            "s=0 ρ = ρ*"
1179        );
1180    }
1181
1182    #[test]
1183    fn legs_move_in_lockstep_along_s() {
1184        let sch = schedules();
1185        // Halfway down the path, every leg is halfway (in its natural coord)
1186        // between entry and target.
1187        let mid = sch.scalar_targets_at(0.5);
1188        assert!((mid.tau - (0.1 + 0.5 * (2.0 - 0.1))).abs() < 1e-12);
1189        assert!((mid.isometry_weight - (0.01 + 0.5 * (1.0 - 0.01))).abs() < 1e-12);
1190        let rho_mid = sch.rho_target_at(0.5);
1191        assert!((rho_mid[0] - 2.5).abs() < 1e-12);
1192    }
1193
1194    #[test]
1195    fn logit_trust_region_tightens_as_tau_cools() {
1196        let hot = LogitTrustRegion::for_tau(2.0);
1197        let cold = LogitTrustRegion::for_tau(0.05);
1198        assert!(
1199            cold.radius < hot.radius,
1200            "colder τ must give a tighter logit trust region"
1201        );
1202        // A step within the radius is applied unchanged.
1203        assert!(matches!(
1204            cold.cap_step(cold.radius * 0.5),
1205            LogitStepCap::Within
1206        ));
1207        // A step past the radius is scaled down, never rejected.
1208        match cold.cap_step(cold.radius * 4.0) {
1209            LogitStepCap::Scaled { scale } => {
1210                assert!(scale > 0.0 && scale < 1.0);
1211                assert!((scale - 0.25).abs() < 1e-12);
1212            }
1213            LogitStepCap::Within => panic!("expected the over-radius step to be scaled"),
1214        }
1215    }
1216
1217    #[test]
1218    fn active_mass_floor_breach_is_recorded_never_fatal() {
1219        let floor = ActiveMassFloor::default_floor();
1220        assert!(floor.check(0.9).is_none(), "healthy routing → no breach");
1221        let breach = floor.check(0.05).expect("collapsed routing → breach");
1222        let mut ledger = ReseedLedger::new();
1223        ledger.record(0.3, breach);
1224        assert_eq!(ledger.reseed_count(), 1);
1225        assert!((ledger.events()[0].s - 0.3).abs() < 1e-12);
1226    }
1227
1228    #[test]
1229    fn note_mass_breach_reenters_heavier_and_logs() {
1230        let mut path = ContinuationPath::enter(schedules());
1231        // Walk s down a bit first so a re-entry visibly raises it.
1232        path.s = 0.5;
1233        let mut ledger = ReseedLedger::new();
1234        let breach = MassFloorBreach {
1235            observed_mean_mass: 0.05,
1236            floor: ActiveMassFloor::DEFAULT_FLOOR,
1237        };
1238        let step = path.note_mass_breach(breach, &mut ledger);
1239        assert!(matches!(
1240            step,
1241            ContinuationStep::Reentered {
1242                reason: ReentryReason::MassFloorBreached(_),
1243                ..
1244            }
1245        ));
1246        assert!(
1247            path.s() > 0.5,
1248            "re-entry must raise s toward the entry regime"
1249        );
1250        assert_eq!(ledger.reseed_count(), 1);
1251    }
1252
1253    #[test]
1254    fn continuation_step_has_no_reject_arm() {
1255        // Compile-time + exhaustiveness witness: every ContinuationStep value
1256        // resolves to a heavier-regime re-entry. There is no rejection arm, so a
1257        // `match` over the enum cannot bind a "give up" case. If a Reject variant
1258        // were ever added, this match would fail to compile against the
1259        // documented invariant.
1260        fn is_progress(step: &ContinuationStep) -> bool {
1261            match step {
1262                ContinuationStep::Descended { .. }
1263                | ContinuationStep::Arrived { .. }
1264                | ContinuationStep::Reentered { .. } => true,
1265            }
1266        }
1267        let breach = MassFloorBreach {
1268            observed_mean_mass: 0.0,
1269            floor: 0.2,
1270        };
1271        assert!(is_progress(&ContinuationStep::Reentered {
1272            s: 1.0,
1273            reason: ReentryReason::MassFloorBreached(breach),
1274        }));
1275        assert!(is_progress(&ContinuationStep::Reentered {
1276            s: 1.0,
1277            reason: ReentryReason::StepUnderflow,
1278        }));
1279    }
1280
1281    #[test]
1282    fn mean_active_mass_distinguishes_routed_from_saddle() {
1283        use ndarray::array;
1284        // Two rows, K=2. Routed: one weight near 1. Saddle: uniform 0.5.
1285        let routed = array![[0.95, 0.05], [0.9, 0.1]];
1286        let saddle = array![[0.5, 0.5], [0.5, 0.5]];
1287        assert!(mean_active_mass(routed.view()) > 0.85);
1288        assert!((mean_active_mass(saddle.view()) - 0.5).abs() < 1e-12);
1289        assert!(
1290            ActiveMassFloor::default_floor()
1291                .check(mean_active_mass(saddle.view()))
1292                .is_none(),
1293            "uniform 0.5 is above the floor — saddle detection is about \
1294             collapse below the failure boundary (0.5× the planted healthy \
1295             mass), not the healthy operating point"
1296        );
1297    }
1298
1299    #[test]
1300    fn heavy_entry_starts_in_the_heavy_regime() {
1301        let path = ContinuationPath::heavy_entry();
1302        assert_eq!(path.s(), 1.0, "heavy_entry must enter at s = 1");
1303        assert_eq!(
1304            path.enter_regime(),
1305            PathRegime::Heavy,
1306            "a fresh heavy_entry is in the heavy-smoothing regime"
1307        );
1308        assert!(
1309            path.logit_step_radius().is_finite() && path.logit_step_radius() > 0.0,
1310            "logit step radius must be finite and positive at entry"
1311        );
1312    }
1313
1314    #[test]
1315    fn demote_with_reason_reenters_heavier_never_rejects() {
1316        let mut path = ContinuationPath::heavy_entry();
1317        // Walk down so a demotion visibly raises s back toward the entry regime.
1318        path.s = 0.3;
1319        path.s_step = 0.1;
1320        let before = path.s;
1321        let regime = path.demote_with_reason(PathDemotionReason::UniformStructural);
1322        assert!(
1323            path.s > before,
1324            "demotion must raise s toward the entry regime"
1325        );
1326        // The returned regime is the coarse band of the (heavier) live s.
1327        assert_eq!(regime, path.enter_regime());
1328        // A second reason demotes the same way — reason-agnostic escalation.
1329        let regime2 = path.demote_with_reason(PathDemotionReason::PrewarmStructural);
1330        assert_eq!(regime2, path.enter_regime());
1331        assert!(path.s >= before, "repeated demotions never lower s");
1332    }
1333
1334    #[test]
1335    fn bare_active_mass_breach_records_and_reenters() {
1336        let mut path = ContinuationPath::heavy_entry();
1337        path.s = 0.4;
1338        assert_eq!(path.reseed_count(), 0);
1339        let before = path.s;
1340        let regime = path.note_active_mass_breach();
1341        assert_eq!(
1342            path.reseed_count(),
1343            1,
1344            "breach must be recorded in the path ledger"
1345        );
1346        assert!(path.s > before, "breach must re-enter a heavier regime");
1347        assert_eq!(regime, path.enter_regime());
1348    }
1349
1350    #[test]
1351    fn path_regime_bands_are_monotone_in_s() {
1352        assert_eq!(PathRegime::from_s(0.0), PathRegime::Target);
1353        assert_eq!(PathRegime::from_s(0.2), PathRegime::Target);
1354        assert_eq!(PathRegime::from_s(0.5), PathRegime::Annealing);
1355        assert_eq!(PathRegime::from_s(0.9), PathRegime::Heavy);
1356        assert_eq!(PathRegime::from_s(1.0), PathRegime::Heavy);
1357    }
1358
1359    #[test]
1360    fn reentry_floors_step_but_never_exits() {
1361        let mut path = ContinuationPath::enter(schedules());
1362        path.s = 0.5;
1363        // Force many re-entries; s_step must floor, s stays in [0,1], and the
1364        // path never produces a non-progress outcome.
1365        for _ in 0..50 {
1366            path.reenter_heavier();
1367            assert!(path.s_step >= S_STEP_FLOOR);
1368            assert!((0.0..=1.0).contains(&path.s));
1369        }
1370    }
1371}