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}