Skip to main content

gam_solve/
loop_guard.rs

1//! Certified termination (#968): ONE exhaustion/stagnation policy for
2//! every damped inner loop.
3//!
4//! # The bug genus this kills
5//!
6//! Every hang in the tracker's history (#874, #789, #683, #744, the
7//! survival-AFT cluster, #826's 42-minute frozen-residual stall) traces to
8//! the same structural flaw: termination safety was a per-branch,
9//! hand-replicated convention. The #874 postmortem is the canonical
10//! specimen — the LM *gain-reject* branch lacked the exhaustion guard its
11//! sibling *screening-reject* branch in the SAME file already had. Guard
12//! drift between sibling branches is the control-flow twin of the
13//! objective↔gradient desync class, and the cure is the same: a single
14//! source of truth that branches consume and cannot locally re-derive.
15//!
16//! # The policy pieces
17//!
18//! [`madsen_can_retry`] / [`madsen_retry_exhausted`] own the damped-retry
19//! exhaustion question for Madsen-style Levenberg–Marquardt loops: a retry
20//! is alive while the damping is finite and below [`MADSEN_DAMPING_CAP`],
21//! and dead once attempts run out or damping leaves that window. Both
22//! engines (reweight.rs Madsen-LM and the custom_family.rs spectral
23//! Newton) must answer this question through these functions — never
24//! through a local predicate.
25//!
26//! [`IterationBound`] and [`RejectEscalator`] are the two *distinct*
27//! safety mechanisms of an unbounded damped-retry loop, kept as two types
28//! on purpose. The bound owns the per-iteration hard count: it ticks once
29//! at the top of EVERY pass — including `continue` paths that neither
30//! accept a step nor reach a reject ritual (Fisher fallback, special
31//! cases) — and is the net that makes an unbounded `loop {}` safe. The
32//! escalator owns the geometric damping discipline applied on REJECTS
33//! only. A single type coupling "count++" to "reject" would either
34//! double-count iterations or silently assume every non-accepting pass
35//! reaches a reject ritual — the exact unbounded-loop hole the guard
36//! exists to close (see the #968 thread's design note).
37//!
38//! [`FlatStreak`] owns the consecutive-window discipline every stagnation
39//! detector shares: a streak that grows on "flat" readings, resets on
40//! recovery, and fires once it spans the window. Loops that own a
41//! scale-aware flatness predicate of their own (the custom_family
42//! joint-Newton objective-flat counter, the blockwise frozen-loglik
43//! divergence detector) consume it directly — they answer the question
44//! attempt caps cannot see: a loop that still "makes progress" every
45//! iteration but whose MERIT is frozen. #744 ran to cycle 1199/1200 at a
46//! flat residual; #826 burned a CI timeout on a frozen joint residual. The
47//! caller feeds its descent quantity (penalized NLL, residual norm, |g|)
48//! through its own flatness predicate once per iteration; the streak
49//! reports a plateau once flat readings span a consecutive window — long
50//! before any iteration cap.
51//!
52//! # Verdicts, not panics
53//!
54//! Exhaustion is an escalation event: the consuming loop converts
55//! [`LoopVerdict::Plateaued`] / [`LoopVerdict::Exhausted`] into its
56//! honest terminal status (`StalledAtValidMinimum`,
57//! `LmStepSearchExhausted`, …) and unwinds. Never a hang, never a panic,
58//! never a silent wrong answer.
59//!
60//! # Migration map (each step deleted a hand-rolled guard)
61//!
62//! 1. (done) reweight.rs `lm_can_retry`/`lm_retry_exhausted` local fns +
63//!    the local `LM_MAX_LAMBDA` const deleted; call sites consume this
64//!    module's policy.
65//! 2. (done) The 7 copies of the reweight.rs reject ritual
66//!    (`loop_lambda *= factor; factor *= 2.0; continue`) collapsed onto
67//!    [`RejectEscalator::escalate`], and the per-iteration hard count
68//!    moved into [`IterationBound`], so neither discipline can drift
69//!    per-branch.
70//! 3. (done) custom_family.rs: the joint-Newton objective-flat counter
71//!    and the blockwise frozen-loglik divergence streak both ride
72//!    [`FlatStreak`] — the #826-class exit discipline now lives here, not
73//!    in per-loop counters. The richer certificate machinery those loops
74//!    layer on top (geometric-tail bound, clamped-step side condition)
75//!    stays local: it is *policy about what counts as flat*, which the
76//!    loops rightly own; the streak/window discipline is what must not
77//!    fork.
78//! 4. (dropped) Terminal-verdict reporting into heartbeat scopes: the
79//!    `[JN-EXIT]`/`[PIRLS]` per-exit log lines already name why a loop
80//!    ended; a parallel verdict channel in the process monitor would be
81//!    redundant global state.
82
83/// Damping ceiling for Madsen-style LM retries. Beyond this the proposed
84/// step is numerically a zero step — retrying cannot make progress, so the
85/// retry chain is declared dead. (Moved verbatim from reweight.rs, where it
86/// was a file-local convention; see module docs for why it must be shared.)
87pub const MADSEN_DAMPING_CAP: f64 = 1e12;
88
89/// Is a damped retry still alive at this damping level?
90#[inline]
91pub fn madsen_can_retry(damping: f64) -> bool {
92    damping.is_finite() && damping < MADSEN_DAMPING_CAP
93}
94
95/// Has the retry chain exhausted its budget — by attempt count or by the
96/// damping leaving the productive window?
97#[inline]
98pub fn madsen_retry_exhausted(damping: f64, attempts: usize, max_attempts: usize) -> bool {
99    attempts >= max_attempts || !damping.is_finite() || damping > MADSEN_DAMPING_CAP
100}
101
102/// Terminal verdict of a guarded loop. `Continue` is the only
103/// non-terminal answer; the two terminal verdicts are ESCALATION events
104/// the consumer must convert into an honest status, never swallow.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum LoopVerdict {
107    Continue,
108    /// The merit stream is frozen: stop and report the current iterate as
109    /// the honest answer (StalledAtValidMinimum if KKT-near, else a named
110    /// stall) instead of grinding out the remaining budget (#744, #826).
111    Plateaued,
112    /// Attempts or damping window exhausted (#874's missing branch guard).
113    Exhausted,
114}
115
116/// Consecutive-flatness streak: the window discipline shared by every
117/// stagnation detector in the tree. The caller owns the flatness
118/// predicate (scale-aware objective tolerance, frozen log-likelihood,
119/// sub-tolerance relative improvement, …); this type owns the part that
120/// historically forked per loop — grow on flat, reset on recovery, fire
121/// once the streak spans the window, and keep firing while it persists.
122#[derive(Clone, Debug)]
123pub struct FlatStreak {
124    window: usize,
125    streak: usize,
126}
127
128impl FlatStreak {
129    pub fn new(window: usize) -> Self {
130        Self {
131            window: window.max(1),
132            streak: 0,
133        }
134    }
135
136    /// Record one pre-judged flatness reading; returns the current verdict.
137    pub fn note(&mut self, flat: bool) -> LoopVerdict {
138        if flat {
139            self.streak += 1;
140            if self.streak >= self.window {
141                return LoopVerdict::Plateaued;
142            }
143        } else {
144            self.streak = 0;
145        }
146        LoopVerdict::Continue
147    }
148
149    /// Hard reset, e.g. after a non-finite merit re-baselines the stream.
150    pub fn reset(&mut self) {
151        self.streak = 0;
152    }
153
154    /// Current consecutive-flat count (diagnostic; the verdict is the
155    /// contract).
156    pub fn streak(&self) -> usize {
157        self.streak
158    }
159}
160
161/// Per-iteration hard bound for a damped retry loop: the net that makes
162/// an unbounded `loop {}` safe. Tick it once at the top of EVERY pass —
163/// accepted, rejected, or any `continue` path that reaches neither — and
164/// ask [`IterationBound::exhausted_at`] wherever the loop's exhaustion
165/// question is posed. Created fresh per outer iteration.
166#[derive(Clone, Debug)]
167pub struct IterationBound {
168    used: usize,
169    max: usize,
170}
171
172impl IterationBound {
173    pub fn new(max: usize) -> Self {
174        Self {
175            used: 0,
176            max: max.max(1),
177        }
178    }
179
180    /// Count one loop pass. Top-of-loop, unconditionally.
181    pub fn tick(&mut self) {
182        self.used += 1;
183    }
184
185    /// Passes counted so far (diagnostics: `last_step_halving`, logs).
186    pub fn used(&self) -> usize {
187        self.used
188    }
189
190    /// The configured cap (diagnostics).
191    pub fn max(&self) -> usize {
192        self.max
193    }
194
195    /// Has the pass count alone exhausted the budget?
196    pub fn count_exhausted(&self) -> bool {
197        self.used >= self.max
198    }
199
200    /// The single exhaustion question: count OR damping window
201    /// ([`madsen_retry_exhausted`], answered from owned state).
202    pub fn exhausted_at(&self, damping: f64) -> bool {
203        madsen_retry_exhausted(damping, self.used, self.max)
204    }
205
206}
207
208/// Initial damping multiplier on the first rejection of an iteration.
209/// Doubles on every further rejection (geometric escalation), reaching
210/// [`MADSEN_DAMPING_CAP`] from λ = 1 in ~12 rejections — the established
211/// reweight.rs schedule, now owned here.
212pub const MADSEN_INITIAL_REJECT_FACTOR: f64 = 2.0;
213
214/// Geometric damping escalator for one reject chain
215/// (Madsen–Nielsen–Tingleff eq 3.16: the multiplier starts at 2 and
216/// doubles on every rejection, so successive bumps are ×2, ×4, ×8, …).
217/// Owns the factor and the reject count as one indivisible discipline —
218/// no branch can bump the damping without advancing the schedule, the
219/// drift mode behind #874. Deliberately does NOT own the per-iteration
220/// count; that is [`IterationBound`]'s job (see module docs for why the
221/// two must not be one type).
222#[derive(Clone, Debug)]
223pub struct RejectEscalator {
224    factor: f64,
225    rejects: usize,
226}
227
228impl Default for RejectEscalator {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl RejectEscalator {
235    pub fn new() -> Self {
236        Self {
237            factor: MADSEN_INITIAL_REJECT_FACTOR,
238            rejects: 0,
239        }
240    }
241
242    /// Record a rejection: bumps the damping and advances the geometric
243    /// schedule in one indivisible step.
244    pub fn escalate(&mut self, damping: &mut f64) {
245        *damping *= self.factor;
246        self.factor *= 2.0;
247        self.rejects += 1;
248    }
249
250    /// Restart the schedule — the problem changed under the chain (e.g. a
251    /// Fisher fallback swapped the Hessian curvature), so the trajectory
252    /// begins anew. Pairs with the caller resetting its damping baseline.
253    pub fn restart(&mut self) {
254        self.factor = MADSEN_INITIAL_REJECT_FACTOR;
255        self.rejects = 0;
256    }
257
258    /// Rejections recorded since construction/restart (diagnostics).
259    pub fn rejects(&self) -> usize {
260        self.rejects
261    }
262}
263
264/// Convergence-truthfulness invariant for an inner-solve terminal verdict
265/// (gam#1040).
266///
267/// An inner Newton/PIRLS solve may only report `converged = true` if it
268/// actually certified a stationarity point on a FINITE residual. A
269/// certificate exit that fires on a cycle where the head-of-cycle KKT norm was
270/// non-finite (so the running `min_certified_residual` is left at its `inf`
271/// sentinel) would otherwise emit `converged=true … best_residual_inf=inf` — a
272/// self-contradicting status: a convergence claim with no finite residual
273/// behind it. This predicate is the single source of truth for that gate:
274/// `converged` survives iff a finite certified residual is on record. When it
275/// returns `false` while the solver believed it converged, the caller must
276/// downgrade to non-converged so the outer optimizer rejects the evaluation
277/// rather than consuming a phantom optimum.
278#[inline]
279pub fn inner_convergence_is_truthful(converged: bool, min_certified_residual: f64) -> bool {
280    !converged || min_certified_residual.is_finite()
281}
282
283/// Deterministic slow-geometric-rate stall predicate (gam#979 survival
284/// marginal-slope hang).
285///
286/// The survival marginal-slope oversmoothed-ρ endgame produces a stiff
287/// penalized Hessian (penalty dominates, eigenvalues ~1e6) whose Newton steps
288/// are ~1e-5 far INSIDE a large trust radius, so the inner KKT residual
289/// descends geometrically but very slowly (~0.99×/cycle, halving only every
290/// ~80 cycles). That is neither divergence nor a flat stall: the residual is
291/// genuinely shrinking, just far too slowly to reach `residual_tol` in a
292/// practical cycle count — so the flat-residual no-improve guard never latches
293/// (the residual clears its 10% bar every ~12 cycles) and the loop grinds ~10³
294/// cycles at ~p³ each, the measured #979 "hang".
295///
296/// Given the residual `window_oldest` cycles `window_cycles` ago and the
297/// `current` residual, this projects — from the per-cycle geometric rate
298/// `(current/window_oldest)^(1/window_cycles)` — how many additional cycles
299/// reaching `residual_tol` would take, and returns `true` when that exceeds
300/// `projection_cap` (i.e. the ρ-evaluation cannot finish in a practical
301/// budget). It is FULLY DETERMINISTIC: cycle indices and residual ratios only,
302/// no wall-clock. It also returns `true` when the window shows no net
303/// geometric progress at all (rate ≥ 1, or the window did not shrink), which
304/// likewise cannot reach tol.
305///
306/// A healthy (quadratically / fast-geometrically converging) solve reaches tol
307/// in a handful of cycles and never fills the window, and even when it does the
308/// projected remaining cycles are tiny, so this never fires on it. The caller
309/// uses a `true` verdict to stop with the current finite β as `converged=false`
310/// so the outer optimizer rejects this ρ and moves on; it certifies nothing and
311/// so cannot bias the envelope gradient.
312#[inline]
313pub fn slow_geometric_rate_exceeds_projection_cap(
314    current: f64,
315    window_oldest: f64,
316    window_cycles: usize,
317    residual_tol: f64,
318    projection_cap: usize,
319) -> bool {
320    if window_cycles == 0 {
321        return false;
322    }
323    if !current.is_finite() || current <= residual_tol {
324        // Either non-finite (a different guard owns that) or already at/under
325        // tol (the convergence certificate owns that): not a slow-rate stall.
326        return false;
327    }
328    if !window_oldest.is_finite() || window_oldest <= 0.0 || current >= window_oldest {
329        // No net geometric progress across the whole window: cannot reach tol.
330        return true;
331    }
332    let rate = (current / window_oldest).powf(1.0 / (window_cycles as f64));
333    if !rate.is_finite() || rate >= 1.0 {
334        return true;
335    }
336    let projected_cycles = (residual_tol / current).ln() / rate.ln();
337    projected_cycles.is_finite() && projected_cycles > projection_cap as f64
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    /// #874 regression shape: a reject storm must reach the damping
345    /// ceiling in a bounded number of escalations no matter which branch
346    /// asks — the escalator owns the schedule, the predicates own the
347    /// window.
348    #[test]
349    fn reject_storm_exhausts_in_bounded_steps() {
350        let mut esc = RejectEscalator::new();
351        let mut damping = 1.0;
352        let mut steps = 0usize;
353        while madsen_can_retry(damping) {
354            esc.escalate(&mut damping);
355            steps += 1;
356            assert!(steps <= 64, "escalation must reach the damping cap");
357        }
358        // Geometric doubling of the factor reaches 1e12 in ~9 escalations.
359        assert!(steps <= 16, "escalation took {steps} steps");
360        assert_eq!(esc.rejects(), steps);
361        assert!(madsen_retry_exhausted(damping, 0, usize::MAX));
362    }
363
364    /// And the dual: escalations do NOT advance the iteration bound on
365    /// their own — collapsing the rituals onto the escalator must not
366    /// double-count attempts against the per-iteration budget.
367    #[test]
368    fn escalations_do_not_double_count_iterations() {
369        let mut bound = IterationBound::new(10);
370        let mut esc = RejectEscalator::new();
371        let mut damping = 1.0;
372        bound.tick();
373        for _ in 0..3 {
374            esc.escalate(&mut damping);
375        }
376        assert_eq!(bound.used(), 1);
377        assert_eq!(esc.rejects(), 3);
378        assert!(!bound.count_exhausted());
379    }
380
381    #[test]
382    fn restart_rewinds_the_geometric_schedule() {
383        let mut esc = RejectEscalator::new();
384        let mut damping = 1.0;
385        esc.escalate(&mut damping); // ×2
386        esc.escalate(&mut damping); // ×4
387        assert_eq!(damping, 8.0);
388        esc.restart();
389        assert_eq!(esc.rejects(), 0);
390        let mut fresh = 1.0;
391        esc.escalate(&mut fresh);
392        assert_eq!(
393            fresh, MADSEN_INITIAL_REJECT_FACTOR,
394            "schedule restarts at ×2"
395        );
396    }
397
398    /// The streak discipline alone (caller-owned flatness predicate, the
399    /// custom_family consumption shape): grows on flat, resets on
400    /// recovery, fires at the window and keeps firing while flat.
401    #[test]
402    fn flat_streak_pins_the_window_discipline() {
403        let mut streak = FlatStreak::new(3);
404        assert_eq!(streak.note(true), LoopVerdict::Continue); // 1
405        assert_eq!(streak.note(true), LoopVerdict::Continue); // 2
406        assert_eq!(streak.note(false), LoopVerdict::Continue); // reset
407        assert_eq!(streak.streak(), 0);
408        assert_eq!(streak.note(true), LoopVerdict::Continue); // 1
409        assert_eq!(streak.note(true), LoopVerdict::Continue); // 2
410        assert_eq!(streak.note(true), LoopVerdict::Plateaued); // 3 fires
411        assert_eq!(streak.note(true), LoopVerdict::Plateaued); // persists
412        assert_eq!(streak.streak(), 4);
413    }
414
415    /// A certificate exit must never report `converged=true` while the only
416    /// residual on record is the non-finite `inf` sentinel — the gam#1040
417    /// inner-report truthfulness violation. The predicate downgrades exactly
418    /// that case and leaves every genuinely-certified exit untouched.
419    #[test]
420    fn inner_convergence_truthfulness_rejects_converged_with_nonfinite_residual() {
421        // converged with a finite certified residual: honest, survives.
422        assert!(inner_convergence_is_truthful(true, 8.0e-6));
423        assert!(inner_convergence_is_truthful(true, 0.0));
424        // converged with NO finite certified residual (the cycle-1 certificate
425        // exit symptom: best_residual_inf=inf): a truthfulness violation.
426        assert!(!inner_convergence_is_truthful(true, f64::INFINITY));
427        assert!(!inner_convergence_is_truthful(true, f64::NAN));
428        // non-converged exits are always truthful regardless of the residual
429        // sentinel — the report says "not converged", no contradiction.
430        assert!(inner_convergence_is_truthful(false, f64::INFINITY));
431        assert!(inner_convergence_is_truthful(false, 1.0e-3));
432    }
433
434    /// The shared predicates pin the exact reweight.rs semantics they
435    /// replaced (finite + strictly-below cap to retry; count OR window
436    /// exit to exhaust).
437    #[test]
438    fn policy_predicates_pin_the_reweight_semantics() {
439        assert!(madsen_can_retry(1e11));
440        assert!(!madsen_can_retry(MADSEN_DAMPING_CAP));
441        assert!(!madsen_can_retry(f64::INFINITY));
442        assert!(madsen_retry_exhausted(1.0, 5, 5));
443        assert!(madsen_retry_exhausted(f64::NAN, 0, 5));
444        assert!(madsen_retry_exhausted(1e13, 0, 5));
445        assert!(!madsen_retry_exhausted(1.0, 4, 5));
446    }
447
448    /// gam#979 survival marginal-slope: the slow-geometric-rate stall guard must
449    /// TERMINATE the inner joint-Newton in a bounded number of cycles on the
450    /// oversmoothed-ρ endgame (a residual crawling down by a fixed small factor
451    /// ~0.99×/cycle that would otherwise grind ~10³ cycles to the budget — the
452    /// measured hang) WITHOUT firing on a healthy fast-geometric solve. This
453    /// replays the production loop's window bookkeeping (the trailing window of
454    /// the last `LINEAR_RATE_WINDOW` post-step residuals, the guard armed only
455    /// after `MIN_CYCLES`) over a deterministic residual stream and asserts a
456    /// finite, bounded exit cycle — an iteration-count assertion, not a
457    /// wall-clock threshold.
458    #[test]
459    fn slow_geometric_stall_guard_terminates_in_bounded_cycles_979() {
460        // Mirror the production constants in inner_blockwise_fit.rs.
461        const LINEAR_RATE_WINDOW: usize = 16;
462        const LINEAR_RATE_PROJECTION_CAP: usize = 100;
463        const RESIDUAL_STALL_MIN_CYCLES: usize = 40;
464        // A representative inner cycle budget; the guard must exit FAR below it.
465        const INNER_BUDGET: usize = 1000;
466        let residual_tol = 1e-6_f64;
467
468        // Replay the production window: a VecDeque holding at most
469        // LINEAR_RATE_WINDOW+1 residuals (front = residual LINEAR_RATE_WINDOW
470        // cycles back), the guard armed only at/after MIN_CYCLES and once the
471        // window is full.
472        fn run_stream(
473            per_cycle_rate: f64,
474            residual_tol: f64,
475            window: usize,
476            min_cycles: usize,
477            cap: usize,
478            budget: usize,
479        ) -> (Option<usize>, bool) {
480            let mut history: std::collections::VecDeque<f64> =
481                std::collections::VecDeque::with_capacity(window + 1);
482            let mut residual = 1.0_f64; // start well above tol
483            let mut reached_tol = false;
484            for cycle in 0..budget {
485                // A genuine convergence certificate would have exited already.
486                if residual <= residual_tol {
487                    reached_tol = true;
488                    return (None, reached_tol);
489                }
490                if history.len() > window {
491                    history.pop_front();
492                }
493                history.push_back(residual);
494                if cycle + 1 >= min_cycles && history.len() > window {
495                    let oldest = *history.front().unwrap();
496                    if slow_geometric_rate_exceeds_projection_cap(
497                        residual,
498                        oldest,
499                        window,
500                        residual_tol,
501                        cap,
502                    ) {
503                        return (Some(cycle + 1), reached_tol);
504                    }
505                }
506                residual *= per_cycle_rate;
507            }
508            (None, reached_tol)
509        }
510
511        // 1) The #979 hang signature: ~0.99×/cycle. Reaching 1e-6 from 1.0 at
512        //    0.99×/cycle would take ~1375 cycles (> the 1000 budget) — a hang.
513        //    The guard must fire, and bounded: just past MIN_CYCLES once the
514        //    window first fills, NOT at the budget.
515        let (slow_exit, slow_reached) = run_stream(
516            0.99,
517            residual_tol,
518            LINEAR_RATE_WINDOW,
519            RESIDUAL_STALL_MIN_CYCLES,
520            LINEAR_RATE_PROJECTION_CAP,
521            INNER_BUDGET,
522        );
523        assert!(
524            !slow_reached,
525            "the slow-geometric stream must not reach tol within budget (it is the hang)"
526        );
527        let slow_exit =
528            slow_exit.expect("slow-geometric stall guard must fire, not grind to the budget");
529        assert!(
530            slow_exit < INNER_BUDGET / 4,
531            "guard must terminate well below the {INNER_BUDGET}-cycle budget, fired at {slow_exit}"
532        );
533        // It can only arm at MIN_CYCLES, so the exit is bounded from both sides.
534        assert!(
535            slow_exit >= RESIDUAL_STALL_MIN_CYCLES,
536            "guard must not fire before it is armed (MIN_CYCLES={RESIDUAL_STALL_MIN_CYCLES})"
537        );
538
539        // 2) A healthy fast-geometric solve (~0.3×/cycle) reaches tol in a
540        //    handful of cycles and NEVER reaches the armed window — the guard
541        //    must never fire on it.
542        let (fast_exit, fast_reached) = run_stream(
543            0.3,
544            residual_tol,
545            LINEAR_RATE_WINDOW,
546            RESIDUAL_STALL_MIN_CYCLES,
547            LINEAR_RATE_PROJECTION_CAP,
548            INNER_BUDGET,
549        );
550        assert!(
551            fast_reached,
552            "a fast-geometric solve must reach tol (healthy convergence)"
553        );
554        assert!(
555            fast_exit.is_none(),
556            "the slow-rate guard must NEVER fire on a healthy fast-geometric solve"
557        );
558
559        // 3) Direct predicate properties at the boundary.
560        // No net progress across the window => fire (cannot reach tol).
561        assert!(slow_geometric_rate_exceeds_projection_cap(
562            1.0,
563            1.0,
564            LINEAR_RATE_WINDOW,
565            residual_tol,
566            LINEAR_RATE_PROJECTION_CAP
567        ));
568        // Residual already at/under tol => never fire (certificate owns it).
569        assert!(!slow_geometric_rate_exceeds_projection_cap(
570            1e-7,
571            1.0,
572            LINEAR_RATE_WINDOW,
573            residual_tol,
574            LINEAR_RATE_PROJECTION_CAP
575        ));
576        // A brisk window (0.5×/cycle over 16 cycles, residual 1e-3) projects to
577        // only ~10 more cycles to tol => never fire.
578        let brisk_oldest = 1e-3 / 0.5_f64.powi(LINEAR_RATE_WINDOW as i32);
579        assert!(!slow_geometric_rate_exceeds_projection_cap(
580            1e-3,
581            brisk_oldest,
582            LINEAR_RATE_WINDOW,
583            residual_tol,
584            LINEAR_RATE_PROJECTION_CAP
585        ));
586    }
587
588    /// #979 CTN endgame regression: the counter-based stall guards in
589    /// `inner_blockwise_fit.rs` count a cycle as "no improvement" unless the
590    /// residual drops ≥10% below the best seen, so a slow monotone endgame
591    /// walking the last few percent toward tol (measured: r=9.05e-3 vs
592    /// tol=9.56e-3, killed at cycle 123 when the 120-cycle merit-veto cap
593    /// expired) accumulates a no-improve streak indistinguishable from a flat
594    /// stall. The fix defers those guards to the deterministic reachability
595    /// projection. This test replays the production counter + window
596    /// bookkeeping over both shapes and asserts the projection separates them:
597    /// the endgame stream is classified reachable (guards exempt, solve
598    /// certifies) even after the no-improve streak passes the veto cap, while
599    /// a genuinely flat stream past tol stays unreachable (guards fire).
600    #[test]
601    fn endgame_descent_is_reachable_while_flat_stall_is_not_979() {
602        const LINEAR_RATE_WINDOW: usize = 16;
603        const LINEAR_RATE_PROJECTION_CAP: usize = 100;
604        const RESIDUAL_STALL_NO_IMPROVE_CYCLES: usize = 30;
605        const RESIDUAL_STALL_MERIT_VETO_MAX_CYCLES: usize = 4 * RESIDUAL_STALL_NO_IMPROVE_CYCLES;
606        const RESIDUAL_STALL_IMPROVEMENT_FACTOR: f64 = 0.9;
607        let residual_tol = 9.557e-3_f64;
608
609        // Replay: per-cycle residual stream -> (max no-improve streak reached
610        // while the projection classified tol as UNREACHABLE, cycle tol reached).
611        let replay = |stream: &dyn Fn(usize) -> f64, budget: usize| -> (usize, Option<usize>) {
612            let mut history: std::collections::VecDeque<f64> =
613                std::collections::VecDeque::with_capacity(LINEAR_RATE_WINDOW + 1);
614            let mut best_seen = f64::INFINITY;
615            let mut no_improve = 0usize;
616            let mut worst_unreachable_streak = 0usize;
617            for cycle in 0..budget {
618                let residual = stream(cycle);
619                if residual <= residual_tol {
620                    return (worst_unreachable_streak, Some(cycle));
621                }
622                if residual < RESIDUAL_STALL_IMPROVEMENT_FACTOR * best_seen {
623                    best_seen = residual;
624                    no_improve = 0;
625                } else {
626                    no_improve += 1;
627                }
628                if history.len() > LINEAR_RATE_WINDOW {
629                    history.pop_front();
630                }
631                history.push_back(residual);
632                let reachable = history.len() > LINEAR_RATE_WINDOW
633                    && !slow_geometric_rate_exceeds_projection_cap(
634                        residual,
635                        *history.front().unwrap(),
636                        LINEAR_RATE_WINDOW,
637                        residual_tol,
638                        LINEAR_RATE_PROJECTION_CAP,
639                    );
640                if !reachable && no_improve > worst_unreachable_streak {
641                    worst_unreachable_streak = no_improve;
642                }
643            }
644            (worst_unreachable_streak, None)
645        };
646
647        // CTN endgame shape: an asymptotic terminal approach — the residual
648        // decays toward a level just BELOW tol (ratio → 1 from above), so the
649        // per-cycle drops shrink and the last ≥10% improvement happens ~80
650        // cycles before certification. The no-improve streak at the crossing
651        // (~84) is far past the 30-cycle guard window, so the pre-fix guards
652        // kill this solve; the projection must classify every armed cycle as
653        // reachable, exempting the guards until certification.
654        let endgame = |cycle: usize| residual_tol * (0.98 + 0.52 * 0.98_f64.powi(cycle as i32));
655        let (endgame_streak, endgame_reached) = replay(&endgame, 400);
656        let reached =
657            endgame_reached.expect("the endgame stream must certify within its projection");
658        assert!(
659            reached > RESIDUAL_STALL_MERIT_VETO_MAX_CYCLES,
660            "fixture must genuinely outlive the veto cap to exercise the kill site \
661             (reached tol at cycle {reached})"
662        );
663        assert!(
664            endgame_streak < RESIDUAL_STALL_NO_IMPROVE_CYCLES,
665            "a monotone endgame descent must never accumulate a guard-firing no-improve \
666             streak while the projection says unreachable (worst streak {endgame_streak})"
667        );
668
669        // Genuinely flat stall well above tol: the projection must classify it
670        // unreachable and let the no-improve streak arm the guards.
671        let flat = |_: usize| 150.0 * residual_tol;
672        let (flat_streak, flat_reached) = replay(&flat, 400);
673        assert!(flat_reached.is_none(), "the flat stream must never certify");
674        assert!(
675            flat_streak >= RESIDUAL_STALL_MERIT_VETO_MAX_CYCLES,
676            "a flat residual must keep arming the stall guards through the veto cap \
677             (worst unreachable streak {flat_streak})"
678        );
679    }
680}