Skip to main content

gam_models/inference/
full_conformal.rs

1//! Exact full-conformal prediction for penalized GAMs — including the
2//! smoothing-parameter response (#942).
3//!
4//! # What this is
5//!
6//! Split conformal (src/inference/conformal.rs) buys finite-sample coverage
7//! by sacrificing data to a calibration fold. FULL conformal uses every
8//! observation for both fitting and calibration: for a candidate response
9//! value `z` at the test covariates `x_*`, fit the model to the AUGMENTED
10//! data `{(x_i, y_i)}_{i=1..n} ∪ {(x_*, z)}`, score every point with the
11//! refit, and keep `z` in the prediction set iff the test point's
12//! nonconformity score is not extreme among all n+1:
13//!
14//! ```text
15//!   e_i(z) = |y_i − μ̂^z(x_i)| ,  e_*(z) = |z − μ̂^z(x_*)|
16//!   C_α = { z :  1 + #{ i : e_i(z) ≥ e_*(z) }  >  α (n+1) }
17//! ```
18//!
19//! Validity needs ONLY exchangeability of the n+1 points and SYMMETRY of
20//! the fitting map (it must treat the augmented row like any other row).
21//! No model correctness, no asymptotics, no held-out fold.
22//!
23//! The field treats this as computationally infeasible because it seems to
24//! require refitting at a continuum of `z` — solved exactly only for ridge
25//! (Nouretdinov et al. 2001) and approximately for lasso paths. Nobody runs
26//! it for smoothing-selected GAMs, and every "efficient full conformal"
27//! proposal FREEZES the smoothing parameters at their original-data values,
28//! which silently breaks the symmetry requirement (the frozen ρ̂ was chosen
29//! looking at y but not at z — the augmented row is treated differently)
30//! with unquantified effect on coverage. This module closes both gaps:
31//!
32//! - **Layer 1 (implemented below, exact):** Gaussian identity at fixed ρ.
33//!   The augmented fit is affine in `z`, so every score is piecewise
34//!   linear in `z` and the EXACT set is computable from one factorization
35//!   and ≤ 2n linear breakpoints — the ridge result generalized to
36//!   arbitrary penalized smooths (any Sλ, any basis).
37//! - **Layer 2 — discrete arm (implemented below, exact):** Binomial /
38//!   Poisson and any finite-or-windowed response support, by ENUMERATION
39//!   with one symmetric refit per candidate (`SymmetricAugmentedFit`).
40//!   Bernoulli's honest full-conformal set — smoothing re-selection
41//!   included — costs exactly two cold fits; windowed counts carry honest
42//!   tail-resolution flags instead of an unprovable monotone-tail
43//!   assumption. Exactness is by construction: every retained candidate
44//!   was actually refit. Validity is proven in the test module by FULL
45//!   ENUMERATION of every Bernoulli dataset at small n (exact coverage
46//!   ≥ 1 − α as a theorem check, not a simulation).
47//! - **Layer 2 — continuous GLM (implemented below, certified):**
48//!   predictor–corrector homotopy in `z` ([`GlmHomotopyFullConformal`]) —
49//!   exact at corrector points because each correction is a Newton solve of
50//!   the SAME symmetric KKT system a cold fit would solve, with the step
51//!   size CERTIFIED by a computed third-derivative contraction bound and a
52//!   cold-refit fallback whenever the certificate refuses.
53//! - **Layer 3 (the research core, contract below):** the smoothing
54//!   response dρ̂/dz through the exact outer IFT — the first full-conformal
55//!   procedure that re-selects smoothing per candidate — plus the
56//!   **frozen-ρ certificate**: a per-dataset computable bound that can
57//!   conditionally accept (or refuse) freezing ρ̂, with the rho-excursion
58//!   step explicitly reported as a grid-checked Lipschitz assumption.
59//!
60//! # Layer 1 math (what the code below implements)
61//!
62//! Unit prior weights (REQUIRED for exchangeability — a non-unit weight on
63//! a training row makes the rows non-exchangeable with the test row; the
64//! constructor rejects that input rather than emit an invalid guarantee).
65//! Augmented penalized least squares with fixed Sλ:
66//!
67//! ```text
68//!   M       = XᵀX + x_* x_*ᵀ + Sλ                  (one factorization)
69//!   β̂(z)    = M⁻¹ (Xᵀy + x_* z) = a + b z ,   a = M⁻¹Xᵀy , b = M⁻¹x_*
70//! ```
71//!
72//! Every residual is AFFINE in z:
73//!
74//! ```text
75//!   r_i(z)  = y_i − x_iᵀa − (x_iᵀb) z              i = 1..n
76//!   r_*(z)  = −x_*ᵀa + (1 − x_*ᵀb) z
77//! ```
78//!
79//! with `1 − x_*ᵀb = 1/(1 + h_*) > 0` for `h_* = x_*ᵀ(XᵀX+Sλ)⁻¹x_*` by
80//! Sherman–Morrison — the test residual's slope never vanishes, so e_*(z)
81//! is genuinely V-shaped and the rank function is well-defined everywhere.
82//!
83//! The comparison `e_i(z) ≥ e_*(z)` ⟺ `(r_i−r_*)(r_i+r_*) ≥ 0` flips only
84//! at roots of two LINEAR equations per i. Collect ≤ 2n roots, sort, and
85//! the rank of e_* is constant on each open interval between consecutive
86//! roots: evaluate the rank at interval midpoints (and at the roots
87//! themselves, closed-set convention — coverage uses `≥`, so boundary
88//! points belong to the set when their rank qualifies) and assemble the
89//! set as a union of intervals. EXACT — no grid, no tolerance, no refits.
90//!
91//! Unboundedness is honest, not an error: if `|slope(r_*)| ≤ |slope(r_i)|`
92//! for enough i, far-out candidates are never extreme and the set is a
93//! half-line or ℝ (low-information / high-leverage regimes). We return the
94//! interval list as-is, ±∞ endpoints included — same honesty convention as
95//! the split module's `+∞` multiplier.
96//!
97//! # Layer 2: GLM homotopy (implemented below)
98//!
99//! `β̂(z)` solves the augmented penalized score equation
100//! `F(β; z) = Σ_i x_i (μ(η_i) − y_i) + x_*(μ(η_*) − z) + Sλβ = 0`
101//! (canonical link form). The z-derivative is one sensitivity solve:
102//!
103//! ```text
104//!   dβ̂/dz = H_pen⁻¹ x_*          (canonical: ∂F/∂z = −x_*)
105//! ```
106//!
107//! Predictor–corrector walk over z with Newton correction of the SAME
108//! KKT system the cold fit solves: exactness at corrector points is
109//! convergence of Newton, not ODE integration accuracy. The step size is
110//! CERTIFIED by the third-derivative data the tree already has (the PIRLS
111//! `c`-array bounds ‖D_βH[v]‖ along the step, giving a computable Newton
112//! attraction radius) — the corrector cannot silently skip a basin. Score
113//! crossings between steps are localized by bisection on the corrected
114//! path. Discrete families (Binomial, Poisson) are FINITE: z walks the
115//! response support with warm starts, and full conformal is exact by
116//! enumeration — no homotopy subtlety at all; implement that arm first.
117//!
118//! # Layer 3 contract: the ρ-response and the frozen-ρ certificate
119//!
120//! The honest fitting map re-selects ρ̂ on the augmented data. Joint
121//! stationarity in (β, ρ):
122//!
123//! ```text
124//!   F(β, ρ; z) = 0                       (inner KKT, as above)
125//!   G(ρ; z)    = ∇_ρ V(ρ; z) = 0          (outer REML/LAML stationarity)
126//! ```
127//!
128//! One outer IFT step gives the smoothing response to the candidate:
129//!
130//! ```text
131//!   dρ̂/dz = − [∇²_ρρ V]⁻¹ · ∂G/∂z ,
132//!   ∂G_k/∂z = ∂²V/∂ρ_k∂z |_{β̂}  +  ⟨ ∂²V/∂ρ_k∂β , β̇_z ⟩ ,   β̇_z = H⁻¹x_*
133//! ```
134//!
135//! Every ingredient already exists in this engine and (today) nowhere
136//! else: the exact outer Hessian `∇²_ρρV` (#740 machinery), the mixed
137//! ρ×β blocks (the drift/correction vectors of the gradient assembly —
138//! after #931 these are the shared `ThetaDirection` channels), and the
139//! factored `H⁻¹` (the #935 sensitivity operator). The full-path
140//! derivative of the fit is then
141//!
142//! ```text
143//!   dμ̂/dz = Xᵀ-row · ( β̇_z + (dβ̂/dρ) · dρ̂/dz )
144//! ```
145//!
146//! and the homotopy of Layer 2 extends one level up, with EVENTS now of
147//! three kinds: score crossings (set boundary candidates), ρ box-bound
148//! activation (active-set strata — freeze the bound coordinate, continue),
149//! and REML basin jumps (corrector lands on a different local optimum).
150//! Basin jumps are where naive path-tracking would silently break the
151//! symmetry requirement; the discipline is: the DEFINED fitting map is the
152//! deterministic seed-path optimizer (#969), the homotopy is only an
153//! acceleration of it, and whenever the corrector cannot certify it is in
154//! the cold map's basin (objective-value cross-check after correction),
155//! the implementation falls back to a cold deterministic fit at that z.
156//! Validity is therefore inherited from the cold map's symmetry — the
157//! homotopy can be wrong only about SPEED, never about the answer.
158//!
159//! ## The frozen-ρ certificate (the deliverable that matters for everyone)
160//!
161//! For the cheap procedure that freezes ρ̂ at the original-data optimum,
162//! the per-dataset certificate bounds the score perturbation along the
163//! candidate range Z:
164//!
165//! ```text
166//!   |e_i(z; ρ̂(z)) − e_i(z; ρ̂_frozen)| ≤  L_i · sup_{z∈Z} ‖ρ̂(z) − ρ̂_frozen‖
167//! ```
168//!
169//! with `L_i = sup ‖∂e_i/∂ρ‖` from the SAME sensitivity operator
170//! (`∂μ̂/∂ρ = X dβ̂/dρ`, one batched solve). The current implementation
171//! checks the ρ-excursion on a fixed probe grid: acceptance is conditional
172//! on the true `sup |dρ̂/dz|` over the reported range not exceeding the
173//! observed probe maximum by more than the stated mean-value allowance. If
174//! that conditional bound is smaller than the MARGIN of every rank
175//! comparison that decides the set's boundary intervals — `min over
176//! deciding pairs |e_i(z) − e_*(z)|` at the Layer-1 breakpoints, with
177//! critical ties contributing zero — then the frozen-ρ set equals the
178//! honest set under that grid-checked Lipschitz assumption. When the check
179//! fails, the procedure says so and runs Layer 3 instead of silently
180//! returning an unchecked set.
181//!
182//! # Wiring (magic-by-default, certificate-first)
183//!
184//! No flags. The predict path requests full conformal exactly like split
185//! conformal (`conformal_level`), and the dispatcher picks: exact Layer 1
186//! for Gaussian-identity fits, enumeration for discrete families, homotopy
187//! beyond. PRIORITY ORDER MATTERS and is a design decision, not an
188//! optimization: the cheap frozen-ρ exact set runs FIRST, the certificate
189//! is computed, and only on certificate REFUSAL does the engine touch the
190//! expensive honest path — and even then the preferred realization is
191//! cold deterministic refits at the few z-regions whose membership the
192//! certificate could not pin (the breakpoint structure localizes them),
193//! with the dρ̂/dz IFT used to BOUND the excursion, not to continuously
194//! track it. Continuous ρ-path-tracking is the last resort, not the
195//! default — the certificate makes it almost always unnecessary, and a
196//! bound-plus-local-refit design has no basin-tracking failure mode at
197//! all. Unit-weight violation and unsupported regimes fall back to the
198//! split/ALO calibrator LOUDLY (logged), never silently — an invalid
199//! guarantee is worse than a wider valid one.
200
201use faer::Side;
202use ndarray::{Array1, Array2};
203
204use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh, fast_av};
205use opt::{BacktrackConfig, backtracking_line_search};
206
207/// One maximal interval of candidate values retained in the prediction set.
208/// Endpoints may be infinite (honest unboundedness in low-information /
209/// high-leverage regimes).
210#[derive(Clone, Debug, PartialEq)]
211pub struct ConformalInterval {
212    pub lo: f64,
213    pub hi: f64,
214}
215
216/// The exact full-conformal prediction set: a finite union of closed
217/// intervals, plus the diagnostics the Layer-3 certificate consumes.
218#[derive(Clone, Debug)]
219pub struct FullConformalSet {
220    /// Maximal intervals, sorted, disjoint.
221    pub intervals: Vec<ConformalInterval>,
222    /// Miscoverage level the set was built for.
223    pub alpha: f64,
224    /// `n + 1` (augmented count) — the denominator of the conformal rank.
225    pub n_augmented: usize,
226    /// The decision margin: the smallest |e_i − e_*| gap over rank
227    /// comparisons whose flip can change membership. Critical ties
228    /// contribute zero. When the set has no finite boundary (all of ℝ or
229    /// empty), the margin is the analytic infimum of the local rank-decision
230    /// margin over the whole candidate line; `+∞` is reserved for the case
231    /// where membership needs no score comparison at all.
232    pub boundary_margin: f64,
233}
234
235/// Exact Gaussian-identity full-conformal engine at fixed Sλ (Layer 1).
236///
237/// One factorization of `M = XᵀX + x_*x_*ᵀ + Sλ`; every candidate-z
238/// quantity is affine in z thereafter. See the module doc for the math.
239pub struct ExactGaussianFullConformal {
240    /// Affine residual coefficients: `r_i(z) = u[i] + w[i]·z` for the n
241    /// training rows, and the test residual in the LAST slot.
242    u: Array1<f64>,
243    w: Array1<f64>,
244    n: usize,
245}
246
247impl ExactGaussianFullConformal {
248    /// Build from raw fit ingredients. `x` is the n×p design at the
249    /// TRAINING rows, `s_lambda` the p×p penalty at the fitted ρ̂ (frozen
250    /// here by construction — Layer 3 owns the honest ρ-response),
251    /// `x_star` the p-row at the test covariates.
252    ///
253    /// Rejects non-unit prior weights: exchangeability of the augmented
254    /// row with the training rows is the entire coverage proof, and a
255    /// reweighted row is not exchangeable with the test row. (Weighted
256    /// conformal — Tibshirani et al. 2019 — is a different estimand with
257    /// likelihood-ratio weights; it can be added as its own constructor,
258    /// not silently conflated with this one.)
259    pub fn new(
260        x: &Array2<f64>,
261        y: &Array1<f64>,
262        prior_weights: &Array1<f64>,
263        s_lambda: &Array2<f64>,
264        x_star: &Array1<f64>,
265    ) -> Result<Self, String> {
266        let n = x.nrows();
267        let p = x.ncols();
268        if y.len() != n || prior_weights.len() != n {
269            return Err("full conformal: row-count mismatch".to_string());
270        }
271        if s_lambda.nrows() != p || s_lambda.ncols() != p || x_star.len() != p {
272            return Err("full conformal: column-count mismatch".to_string());
273        }
274        if prior_weights.iter().any(|&w| (w - 1.0).abs() > 1e-12) {
275            return Err(
276                "full conformal requires unit prior weights: a reweighted training row is \
277                 not exchangeable with the test row, so the finite-sample coverage proof \
278                 does not apply; use the split/ALO conformal calibrator instead"
279                    .to_string(),
280            );
281        }
282
283        // M = XᵀX + x_*x_*ᵀ + Sλ — the augmented penalized normal matrix.
284        let mut m = x.t().dot(x) + s_lambda;
285        for i in 0..p {
286            for j in 0..p {
287                m[[i, j]] += x_star[i] * x_star[j];
288            }
289        }
290        let chol = m
291            .cholesky(Side::Lower)
292            .map_err(|e| format!("full conformal: augmented normal matrix not SPD: {e:?}"))?;
293        let xty = x.t().dot(y);
294        let a = chol.solvevec(&xty);
295        let b = chol.solvevec(&x_star.to_owned());
296
297        // Affine residuals r_i(z) = u_i + w_i z; test residual last.
298        let mut u = Array1::<f64>::zeros(n + 1);
299        let mut w = Array1::<f64>::zeros(n + 1);
300        let xa = fast_av(x, &a);
301        let xb = fast_av(x, &b);
302        for i in 0..n {
303            u[i] = y[i] - xa[i];
304            w[i] = -xb[i];
305        }
306        let mu_a_star = x_star.dot(&a);
307        let h_frac = x_star.dot(&b); // = h/(1+h) ∈ [0, 1)
308        u[n] = -mu_a_star;
309        w[n] = 1.0 - h_frac; // strictly positive by Sherman–Morrison
310        if w[n] <= 0.0 {
311            return Err(
312                "full conformal: test-residual slope 1 − x_*ᵀM⁻¹x_* must be positive; \
313                 non-SPD or numerically broken augmented system"
314                    .to_string(),
315            );
316        }
317        Ok(Self { u, w, n })
318    }
319
320    /// Number of training rows whose score weakly dominates the test score
321    /// at candidate z: `#{ i ≤ n : e_i(z) ≥ e_*(z) }`.
322    fn dominating_count(&self, z: f64) -> usize {
323        let e_star = (self.u[self.n] + self.w[self.n] * z).abs();
324        (0..self.n)
325            .filter(|&i| (self.u[i] + self.w[i] * z).abs() >= e_star)
326            .count()
327    }
328
329    /// Membership at candidate z: conformal p-value `(1 + count)/(n+1) > α`.
330    fn member(&self, z: f64, alpha: f64) -> bool {
331        let n1 = (self.n + 1) as f64;
332        (1.0 + self.dominating_count(z) as f64) > alpha * n1
333    }
334
335    fn required_dominating_count(&self, alpha: f64) -> usize {
336        let threshold = alpha * (self.n + 1) as f64;
337        for count in 0..=self.n {
338            if 1.0 + count as f64 > threshold {
339                return count;
340            }
341        }
342        self.n + 1
343    }
344
345    fn local_decision_margin(&self, z: f64, alpha: f64) -> f64 {
346        let required = self.required_dominating_count(alpha);
347        if required == 0 {
348            return f64::INFINITY;
349        }
350        let e_star = (self.u[self.n] + self.w[self.n] * z).abs();
351        let mut true_gaps = Vec::new();
352        let mut false_gaps = Vec::new();
353        for i in 0..self.n {
354            let e_i = (self.u[i] + self.w[i] * z).abs();
355            let gap = (e_i - e_star).abs();
356            if e_i >= e_star {
357                true_gaps.push(gap);
358            } else {
359                false_gaps.push(gap);
360            }
361        }
362        if true_gaps.len() >= required {
363            true_gaps.sort_by(|a, b| a.partial_cmp(b).expect("finite score gaps"));
364            true_gaps[true_gaps.len() - required]
365        } else {
366            let needed = required - true_gaps.len();
367            false_gaps.sort_by(|a, b| a.partial_cmp(b).expect("finite score gaps"));
368            false_gaps.get(needed - 1).copied().unwrap_or(f64::INFINITY)
369        }
370    }
371
372    fn push_finite_root(points: &mut Vec<f64>, numerator: f64, denominator: f64) {
373        if denominator.abs() > 0.0 {
374            let z = numerator / denominator;
375            if z.is_finite() {
376                points.push(z);
377            }
378        }
379    }
380
381    fn abs_residual_affine_at(&self, row: usize, z: f64) -> (f64, f64) {
382        let value = self.u[row] + self.w[row] * z;
383        let sign = if value >= 0.0 { 1.0 } else { -1.0 };
384        (sign * self.w[row], sign * self.u[row])
385    }
386
387    fn gap_affines_on_cell(&self, z: f64) -> Vec<(bool, f64, f64)> {
388        let (star_slope, star_intercept) = self.abs_residual_affine_at(self.n, z);
389        let mut gaps = Vec::with_capacity(self.n);
390        for i in 0..self.n {
391            let (row_slope, row_intercept) = self.abs_residual_affine_at(i, z);
392            let diff_slope = row_slope - star_slope;
393            let diff_intercept = row_intercept - star_intercept;
394            let diff = diff_slope * z + diff_intercept;
395            if diff >= 0.0 {
396                gaps.push((true, diff_slope, diff_intercept));
397            } else {
398                gaps.push((false, -diff_slope, -diff_intercept));
399            }
400        }
401        gaps
402    }
403
404    fn asymptotic_abs_residual_affine(&self, row: usize, direction: f64) -> (f64, f64) {
405        let slope_in_t = direction * self.w[row];
406        let sign = if slope_in_t > 0.0 {
407            1.0
408        } else if slope_in_t < 0.0 {
409            -1.0
410        } else if self.u[row] >= 0.0 {
411            1.0
412        } else {
413            -1.0
414        };
415        (sign * slope_in_t, sign * self.u[row])
416    }
417
418    fn asymptotic_decision_margin(&self, direction: f64, alpha: f64) -> f64 {
419        let required = self.required_dominating_count(alpha);
420        if required == 0 {
421            return f64::INFINITY;
422        }
423        let (star_slope, star_intercept) = self.asymptotic_abs_residual_affine(self.n, direction);
424        let mut true_gaps = Vec::new();
425        let mut false_gaps = Vec::new();
426        for i in 0..self.n {
427            let (row_slope, row_intercept) = self.asymptotic_abs_residual_affine(i, direction);
428            let diff_slope = row_slope - star_slope;
429            let diff_intercept = row_intercept - star_intercept;
430            let truth = diff_slope > 0.0 || (diff_slope == 0.0 && diff_intercept >= 0.0);
431            let gap = if truth {
432                (diff_slope, diff_intercept)
433            } else {
434                (-diff_slope, -diff_intercept)
435            };
436            if truth {
437                true_gaps.push(gap);
438            } else {
439                false_gaps.push(gap);
440            }
441        }
442        let critical = if true_gaps.len() >= required {
443            true_gaps.sort_by(|a, b| {
444                a.0.partial_cmp(&b.0)
445                    .expect("finite asymptotic slopes")
446                    .then_with(|| a.1.partial_cmp(&b.1).expect("finite asymptotic intercepts"))
447            });
448            true_gaps.get(true_gaps.len() - required).copied()
449        } else {
450            let needed = required - true_gaps.len();
451            false_gaps.sort_by(|a, b| {
452                a.0.partial_cmp(&b.0)
453                    .expect("finite asymptotic slopes")
454                    .then_with(|| a.1.partial_cmp(&b.1).expect("finite asymptotic intercepts"))
455            });
456            false_gaps.get(needed - 1).copied()
457        };
458        match critical {
459            Some((slope, intercept)) if slope == 0.0 => intercept.max(0.0),
460            Some(_) => f64::INFINITY,
461            None => f64::INFINITY,
462        }
463    }
464
465    fn margin_without_finite_boundaries(&self, alpha: f64, roots: &[f64]) -> f64 {
466        let mut points = roots.to_vec();
467        for row in 0..=self.n {
468            Self::push_finite_root(&mut points, -self.u[row], self.w[row]);
469        }
470        points.sort_by(|a, b| a.partial_cmp(b).expect("finite breakpoints"));
471        points.dedup_by(|a, b| *a == *b);
472
473        let mut eval_points = points.clone();
474        for cell in 0..=points.len() {
475            let lo = if cell == 0 {
476                f64::NEG_INFINITY
477            } else {
478                points[cell - 1]
479            };
480            let hi = if cell == points.len() {
481                f64::INFINITY
482            } else {
483                points[cell]
484            };
485            let z = if lo.is_finite() && hi.is_finite() {
486                0.5 * (lo + hi)
487            } else if lo.is_finite() {
488                lo + 1.0
489            } else if hi.is_finite() {
490                hi - 1.0
491            } else {
492                0.0
493            };
494            let gaps = self.gap_affines_on_cell(z);
495            let required = self.required_dominating_count(alpha);
496            if required == 0 {
497                continue;
498            }
499            let true_count = gaps.iter().filter(|g| g.0).count();
500            let need_truth = true_count >= required;
501            let relevant: Vec<(f64, f64)> = gaps
502                .iter()
503                .filter(|g| g.0 == need_truth)
504                .map(|g| (g.1, g.2))
505                .collect();
506            for a in 0..relevant.len() {
507                for b in (a + 1)..relevant.len() {
508                    let denominator = relevant[a].0 - relevant[b].0;
509                    if denominator.abs() > 0.0 {
510                        let cross = (relevant[b].1 - relevant[a].1) / denominator;
511                        if cross.is_finite() && cross > lo && cross < hi {
512                            eval_points.push(cross);
513                        }
514                    }
515                }
516            }
517        }
518        eval_points.sort_by(|a, b| a.partial_cmp(b).expect("finite margin points"));
519        eval_points.dedup_by(|a, b| *a == *b);
520
521        let mut margin = f64::INFINITY;
522        if eval_points.is_empty() {
523            margin = margin.min(self.local_decision_margin(0.0, alpha));
524        } else {
525            for z in eval_points {
526                margin = margin.min(self.local_decision_margin(z, alpha));
527            }
528        }
529        margin = margin.min(self.asymptotic_decision_margin(1.0, alpha));
530        margin = margin.min(self.asymptotic_decision_margin(-1.0, alpha));
531        margin
532    }
533
534    /// The exact prediction set at miscoverage α.
535    ///
536    /// Breakpoints: for each i, roots of `r_*(z) = ±r_i(z)` — two linear
537    /// equations. Between consecutive roots the comparison pattern (hence
538    /// the rank of e_*) is constant; evaluate membership on midpoints and
539    /// at every root (closed-set convention), then merge runs into maximal
540    /// intervals. Cost O(n log n) after the single factorization.
541    pub fn prediction_set(&self, alpha: f64) -> FullConformalSet {
542        let n = self.n;
543        let (us, ws) = (self.u[n], self.w[n]);
544        let mut roots: Vec<f64> = Vec::with_capacity(2 * n);
545        for i in 0..n {
546            // r_* − r_i = (us − u_i) + (ws − w_i) z = 0
547            let d = ws - self.w[i];
548            Self::push_finite_root(&mut roots, self.u[i] - us, d);
549            // r_* + r_i = (us + u_i) + (ws + w_i) z = 0
550            let s = ws + self.w[i];
551            Self::push_finite_root(&mut roots, -(us + self.u[i]), s);
552        }
553        roots.sort_by(|p, q| p.partial_cmp(q).expect("finite breakpoints"));
554        roots.dedup_by(|p, q| *p == *q);
555
556        // Probe points: each root, each gap midpoint, and the two open
557        // tails. Membership is constant strictly between consecutive
558        // roots, so this probe set decides the set exactly.
559        let mut probes: Vec<f64> = Vec::with_capacity(2 * roots.len() + 3);
560        if roots.is_empty() {
561            probes.push(0.0);
562        } else {
563            let span = (roots[roots.len() - 1] - roots[0]).max(1.0);
564            probes.push(roots[0] - span);
565            for k in 0..roots.len() {
566                probes.push(roots[k]);
567                if k + 1 < roots.len() {
568                    probes.push(0.5 * (roots[k] + roots[k + 1]));
569                }
570            }
571            probes.push(roots[roots.len() - 1] + span);
572        }
573
574        // Scan probes into maximal intervals. A member midpoint/tail claims
575        // its whole open gap; member roots close the endpoints.
576        let mut intervals: Vec<ConformalInterval> = Vec::new();
577        let mut open_lo: Option<f64> = None;
578        let gap_bounds = |idx: usize| -> (f64, f64) {
579            // bounds of the gap a probe at sorted position idx represents
580            if roots.is_empty() {
581                return (f64::NEG_INFINITY, f64::INFINITY);
582            }
583            if idx == 0 {
584                return (f64::NEG_INFINITY, roots[0]);
585            }
586            if idx == probes.len() - 1 {
587                return (roots[roots.len() - 1], f64::INFINITY);
588            }
589            // probes alternate root, mid, root, mid, ... after the first
590            let k = (idx - 1) / 2; // gap index for midpoints, root index for roots
591            if idx % 2 == 1 {
592                // a root: zero-width "gap" at the root itself
593                (roots[k], roots[k])
594            } else {
595                (roots[k], roots[k + 1])
596            }
597        };
598        for (idx, &z) in probes.iter().enumerate() {
599            let inside = self.member(z, alpha);
600            let (lo, hi) = gap_bounds(idx);
601            if inside {
602                if open_lo.is_none() {
603                    open_lo = Some(lo);
604                }
605                if idx == probes.len() - 1 {
606                    intervals.push(ConformalInterval {
607                        lo: open_lo.take().expect("open interval"),
608                        hi,
609                    });
610                }
611            } else if let Some(lo_open) = open_lo.take() {
612                intervals.push(ConformalInterval {
613                    lo: lo_open,
614                    hi: lo,
615                });
616            }
617        }
618
619        // Decision margin for the frozen-ρ check (Layer 3): at a finite
620        // boundary, evaluate the exact local rank-decision margin. Critical
621        // ties contribute zero. If there is no finite boundary (all-R or
622        // empty), compute the analytic infimum of the same local quantity
623        // over the whole piecewise-linear candidate line.
624        let mut finite_endpoints = Vec::new();
625        for itv in &intervals {
626            for endpoint in [itv.lo, itv.hi] {
627                if endpoint.is_finite() {
628                    finite_endpoints.push(endpoint);
629                }
630            }
631        }
632        let boundary_margin = if finite_endpoints.is_empty() {
633            self.margin_without_finite_boundaries(alpha, &roots)
634        } else {
635            finite_endpoints
636                .into_iter()
637                .map(|endpoint| self.local_decision_margin(endpoint, alpha))
638                .fold(f64::INFINITY, f64::min)
639        };
640
641        FullConformalSet {
642            intervals,
643            alpha,
644            n_augmented: n + 1,
645            boundary_margin,
646        }
647    }
648}
649
650/// The symmetric augmented fitting map the discrete enumeration arm walks.
651///
652/// `scores(z)` must: fit the n+1 augmented rows `{(x_i, y_i)} ∪ {(x_*, z)}`
653/// and return all n+1 nonconformity scores with the TEST row's score LAST.
654/// The single requirement backing the coverage guarantee is SYMMETRY: the
655/// fitting map must treat the augmented row exactly like a training row
656/// (same loss term, same weight, same participation in any smoothing /
657/// hyperparameter selection the map performs). A map that freezes anything
658/// it selected by looking at the training responses but not at `z` breaks
659/// symmetry and voids the guarantee — for discrete families that honesty is
660/// CHEAP, because the support is walked by enumeration (2 refits for
661/// Bernoulli), so the map can simply be the full cold fit, ρ-selection
662/// included.
663///
664/// `&mut self` so implementations can warm-start across consecutive
665/// candidates (a speed optimization that cannot affect the answer when each
666/// solve is run to its deterministic optimum).
667pub trait SymmetricAugmentedFit {
668    fn scores(&mut self, z: f64) -> Result<Array1<f64>, String>;
669}
670
671/// Blanket impl so plain closures can serve as the fitting map (tests, and
672/// adapter shims that capture a fit configuration).
673impl<F> SymmetricAugmentedFit for F
674where
675    F: FnMut(f64) -> Result<Array1<f64>, String>,
676{
677    fn scores(&mut self, z: f64) -> Result<Array1<f64>, String> {
678        self(z)
679    }
680}
681
682/// One enumerated candidate's conformal verdict.
683#[derive(Clone, Debug)]
684pub struct DiscreteCandidate {
685    pub z: f64,
686    /// Conformal p-value `(1 + #{i ≤ n : e_i ≥ e_*}) / (n+1)`. Ties count
687    /// FOR the candidate (the `≥` convention) — the conservative direction;
688    /// strict-inequality ranking would under-cover under ties.
689    pub p_value: f64,
690    pub member: bool,
691}
692
693/// Exact full-conformal prediction set for a DISCRETE response family,
694/// computed by enumeration of candidate responses with one symmetric refit
695/// per candidate (#942 Layer 2, discrete arm).
696///
697/// There is no homotopy and no approximation anywhere in this object: for
698/// each candidate `z` the fitting map is run to its optimum, the n+1 scores
699/// are ranked, and the candidate is kept iff its conformal p-value exceeds
700/// α. Validity is the standard full-conformal argument — exchangeability of
701/// the n+1 rows plus symmetry of the map — and EXACTNESS is by construction
702/// (the support is finite or explicitly windowed; every retained candidate
703/// was actually refit).
704#[derive(Clone, Debug)]
705pub struct DiscreteFullConformalSet {
706    /// Retained candidates, ascending.
707    pub members: Vec<f64>,
708    /// Every enumerated candidate with its p-value (diagnostics; the
709    /// boundary-adjacent p-values are the discrete analogue of Layer 1's
710    /// `boundary_margin`).
711    pub candidates: Vec<DiscreteCandidate>,
712    pub alpha: f64,
713    /// `n + 1`.
714    pub n_augmented: usize,
715    /// `Some(z_first)` when the SMALLEST enumerated candidate was a member
716    /// of a WINDOWED enumeration — the retained set may continue
717    /// contiguously below the window. Always `None` for exhaustive supports
718    /// (the Bernoulli arm). For a windowed support, `None` only says the
719    /// retained set does not continue through the enumerated edge; absent a
720    /// monotone-tail theorem for the fitting map, it says nothing about
721    /// non-contiguous retained candidates farther outside the window.
722    pub lower_tail_unresolved: Option<f64>,
723    /// Mirror of `lower_tail_unresolved` for the largest candidate.
724    pub upper_tail_unresolved: Option<f64>,
725}
726
727/// Walk an EXHAUSTIVE discrete support (e.g. Bernoulli `{0, 1}`). The
728/// returned set is the exact full-conformal set, period — no tail
729/// semantics, because there is nothing outside the support.
730pub fn discrete_full_conformal_exhaustive<M: SymmetricAugmentedFit>(
731    fit: &mut M,
732    support: &[f64],
733    alpha: f64,
734) -> Result<DiscreteFullConformalSet, String> {
735    let mut set = discrete_walk(fit, support, alpha)?;
736    set.lower_tail_unresolved = None;
737    set.upper_tail_unresolved = None;
738    Ok(set)
739}
740
741/// Walk a WINDOW of an unbounded discrete support (e.g. Poisson counts
742/// `lo..=hi`). Exact ON THE WINDOW; the tail flags report honestly whether
743/// the retained set continues through either edge (edge candidate retained
744/// ⇒ contiguous tail unresolved). An excluded edge resolves only that
745/// contiguous continuation. Without a monotone-tail theorem for the fitting
746/// map, callers must not interpret cleared flags as a global proof that no
747/// non-contiguous retained candidates exist farther outside the window.
748pub fn discrete_full_conformal_window<M: SymmetricAugmentedFit>(
749    fit: &mut M,
750    window: &[f64],
751    alpha: f64,
752) -> Result<DiscreteFullConformalSet, String> {
753    discrete_walk(fit, window, alpha)
754}
755
756/// Bernoulli convenience arm: the support is `{0, 1}`, so the honest
757/// (ρ-re-selecting) full-conformal set costs exactly two cold fits.
758pub fn bernoulli_full_conformal<M: SymmetricAugmentedFit>(
759    fit: &mut M,
760    alpha: f64,
761) -> Result<DiscreteFullConformalSet, String> {
762    discrete_full_conformal_exhaustive(fit, &[0.0, 1.0], alpha)
763}
764
765fn discrete_walk<M: SymmetricAugmentedFit>(
766    fit: &mut M,
767    candidates: &[f64],
768    alpha: f64,
769) -> Result<DiscreteFullConformalSet, String> {
770    if candidates.is_empty() {
771        return Err("discrete full conformal: empty candidate list".to_string());
772    }
773    if !(0.0..1.0).contains(&alpha) {
774        return Err(format!(
775            "discrete full conformal: alpha must be in [0, 1), got {alpha}"
776        ));
777    }
778    if candidates.windows(2).any(|w| !(w[0] < w[1])) {
779        return Err("discrete full conformal: candidates must be strictly increasing".to_string());
780    }
781
782    let mut out = Vec::with_capacity(candidates.len());
783    let mut members = Vec::new();
784    let mut n_augmented = 0usize;
785    for &z in candidates {
786        let scores = fit.scores(z)?;
787        let n1 = scores.len();
788        if n1 < 2 {
789            return Err(
790                "discrete full conformal: fitting map must score at least two rows".to_string(),
791            );
792        }
793        if n_augmented == 0 {
794            n_augmented = n1;
795        } else if n_augmented != n1 {
796            return Err(format!(
797                "discrete full conformal: fitting map returned {n1} scores after returning \
798                 {n_augmented}; the augmented row count cannot change across candidates"
799            ));
800        }
801        if scores.iter().any(|s| !s.is_finite()) {
802            return Err(format!(
803                "discrete full conformal: non-finite nonconformity score at candidate {z}; \
804                 refusing to rank garbage"
805            ));
806        }
807        let e_star = scores[n1 - 1];
808        let count = scores.iter().take(n1 - 1).filter(|&&e| e >= e_star).count();
809        let p_value = (1.0 + count as f64) / (n1 as f64);
810        let member = p_value > alpha;
811        if member {
812            members.push(z);
813        }
814        out.push(DiscreteCandidate { z, p_value, member });
815    }
816
817    let lower_tail_unresolved = out.first().filter(|c| c.member).map(|c| c.z);
818    let upper_tail_unresolved = out.last().filter(|c| c.member).map(|c| c.z);
819    Ok(DiscreteFullConformalSet {
820        members,
821        candidates: out,
822        alpha,
823        n_augmented,
824        lower_tail_unresolved,
825        upper_tail_unresolved,
826    })
827}
828
829/// Layer-3 verdict for the frozen-ρ shortcut. Produced by comparing the
830/// grid-checked ρ-excursion bound against the exact engine's
831/// `boundary_margin` (see module doc). `Certified` is conditional on the
832/// stated rho-grid Lipschitz assumption; `Refused` carries the two numbers
833/// so the caller can show exactly how far from acceptable the shortcut was.
834#[derive(Clone, Debug)]
835pub enum FrozenRhoCertificate {
836    Certified {
837        score_perturbation_bound: f64,
838        boundary_margin: f64,
839    },
840    Refused {
841        score_perturbation_bound: f64,
842        boundary_margin: f64,
843    },
844}
845
846impl FrozenRhoCertificate {
847    /// Decide from the two computed constants. Strict inequality: a bound
848    /// equal to the margin cannot certify, and a zero margin can never
849    /// certify because no positive perturbation bound is strictly below it.
850    pub fn decide(score_perturbation_bound: f64, boundary_margin: f64) -> Self {
851        if boundary_margin > 0.0 && score_perturbation_bound < boundary_margin {
852            FrozenRhoCertificate::Certified {
853                score_perturbation_bound,
854                boundary_margin,
855            }
856        } else {
857            FrozenRhoCertificate::Refused {
858                score_perturbation_bound,
859                boundary_margin,
860            }
861        }
862    }
863}
864
865/// Closed-form Gaussian-REML smoothing-parameter response and the frozen-ρ
866/// certificate it powers — the #942 Layer-3 research core, realized exactly
867/// for the single-penalty model `Sλ = λ S` (`ρ = log λ`).
868///
869/// # Why this object exists
870///
871/// Layer 1 ([`ExactGaussianFullConformal`]) is honest only if ρ is held fixed
872/// — but the DEFINED Gaussian fitting map re-selects ρ̂ by REML on whatever
873/// data it sees, including the augmented row `(x_*, z)`. Every "efficient
874/// full conformal" method in the literature silently freezes ρ̂ at its
875/// original-data value and never quantifies the resulting symmetry break.
876/// This object closes that gap WITHOUT a homotopy: it computes the honest
877/// re-selecting map exactly (it is a 1-D REML problem per candidate), the
878/// smoothing response `dρ̂/dz` in closed form via the outer IFT, and a
879/// per-dataset conditional check that accepts (or refuses) freezing ρ̂. On
880/// acceptance the cheap frozen-ρ set is returned with the rho-grid
881/// assumption that makes equality to the honest set valid; on refusal the
882/// caller is told, with the two deciding constants, exactly how far short
883/// the shortcut fell.
884///
885/// # The closed forms (single penalty `Sλ = λ S`)
886///
887/// Augmented penalized least squares with the test row included:
888///
889/// ```text
890///   A(λ)   = XᵀX + x_* x_*ᵀ + λ S         (independent of z)
891///   c(z)   = Xᵀy + x_* z ,  β̂ = A(λ)⁻¹ c(z) = a + b z   (affine in z)
892///   D(ρ,z) = ‖y_aug‖² − c(z)ᵀ A(λ)⁻¹ c(z)              (penalized RSS)
893/// ```
894///
895/// The Gaussian REML criterion to MINIMIZE over ρ (σ² profiled out, additive
896/// constants dropped; `M₀ = nullity(S)`, `r = rank(S)`, `n_eff = n (+1` if the
897/// test row is present`)`):
898///
899/// ```text
900///   Ṽ(ρ,z) = (n_eff − M₀) · log D(ρ,z) + log|A(λ)| − r ρ
901/// ```
902///
903/// Its z- and ρ-derivatives are all closed form (`pen = λ β̂ᵀSβ̂`):
904///
905/// ```text
906///   ∂D/∂ρ = pen ,                ∂D/∂z = 2(z − x_*ᵀβ̂) = 2 r_*
907///   G    = ∂Ṽ/∂ρ      = (n_eff−M₀)·pen/D + λ tr(A⁻¹S) − r
908///   ∂²Ṽ/∂ρ²           = (n_eff−M₀)·(pen'·D − pen²)/D² + λ tr(A⁻¹S) − λ² tr((A⁻¹S)²)
909///   ∂²Ṽ/∂ρ∂z          = (n_eff−M₀)·(pen_z'·D − pen·D_z')/D²
910/// ```
911///
912/// with `pen' = pen − 2λ²·β̂ᵀS A⁻¹ S β̂`, `pen_z' = 2λ·β̂ᵀS (A⁻¹x_*)`,
913/// `D_z' = 2 r_*`. The smoothing response to the candidate is one outer IFT
914/// step on `G(ρ̂(z), z) = 0`:
915///
916/// ```text
917///   dρ̂/dz = − (∂²Ṽ/∂ρ²)⁻¹ · ∂²Ṽ/∂ρ∂z .
918/// ```
919///
920/// The score–ρ sensitivity (which the certificate's Lipschitz constant uses)
921/// is `∂μ̂_i/∂ρ = x_iᵀ (dβ̂/dρ)` with `dβ̂/dρ = −λ A⁻¹ S β̂`, so
922/// `|∂e_i/∂ρ| = |∂μ̂_i/∂ρ|` (the absolute-residual score's only ρ-dependence
923/// is through μ̂). Everything is assembled from ONE Cholesky of `A(λ)` plus a
924/// handful of solves.
925pub struct GaussianRemlRhoResponse<'a> {
926    x: &'a Array2<f64>,
927    y: &'a Array1<f64>,
928    s: &'a Array2<f64>,
929    x_star: &'a Array1<f64>,
930    n: usize,
931    p: usize,
932    rank_s: usize,
933    xtx: Array2<f64>,
934    xty: Array1<f64>,
935    yty: f64,
936}
937
938/// One closed-form evaluation of the (possibly augmented) Gaussian REML
939/// criterion at `ρ = log λ`, carrying every derivative the IFT and the
940/// certificate consume.
941#[derive(Clone, Debug)]
942struct RemlEval {
943    /// `Ṽ(ρ,z)` (additive constants dropped — only differences in ρ matter).
944    value: f64,
945    /// `G = ∂Ṽ/∂ρ`.
946    grad: f64,
947    /// `∂²Ṽ/∂ρ²`.
948    hess: f64,
949    /// `∂²Ṽ/∂ρ∂z` (0 when the test row is absent).
950    cross: f64,
951    /// `∂μ̂_i/∂ρ` at the training rows.
952    mu_rho_train: Array1<f64>,
953    /// `∂μ̂_*/∂ρ` at the test row.
954    mu_rho_test: f64,
955}
956
957/// The frozen-ρ full-conformal set with its Layer-3 certificate and the
958/// constants the certificate decided on.
959#[derive(Clone, Debug)]
960pub struct CertifiedFullConformal {
961    /// The cheap exact set built at the frozen `ρ̂₀` (original-data optimum).
962    pub frozen_set: FullConformalSet,
963    /// Whether freezing ρ̂ is accepted under the reported rho-grid
964    /// Lipschitz assumption.
965    pub certificate: FrozenRhoCertificate,
966    /// `ρ̂₀ = log λ̂₀` selected by REML on the original (un-augmented) data.
967    pub rho_frozen: f64,
968    /// Conditional bound on `sup_z |ρ̂(z) − ρ̂₀|` over the finite deciding
969    /// range. Zero with `rho_probe_count == 0` means no finite range was
970    /// probed.
971    pub rho_excursion: f64,
972    /// `max_i (|∂μ̂_i/∂ρ| + |∂μ̂_*/∂ρ|)` — the score-gap Lipschitz constant in ρ.
973    pub score_rho_lipschitz: f64,
974    /// Number of equal-spaced rho-response probes used on the finite
975    /// deciding range. Zero means no finite probe range was available.
976    pub rho_probe_count: usize,
977    /// Largest observed `|dρ̂/dz|` on the rho-response probe grid. This is a
978    /// diagnostic, not a continuous supremum proof.
979    pub observed_sup_drho_dz: f64,
980}
981
982impl<'a> GaussianRemlRhoResponse<'a> {
983    /// Build the response object. Computes `rank(S)` once by symmetric
984    /// eigendecomposition (relative tolerance on the largest eigenvalue).
985    pub fn new(
986        x: &'a Array2<f64>,
987        y: &'a Array1<f64>,
988        s: &'a Array2<f64>,
989        x_star: &'a Array1<f64>,
990    ) -> Result<Self, String> {
991        let n = x.nrows();
992        let p = x.ncols();
993        if y.len() != n {
994            return Err("gaussian reml response: row-count mismatch".to_string());
995        }
996        if s.nrows() != p || s.ncols() != p || x_star.len() != p {
997            return Err("gaussian reml response: column-count mismatch".to_string());
998        }
999        let (evals, _) = s.eigh(Side::Lower).map_err(|e| {
1000            format!("gaussian reml response: penalty eigendecomposition failed: {e:?}")
1001        })?;
1002        let max_ev = evals.iter().cloned().fold(0.0_f64, |a, b| a.max(b.abs()));
1003        let tol = max_ev * 1e-10 * (p.max(1) as f64);
1004        let rank_s = evals.iter().filter(|&&e| e > tol).count();
1005        let xtx = x.t().dot(x);
1006        let xty = x.t().dot(y);
1007        let yty = y.dot(y);
1008        Ok(Self {
1009            x,
1010            y,
1011            s,
1012            x_star,
1013            n,
1014            p,
1015            rank_s,
1016            xtx,
1017            xty,
1018            yty,
1019        })
1020    }
1021
1022    /// `rank(S)` as detected at construction.
1023    pub fn rank_s(&self) -> usize {
1024        self.rank_s
1025    }
1026
1027    /// Closed-form REML evaluation at `ρ`. `z = Some(_)` augments with the
1028    /// test row; `z = None` is the original-data criterion (used for ρ̂₀).
1029    fn eval(&self, rho: f64, z: Option<f64>) -> Result<RemlEval, String> {
1030        let p = self.p;
1031        let n_eff = self.n + usize::from(z.is_some());
1032        let m0 = p - self.rank_s;
1033        if n_eff <= m0 {
1034            return Err(format!(
1035                "gaussian reml response: degrees of freedom n_eff−M₀ = {n_eff}−{m0} ≤ 0; \
1036                 REML criterion undefined"
1037            ));
1038        }
1039        let coef = (n_eff - m0) as f64;
1040        let r = self.rank_s as f64;
1041        let lambda = gam_problem::checked_exp_log_strength(rho)
1042            .map_err(|error| format!("gaussian REML conformal response: {error}"))?;
1043
1044        // A(λ) = XᵀX + λ S [+ x_* x_*ᵀ].
1045        let mut a = self.xtx.clone();
1046        for i in 0..p {
1047            for j in 0..p {
1048                a[[i, j]] += lambda * self.s[[i, j]];
1049            }
1050        }
1051        if z.is_some() {
1052            for i in 0..p {
1053                for j in 0..p {
1054                    a[[i, j]] += self.x_star[i] * self.x_star[j];
1055                }
1056            }
1057        }
1058        let chol = a
1059            .cholesky(Side::Lower)
1060            .map_err(|e| format!("gaussian reml response: A(λ) not SPD: {e:?}"))?;
1061
1062        // c(z) = Xᵀy [+ x_* z].
1063        let mut c = self.xty.clone();
1064        if let Some(zv) = z {
1065            for j in 0..p {
1066                c[j] += self.x_star[j] * zv;
1067            }
1068        }
1069        let beta = chol.solvevec(&c);
1070        let yty_eff = self.yty + z.map_or(0.0, |zv| zv * zv);
1071        let d = yty_eff - c.dot(&beta);
1072        if !(d > 0.0) {
1073            return Err(format!(
1074                "gaussian reml response: non-positive penalized RSS D = {d}; degenerate fit"
1075            ));
1076        }
1077
1078        let sbeta = self.s.dot(&beta);
1079        let pen = lambda * beta.dot(&sbeta);
1080
1081        // Z = A⁻¹ S for the trace terms tr(A⁻¹S), tr((A⁻¹S)²).
1082        let z_mat = chol.solve_mat(self.s);
1083        let mut tr_ainv_s = 0.0;
1084        let mut tr_ainv_s_sq = 0.0;
1085        for i in 0..p {
1086            tr_ainv_s += z_mat[[i, i]];
1087            for j in 0..p {
1088                tr_ainv_s_sq += z_mat[[i, j]] * z_mat[[j, i]];
1089            }
1090        }
1091
1092        // v_s = A⁻¹ Sβ̂ (so dβ̂/dρ = −λ v_s); quad = β̂ᵀS A⁻¹ S β̂.
1093        let v_s = chol.solvevec(&sbeta);
1094        let quad = sbeta.dot(&v_s);
1095
1096        let logdet: f64 = 2.0 * chol.diag().iter().map(|d| d.ln()).sum::<f64>();
1097        let value = coef * d.ln() + logdet - r * rho;
1098        let grad = coef * pen / d + lambda * tr_ainv_s - r;
1099        let pen_prime = pen - 2.0 * lambda * lambda * quad;
1100        let hess = coef * (pen_prime * d - pen * pen) / (d * d) + lambda * tr_ainv_s
1101            - lambda * lambda * tr_ainv_s_sq;
1102
1103        // ∂μ̂/∂ρ = X (dβ̂/dρ) = −λ X v_s.
1104        let xv = fast_av(self.x, &v_s);
1105        let mu_rho_train = xv.mapv(|t| -lambda * t);
1106        let mu_rho_test = -lambda * self.x_star.dot(&v_s);
1107
1108        let cross = if let Some(zv) = z {
1109            let b = chol.solvevec(self.x_star); // dβ̂/dz
1110            let pen_z = 2.0 * lambda * sbeta.dot(&b);
1111            let r_star = zv - self.x_star.dot(&beta);
1112            let d_z = 2.0 * r_star;
1113            coef * (pen_z * d - pen * d_z) / (d * d)
1114        } else {
1115            0.0
1116        };
1117
1118        Ok(RemlEval {
1119            value,
1120            grad,
1121            hess,
1122            cross,
1123            mu_rho_train,
1124            mu_rho_test,
1125        })
1126    }
1127
1128    /// Public, value-only REML criterion (for FD verification of the gradient).
1129    pub fn penalized_laml_criterion(&self, rho: f64, z: Option<f64>) -> Result<f64, String> {
1130        Ok(self.eval(rho, z)?.value)
1131    }
1132
1133    /// `dρ̂/dz` at a stationary `(ρ, z)` via the outer IFT.
1134    pub fn drho_dz(&self, rho: f64, z: f64) -> Result<f64, String> {
1135        let ev = self.eval(rho, Some(z))?;
1136        if ev.hess.abs() < 1e-14 {
1137            return Err(
1138                "gaussian reml response: outer Hessian ∂²Ṽ/∂ρ² ≈ 0; dρ̂/dz singular".to_string(),
1139            );
1140        }
1141        Ok(-ev.cross / ev.hess)
1142    }
1143
1144    /// Select ρ̂ by REML: a coarse value scan over `ρ ∈ [−25, 25]` to seed,
1145    /// then safeguarded Newton on `G = 0`. Deterministic (no randomness, fixed
1146    /// grid), so it qualifies as the symmetric fitting map's smoothing choice.
1147    pub fn select_rho(&self, z: Option<f64>) -> Result<f64, String> {
1148        let (lo, hi, m) = (-25.0_f64, 25.0_f64, 60usize);
1149        let mut best = (f64::INFINITY, 0.0_f64);
1150        for k in 0..=m {
1151            let rho = lo + (hi - lo) * (k as f64) / (m as f64);
1152            if let Ok(ev) = self.eval(rho, z)
1153                && ev.value < best.0
1154            {
1155                best = (ev.value, rho);
1156            }
1157        }
1158        let mut rho = best.1;
1159        for _ in 0..100 {
1160            let ev = self.eval(rho, z)?;
1161            if !ev.hess.is_finite() || ev.hess <= 1e-12 {
1162                break;
1163            }
1164            let step = ev.grad / ev.hess;
1165            let new_rho = (rho - step).clamp(lo - 5.0, hi + 5.0);
1166            let delta = new_rho - rho;
1167            rho = new_rho;
1168            if delta.abs() < 1e-13 {
1169                break;
1170            }
1171        }
1172        Ok(rho)
1173    }
1174
1175    /// Honest membership at candidate `z`: re-select ρ̂(z) on the augmented
1176    /// data, fit, and apply the conformal rank rule. This IS the honest
1177    /// (ρ-re-selecting) full-conformal map, computed exactly per candidate.
1178    pub fn honest_membership(&self, z: f64, alpha: f64) -> Result<bool, String> {
1179        let rho = self.select_rho(Some(z))?;
1180        let lambda = gam_problem::checked_exp_log_strength(rho)
1181            .map_err(|error| format!("gaussian REML conformal response: {error}"))?;
1182        let p = self.p;
1183        let mut a = self.xtx.clone();
1184        for i in 0..p {
1185            for j in 0..p {
1186                a[[i, j]] += lambda * self.s[[i, j]] + self.x_star[i] * self.x_star[j];
1187            }
1188        }
1189        let chol = a
1190            .cholesky(Side::Lower)
1191            .map_err(|e| format!("gaussian reml response: honest A(λ) not SPD: {e:?}"))?;
1192        let mut c = self.xty.clone();
1193        for j in 0..p {
1194            c[j] += self.x_star[j] * z;
1195        }
1196        let beta = chol.solvevec(&c);
1197        let e_star = (z - self.x_star.dot(&beta)).abs();
1198        let xb = fast_av(self.x, &beta);
1199        let count = (0..self.n)
1200            .filter(|&i| (self.y[i] - xb[i]).abs() >= e_star)
1201            .count();
1202        Ok((1.0 + count as f64) > alpha * (self.n as f64 + 1.0))
1203    }
1204
1205    /// Run the certificate-first procedure: build the frozen-ρ exact set, then
1206    /// compute the conditional score perturbation a ρ re-selection could
1207    /// induce and decide whether the frozen set is accepted under the
1208    /// rho-grid Lipschitz assumption.
1209    ///
1210    /// The score-perturbation bound is `max_i(|∂μ̂_i/∂ρ| + |∂μ̂_*/∂ρ|) ·
1211    /// sup_z|ρ̂(z) − ρ̂₀|`, where the ρ-excursion is bounded over the set's
1212    /// finite deciding range by the worst probed `|ρ̂(z) − ρ̂₀|` plus a
1213    /// mean-value remainder from the observed probe-grid maximum of
1214    /// `|dρ̂/dz|`. This is a conditional check, not a continuous supremum
1215    /// proof: the returned diagnostics expose the probe count and observed
1216    /// derivative maximum.
1217    pub fn certified_full_conformal(&self, alpha: f64) -> Result<CertifiedFullConformal, String> {
1218        let rho0 = self.select_rho(None)?;
1219        let lambda0 = gam_problem::checked_exp_log_strength(rho0)
1220            .map_err(|error| format!("full conformal selected an invalid log strength: {error}"))?;
1221        let mut s_lambda = Array2::<f64>::zeros((self.p, self.p));
1222        for i in 0..self.p {
1223            for j in 0..self.p {
1224                s_lambda[[i, j]] = lambda0 * self.s[[i, j]];
1225            }
1226        }
1227        let weights = Array1::<f64>::ones(self.n);
1228        let engine =
1229            ExactGaussianFullConformal::new(self.x, self.y, &weights, &s_lambda, self.x_star)?;
1230        let frozen_set = engine.prediction_set(alpha);
1231
1232        // Collect the finite deciding endpoints. If there are none (set is ℝ
1233        // or empty), the margin has already been computed analytically. With
1234        // no finite range for the rho probes, accept only the score-independent
1235        // case where no comparison is needed; otherwise refuse instead of
1236        // pretending the unbounded rho excursion was checked.
1237        let mut endpoints: Vec<f64> = Vec::new();
1238        for itv in &frozen_set.intervals {
1239            for ep in [itv.lo, itv.hi] {
1240                if ep.is_finite() {
1241                    endpoints.push(ep);
1242                }
1243            }
1244        }
1245        if endpoints.is_empty() {
1246            let score_perturbation_bound = if frozen_set.boundary_margin == f64::INFINITY {
1247                0.0
1248            } else {
1249                f64::INFINITY
1250            };
1251            return Ok(CertifiedFullConformal {
1252                certificate: FrozenRhoCertificate::decide(
1253                    score_perturbation_bound,
1254                    frozen_set.boundary_margin,
1255                ),
1256                frozen_set,
1257                rho_frozen: rho0,
1258                rho_excursion: 0.0,
1259                score_rho_lipschitz: 0.0,
1260                rho_probe_count: 0,
1261                observed_sup_drho_dz: 0.0,
1262            });
1263        }
1264        endpoints.sort_by(|a, b| a.partial_cmp(b).expect("finite endpoints"));
1265        let z_lo = *endpoints.first().expect("non-empty");
1266        let z_hi = *endpoints.last().expect("non-empty");
1267
1268        // Probe grid spanning the deciding range; honest ρ̂(z) and dρ̂/dz at
1269        // each probe. The ρ-excursion sup is bounded by the worst observed
1270        // deviation plus the mean-value Lipschitz remainder over the range.
1271        let probes = 64usize;
1272        let mut max_dev = 0.0_f64;
1273        let mut observed_sup_drho_dz = 0.0_f64;
1274        let mut lip = 0.0_f64;
1275        // Lipschitz at the frozen optimum (un-augmented sensitivities).
1276        let ev0 = self.eval(rho0, None)?;
1277        for i in 0..self.n {
1278            lip = lip.max(ev0.mu_rho_train[i].abs() + ev0.mu_rho_test.abs());
1279        }
1280        for k in 0..=probes {
1281            let z = z_lo + (z_hi - z_lo) * (k as f64) / (probes as f64);
1282            let rho_z = self.select_rho(Some(z))?;
1283            max_dev = max_dev.max((rho_z - rho0).abs());
1284            if let Ok(d) = self.drho_dz(rho_z, z) {
1285                observed_sup_drho_dz = observed_sup_drho_dz.max(d.abs());
1286            }
1287            // Lipschitz also at the re-selected optimum (scores move with ρ).
1288            let evz = self.eval(rho_z, Some(z))?;
1289            for i in 0..self.n {
1290                lip = lip.max(evz.mu_rho_train[i].abs() + evz.mu_rho_test.abs());
1291            }
1292        }
1293        // Mean-value remainder under the explicit grid assumption: between
1294        // probes ρ̂ can drift by at most the observed derivative maximum times
1295        // the probe spacing, provided the continuous derivative supremum does
1296        // not exceed the observed maximum beyond this allowance.
1297        let spacing = (z_hi - z_lo) / (probes as f64);
1298        let rho_excursion = max_dev + observed_sup_drho_dz * spacing;
1299        let score_perturbation_bound = lip * rho_excursion;
1300        let certificate =
1301            FrozenRhoCertificate::decide(score_perturbation_bound, frozen_set.boundary_margin);
1302
1303        Ok(CertifiedFullConformal {
1304            frozen_set,
1305            certificate,
1306            rho_frozen: rho0,
1307            rho_excursion,
1308            score_rho_lipschitz: lip,
1309            rho_probe_count: probes + 1,
1310            observed_sup_drho_dz,
1311        })
1312    }
1313}
1314
1315// ─────────────────────────────────────────────────────────────────────────
1316// Layer 2 — continuous-GLM certified predictor–corrector homotopy in z
1317// ─────────────────────────────────────────────────────────────────────────
1318
1319/// Maximum number of certified continuation sub-steps the homotopy may
1320/// spend walking between two consecutive candidates before it gives up and
1321/// falls back to a cold deterministic refit. A work budget, not a tuning
1322/// knob: exceeding it can only cost SPEED (one extra cold fit), never
1323/// correctness — the fallback solves the same KKT system to its optimum.
1324const GLM_HOMOTOPY_MAX_SUBSTEPS: usize = 1024;
1325
1326/// Maximum step halvings per sub-step before the certificate's refusal is
1327/// treated as final and the cold-refit fallback fires.
1328const GLM_HOMOTOPY_MAX_HALVINGS: usize = 24;
1329
1330/// Maximum chord-corrector iterations per certified sub-step. With the
1331/// contraction constant certified below [`GLM_CONTRACTION_ACCEPT`], the
1332/// residual shrinks at least geometrically, so this budget is generous.
1333const GLM_CORRECTOR_MAX_ITERS: usize = 80;
1334
1335/// Maximum damped-Newton iterations for a cold augmented GLM fit.
1336const GLM_NEWTON_MAX_ITERS: usize = 200;
1337
1338/// Maximum Armijo backtracking halvings per cold Newton iteration.
1339const GLM_NEWTON_MAX_BACKTRACKS: usize = 60;
1340
1341/// Strict scale-invariant KKT tolerance declaring convergence, applied to the
1342/// RAW penalized gradient via [`GlmHomotopyFullConformal::kkt_converged`]
1343/// (dimension-scaled OR natural-scale relative — the same certificate the main
1344/// P-IRLS solver uses). NOT a tolerance on the preconditioned Newton step.
1345const GLM_CONVERGENCE_RTOL: f64 = 1e-12;
1346
1347/// Near-stationary acceptance tolerance: a stalled iterate sitting at the
1348/// floating-point floor of the raw gradient is still accepted when it
1349/// certifies KKT stationarity at this looser scale-invariant tolerance. The
1350/// COMPUTED error bound carried out of the step uses the actual residual, so
1351/// accepting a stall is honest — the bound is simply larger and the downstream
1352/// margin gate decides whether a cold refit is needed. Mirrors the main
1353/// solver's 10×-band `near_stationary_kkt`.
1354const GLM_STALL_ACCEPT_RTOL: f64 = 1e-8;
1355
1356/// Certified contraction constant below which a predictor step is accepted:
1357/// `κ < 1/2` makes the chord-corrector a contraction on the ball
1358/// `B(β_pred, 2‖H₀⁻¹F(β_pred)‖)`, which then provably contains the root.
1359const GLM_CONTRACTION_ACCEPT: f64 = 0.5;
1360
1361/// Armijo sufficient-decrease constant for the cold-fit line search —
1362/// sourced from the shared optimizer constants so the workspace has exactly
1363/// one `c₁`.
1364const GLM_ARMIJO_C1: f64 = opt::constants::ARMIJO_C1;
1365
1366/// `η` location of the extrema of the logistic third derivative
1367/// `b‴(η) = σ(1−σ)(1−2σ)`: `σ = (3±√3)/6 ⇔ η = ±ln(2+√3)`.
1368const LOGIT_THIRD_DERIV_CRITICAL_ETA: f64 = 1.316_957_896_924_816_6;
1369
1370#[inline]
1371fn vec_norm(v: &Array1<f64>) -> f64 {
1372    v.dot(v).sqrt()
1373}
1374
1375use gam_linalg::utils::stable_softplus as softplus;
1376
1377/// Canonical-link GLM families supported by the certified z-homotopy
1378/// ([`GlmHomotopyFullConformal`]). Canonical links make the candidate
1379/// response enter the augmented penalized score LINEARLY (`∂F/∂z = −x_*`),
1380/// so the exact response of the augmented optimum to the candidate is the
1381/// single solve `dβ̂/dz = H⁻¹ x_*` — no family-specific cross terms. The
1382/// per-η derivative tower `b′ = μ`, `b″ = w`, `b‴` is the K=1 specialization
1383/// of the row-kernel channels (`row_kernel` Hessian / `row_third_contracted`
1384/// in src/families/row_kernel.rs); it is carried analytically here because
1385/// the homotopy must evaluate the tower at MOVING β while a `RowKernel`
1386/// evaluates at its internally held coefficients.
1387#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1388pub enum CanonicalGlmFamily {
1389    /// Bernoulli response, logit link: `b(η) = log(1+eʸ)`, `μ = σ(η)`.
1390    BernoulliLogit,
1391    /// Poisson response, log link: `b(η) = eʸ`, `μ = eʸ`.
1392    PoissonLog,
1393}
1394
1395impl CanonicalGlmFamily {
1396    /// `μ(η) = b′(η)` — the canonical mean function.
1397    pub fn mean(&self, eta: f64) -> f64 {
1398        match self {
1399            Self::BernoulliLogit => {
1400                if eta >= 0.0 {
1401                    1.0 / (1.0 + (-eta).exp())
1402                } else {
1403                    let e = eta.exp();
1404                    e / (1.0 + e)
1405                }
1406            }
1407            Self::PoissonLog => eta.exp(),
1408        }
1409    }
1410
1411    /// `w(η) = b″(η)` — the canonical Fisher weight (strictly positive).
1412    pub fn weight(&self, eta: f64) -> f64 {
1413        match self {
1414            Self::BernoulliLogit => {
1415                let mu = self.mean(eta);
1416                mu * (1.0 - mu)
1417            }
1418            Self::PoissonLog => eta.exp(),
1419        }
1420    }
1421
1422    /// Per-row negative log-likelihood kernel `b(η) − y η` (the y-independent
1423    /// normalizer is dropped — it never moves the optimum).
1424    fn nll_term(&self, eta: f64, y: f64) -> f64 {
1425        match self {
1426            Self::BernoulliLogit => softplus(eta) - y * eta,
1427            Self::PoissonLog => eta.exp() - y * eta,
1428        }
1429    }
1430
1431    /// `sup { b″(η) : η ∈ [lo, hi] }` — COMPUTED interval bound on the
1432    /// Fisher weight, used to convert a coefficient-error bound into a
1433    /// mean-scale (score) error bound.
1434    fn weight_abs_sup(&self, lo: f64, hi: f64) -> f64 {
1435        match self {
1436            Self::BernoulliLogit => {
1437                if lo <= 0.0 && 0.0 <= hi {
1438                    0.25
1439                } else {
1440                    self.weight(lo).max(self.weight(hi))
1441                }
1442            }
1443            Self::PoissonLog => hi.exp(),
1444        }
1445    }
1446
1447    /// `sup { |b‴(η)| : η ∈ [lo, hi] }` — COMPUTED interval bound on the
1448    /// third-derivative channel (the K=1 `row_third_contracted` value). The
1449    /// logistic case checks the interval endpoints and the two interior
1450    /// critical points `η = ±ln(2+√3)` where `|b‴|` attains its global
1451    /// maximum `1/(6√3)`; the Poisson case is monotone (`b‴ = eʸ`).
1452    fn third_abs_sup(&self, lo: f64, hi: f64) -> f64 {
1453        match self {
1454            Self::BernoulliLogit => {
1455                let t = |eta: f64| {
1456                    let mu = self.mean(eta);
1457                    (mu * (1.0 - mu) * (1.0 - 2.0 * mu)).abs()
1458                };
1459                let mut sup = t(lo).max(t(hi));
1460                for c in [
1461                    -LOGIT_THIRD_DERIV_CRITICAL_ETA,
1462                    LOGIT_THIRD_DERIV_CRITICAL_ETA,
1463                ] {
1464                    if lo <= c && c <= hi {
1465                        sup = sup.max(t(c));
1466                    }
1467                }
1468                sup
1469            }
1470            Self::PoissonLog => hi.exp(),
1471        }
1472    }
1473
1474    /// Reject a training response outside the family's support — fitting a
1475    /// canonical GLM to an impossible response is a caller bug, not a
1476    /// numerical regime.
1477    fn validate_training_response(&self, y: f64, row: usize) -> Result<(), String> {
1478        if !y.is_finite() {
1479            return Err(format!("glm homotopy: non-finite response at row {row}"));
1480        }
1481        match self {
1482            Self::BernoulliLogit => {
1483                if !(0.0..=1.0).contains(&y) {
1484                    return Err(format!(
1485                        "glm homotopy: Bernoulli response must lie in [0, 1], got {y} at row {row}"
1486                    ));
1487                }
1488            }
1489            Self::PoissonLog => {
1490                if y < 0.0 {
1491                    return Err(format!(
1492                        "glm homotopy: Poisson response must be non-negative, got {y} at row {row}"
1493                    ));
1494                }
1495            }
1496        }
1497        Ok(())
1498    }
1499
1500    /// Reject a conformal candidate outside the family's response support —
1501    /// the full-conformal set is a subset of the support by definition.
1502    fn validate_candidate(&self, z: f64) -> Result<(), String> {
1503        if !z.is_finite() {
1504            return Err(format!("glm homotopy: non-finite candidate {z}"));
1505        }
1506        match self {
1507            Self::BernoulliLogit => {
1508                if !(0.0..=1.0).contains(&z) {
1509                    return Err(format!(
1510                        "glm homotopy: Bernoulli candidate must lie in [0, 1], got {z}"
1511                    ));
1512                }
1513            }
1514            Self::PoissonLog => {
1515                if z < 0.0 {
1516                    return Err(format!(
1517                        "glm homotopy: Poisson candidate must be non-negative, got {z}"
1518                    ));
1519                }
1520            }
1521        }
1522        Ok(())
1523    }
1524}
1525
1526/// One candidate's verdict together with the tracked coefficients and the
1527/// COMPUTED bound on their distance to the exact augmented optimum.
1528#[derive(Clone, Debug)]
1529pub struct GlmHomotopyCandidate {
1530    pub z: f64,
1531    /// Conformal p-value `(1 + #{i ≤ n : e_i ≥ e_*}) / (n+1)` (ties count
1532    /// FOR the candidate — the conservative `≥` convention shared with the
1533    /// discrete enumeration arm).
1534    pub p_value: f64,
1535    pub member: bool,
1536    /// The coefficients the verdict was computed from: the homotopy-tracked
1537    /// β̂(z) (chord-corrected to the augmented KKT root) or a cold refit.
1538    pub beta: Array1<f64>,
1539    /// Certified bound on `‖beta − β̂(z)‖₂` (distance to the EXACT augmented
1540    /// optimum), computed from the chord-contraction constant: with
1541    /// `r = ‖H₀⁻¹F(beta)‖` and certified `κ < ½` on `B(beta, 2r)`, the root
1542    /// lies in that ball and `‖beta − β̂(z)‖ ≤ r/(1−κ)`. `+∞` when the
1543    /// certificate refuses (the membership gate then forces a cold refit or
1544    /// reports the tie unresolved — never a silent guess).
1545    pub beta_error_bound: f64,
1546    /// Whether this candidate was decided from a cold deterministic refit
1547    /// (first candidate, certificate refusal, or margin-forced refit)
1548    /// rather than the tracked path.
1549    pub cold_refit: bool,
1550}
1551
1552/// The exact full-conformal set for a canonical-link GLM, assembled by the
1553/// certified predictor–corrector homotopy with cold-refit fallback.
1554#[derive(Clone, Debug)]
1555pub struct GlmHomotopyConformalSet {
1556    /// Retained candidates, ascending.
1557    pub members: Vec<f64>,
1558    pub candidates: Vec<GlmHomotopyCandidate>,
1559    pub alpha: f64,
1560    /// `n + 1`.
1561    pub n_augmented: usize,
1562    /// Number of candidate transitions where the step certificate refused
1563    /// (third-order bound too large within the halving/sub-step budget) and
1564    /// the engine fell back to a cold deterministic refit.
1565    pub refit_fallbacks: usize,
1566    /// Number of cold refits forced by the MEMBERSHIP margin gate: the
1567    /// tracked solution was certified, but a rank comparison was decided by
1568    /// a margin smaller than the propagated score-error bound, so the
1569    /// engine refused to call it from the tracked path.
1570    pub margin_refits: usize,
1571    /// Number of candidates whose verdict remained margin-ambiguous even
1572    /// after a cold refit (a genuine floating-point-level score tie). The
1573    /// reported verdict then uses the conservative `≥` tie convention — the
1574    /// direction that can only over-cover, never under-cover.
1575    pub ties_unresolved: usize,
1576    /// Largest certified `‖beta − β̂(z)‖` bound over all reported candidates.
1577    pub max_beta_error_bound: f64,
1578}
1579
1580struct GlmCandidateVerdict {
1581    p_value: f64,
1582    member: bool,
1583    decided: bool,
1584}
1585
1586/// Certified predictor–corrector homotopy in the candidate response `z` for
1587/// canonical-link GLMs (#942 Layer 2, continuous arm).
1588///
1589/// # The path being tracked
1590///
1591/// `β̂(z)` solves the augmented penalized score equation
1592///
1593/// ```text
1594///   F(β; z) = Σᵢ xᵢ (μ(ηᵢ) − yᵢ) + x_* (μ(η_*) − z) + Sλ β = 0 ,
1595/// ```
1596///
1597/// which for a canonical link is the gradient of a STRICTLY convex objective
1598/// (Fisher weights `b″ > 0`, `Sλ ⪰ 0`, `H` required SPD), so the root is
1599/// unique — there is no basin-tracking failure mode and the homotopy can be
1600/// wrong only about SPEED, never about the answer. Since `∂F/∂z = −x_*`,
1601///
1602/// ```text
1603///   dβ̂/dz = H(β̂)⁻¹ x_* ,   H(β) = XᵀW(β)X + w_*(β) x_*x_*ᵀ + Sλ .
1604/// ```
1605///
1606/// # The certified step
1607///
1608/// From a corrected point `β₀` at `z` with factored `H₀ = H(β₀)`:
1609///
1610/// 1. **Predictor:** `β_pred = β₀ + h·H₀⁻¹x_*`.
1611/// 2. **Certificate:** the corrector is the chord iteration
1612///    `β ← β − H₀⁻¹F(β; z+h)` on the already-factored `H₀`. Its contraction
1613///    constant on the ball `B(β_pred, R)`, `R = 2‖H₀⁻¹F(β_pred)‖`, is
1614///    bounded by the COMPUTED quantity
1615///
1616///    ```text
1617///      κ = [ Σᵢ Tᵢ·devᵢ·‖xᵢ‖² + T_*·dev_*·‖x_*‖² ] / λ_min(H₀) ,
1618///      devᵢ = |h·xᵢᵀH₀⁻¹x_*| + ‖xᵢ‖·R ,
1619///      Tᵢ   = sup |b‴| over [ηᵢ(β₀) − devᵢ , ηᵢ(β₀) + devᵢ]
1620///    ```
1621///
1622///    (`‖H(β)−H₀‖₂ ≤ Σᵢ |wᵢ(β)−wᵢ(β₀)|·‖xᵢ‖²` and `|Δwᵢ| ≤ Tᵢ·|Δηᵢ|` —
1623///    the third-derivative tower bounding the Hessian's Lipschitz drift,
1624///    exactly the `row_third_contracted` channel evaluated as an interval
1625///    bound). `κ < ½` makes the chord map a contraction of `B(β_pred, R)`
1626///    into itself, so the (unique) root lies in the ball and the corrector
1627///    converges to it geometrically.
1628/// 3. **Refusal:** `κ ≥ ½` halves `h`; exhausting the halving or sub-step
1629///    budget abandons the path for this transition and falls back to a COLD
1630///    deterministic refit — the homotopy is only an acceleration of the
1631///    defined symmetric fitting map, never a redefinition of it.
1632/// 4. **Carried bound:** at acceptance the distance to the exact root is
1633///    bounded by the computed `r/(1−κ_f)` with `r` the final corrector
1634///    residual and `κ_f` re-evaluated at the final iterate.
1635///
1636/// # Membership with a margin gate
1637///
1638/// Scores are response-scale absolute residuals. The β-error bound
1639/// propagates to each score through the computed interval weight bound
1640/// (`|Δμᵢ| ≤ sup b″·‖xᵢ‖·bound`); a rank comparison decided by a margin
1641/// smaller than the joint perturbation is NOT trusted: the engine cold-refits
1642/// and re-decides, and if the tie survives the refit it applies the
1643/// conservative `≥` convention and reports it in `ties_unresolved`.
1644/// Exact-or-refuse, end to end.
1645///
1646/// ρ is frozen at the supplied `s_lambda` by construction — the honest
1647/// smoothing re-selection and its certificate are Layer 3's domain.
1648pub struct GlmHomotopyFullConformal<'a> {
1649    family: CanonicalGlmFamily,
1650    x: &'a Array2<f64>,
1651    y: &'a Array1<f64>,
1652    s_lambda: &'a Array2<f64>,
1653    x_star: &'a Array1<f64>,
1654    n: usize,
1655    p: usize,
1656    /// `‖xᵢ‖₂` per training row.
1657    row_norm: Array1<f64>,
1658    /// `‖xᵢ‖₂²` per training row.
1659    row_sq: Array1<f64>,
1660    star_norm: f64,
1661    star_sq: f64,
1662}
1663
1664impl<'a> GlmHomotopyFullConformal<'a> {
1665    /// Build the engine. Rejects non-unit prior weights for the same reason
1666    /// as [`ExactGaussianFullConformal::new`]: a reweighted training row is
1667    /// not exchangeable with the test row, so the coverage proof would not
1668    /// apply.
1669    pub fn new(
1670        family: CanonicalGlmFamily,
1671        x: &'a Array2<f64>,
1672        y: &'a Array1<f64>,
1673        prior_weights: &Array1<f64>,
1674        s_lambda: &'a Array2<f64>,
1675        x_star: &'a Array1<f64>,
1676    ) -> Result<Self, String> {
1677        let n = x.nrows();
1678        let p = x.ncols();
1679        if y.len() != n || prior_weights.len() != n {
1680            return Err("glm homotopy: row-count mismatch".to_string());
1681        }
1682        if s_lambda.nrows() != p || s_lambda.ncols() != p || x_star.len() != p {
1683            return Err("glm homotopy: column-count mismatch".to_string());
1684        }
1685        if prior_weights.iter().any(|&w| (w - 1.0).abs() > 1e-12) {
1686            return Err(
1687                "glm homotopy full conformal requires unit prior weights: a reweighted \
1688                 training row is not exchangeable with the test row, so the finite-sample \
1689                 coverage proof does not apply; use the split/ALO conformal calibrator instead"
1690                    .to_string(),
1691            );
1692        }
1693        for (i, &yi) in y.iter().enumerate() {
1694            family.validate_training_response(yi, i)?;
1695        }
1696        let mut row_norm = Array1::<f64>::zeros(n);
1697        let mut row_sq = Array1::<f64>::zeros(n);
1698        for i in 0..n {
1699            let sq = x.row(i).dot(&x.row(i));
1700            row_sq[i] = sq;
1701            row_norm[i] = sq.sqrt();
1702        }
1703        let star_sq = x_star.dot(x_star);
1704        Ok(Self {
1705            family,
1706            x,
1707            y,
1708            s_lambda,
1709            x_star,
1710            n,
1711            p,
1712            row_norm,
1713            row_sq,
1714            star_norm: star_sq.sqrt(),
1715            star_sq,
1716        })
1717    }
1718
1719    /// Augmented penalized score `F(β; z)`.
1720    fn penalized_score(&self, beta: &Array1<f64>, z: f64) -> Array1<f64> {
1721        let eta = fast_av(self.x, beta);
1722        let mut resid = Array1::<f64>::zeros(self.n);
1723        for i in 0..self.n {
1724            resid[i] = self.family.mean(eta[i]) - self.y[i];
1725        }
1726        let mut g = self.x.t().dot(&resid) + self.s_lambda.dot(beta);
1727        let r_star = self.family.mean(self.x_star.dot(beta)) - z;
1728        for j in 0..self.p {
1729            g[j] += self.x_star[j] * r_star;
1730        }
1731        g
1732    }
1733
1734    /// Natural magnitude of the augmented penalized gradient, mirroring the
1735    /// main P-IRLS convergence certificate's `gradient_natural_scale`
1736    /// (`src/solver/pirls/state.rs`): `‖Xᵀ(μ − y)‖₂ + ‖Sβ‖₂` plus the test
1737    /// row's score contribution `‖x_*‖·|μ̂_* − z|`. The penalized score is a
1738    /// difference of these O(√(n+1)) sums, so at the optimum the raw gradient
1739    /// floor scales with this quantity, NOT with `(1 + ‖β‖)`. Dividing by
1740    /// `1 + this` yields a stationarity residual that is invariant under
1741    /// uniform rescaling of the objective and per-observation in meaning.
1742    fn gradient_natural_scale(&self, beta: &Array1<f64>, z: f64) -> f64 {
1743        let eta = fast_av(self.x, beta);
1744        let mut resid = Array1::<f64>::zeros(self.n);
1745        for i in 0..self.n {
1746            resid[i] = self.family.mean(eta[i]) - self.y[i];
1747        }
1748        let score = self.x.t().dot(&resid);
1749        let r_star = self.family.mean(self.x_star.dot(beta)) - z;
1750        vec_norm(&score) + vec_norm(&self.s_lambda.dot(beta)) + self.star_norm * r_star.abs()
1751    }
1752
1753    /// Dimension-based scale `√(n+1) · √p` for the structural KKT bound, with
1754    /// `n+1` counting the appended test row. Matches `kkt_dimension_scale` in
1755    /// the main P-IRLS state: under standardized columns the augmented score
1756    /// `Xᵀ(μ − y)` has components of order O(√(n+1)), so an absolute
1757    /// `‖g‖ < τ` test becomes systematically too tight as `n` grows. This
1758    /// scaling restores the advertised per-observation meaning of `τ`.
1759    fn kkt_dimension_scale(&self) -> f64 {
1760        (((self.n + 1) as f64).sqrt()) * ((self.p as f64).max(1.0).sqrt())
1761    }
1762
1763    /// Scale-invariant KKT acceptance on the RAW penalized gradient, exactly
1764    /// the `WorkingState::certifies_kkt` certificate the engine's main solver
1765    /// uses: the iterate certifies stationarity at tolerance `tol` under
1766    /// EITHER the dimension-scaled absolute bound OR the data-driven
1767    /// natural-scale relative bound. The earlier predicate compared the
1768    /// PRECONDITIONED Newton step `‖H⁻¹g‖` against `tol·(1 + ‖β‖)`, whose
1769    /// floating-point floor is `~ε·(n+1)/λ_min(H)` — n-dependent and not
1770    /// compensated by `(1 + ‖β‖)`, so genuinely-converged fits (e.g. raw
1771    /// gradient floor `3.6e-8` at moderate n) were rejected as non-converged.
1772    fn kkt_converged(&self, beta: &Array1<f64>, z: f64, tol: f64) -> bool {
1773        let g_norm = vec_norm(&self.penalized_score(beta, z));
1774        g_norm < tol * self.kkt_dimension_scale()
1775            || g_norm / (1.0 + self.gradient_natural_scale(beta, z)) < tol
1776    }
1777
1778    /// Augmented penalized NLL (line-search merit function).
1779    fn penalized_nll(&self, beta: &Array1<f64>, z: f64) -> f64 {
1780        let eta = fast_av(self.x, beta);
1781        let mut nll = 0.0;
1782        for i in 0..self.n {
1783            nll += self.family.nll_term(eta[i], self.y[i]);
1784        }
1785        nll += self.family.nll_term(self.x_star.dot(beta), z);
1786        nll + 0.5 * beta.dot(&self.s_lambda.dot(beta))
1787    }
1788
1789    /// Augmented penalized Hessian `H(β)` (independent of `z` — the
1790    /// candidate enters the score linearly under a canonical link).
1791    fn penalized_hessian(&self, beta: &Array1<f64>) -> Array2<f64> {
1792        let eta = fast_av(self.x, beta);
1793        let mut xw = self.x.to_owned();
1794        for i in 0..self.n {
1795            let w = self.family.weight(eta[i]);
1796            for j in 0..self.p {
1797                xw[[i, j]] *= w;
1798            }
1799        }
1800        let mut h = self.x.t().dot(&xw) + self.s_lambda;
1801        let w_star = self.family.weight(self.x_star.dot(beta));
1802        for a in 0..self.p {
1803            for b in 0..self.p {
1804                h[[a, b]] += w_star * self.x_star[a] * self.x_star[b];
1805            }
1806        }
1807        h
1808    }
1809
1810    /// The COMPUTED chord-contraction constant `κ` of the module doc: the
1811    /// Lipschitz drift of `H` over the stated η-intervals (per-row third
1812    /// derivative interval sups), divided by `λ_min(H₀)`. `shift[i]` is the
1813    /// known η-displacement of row i between the factorization point and the
1814    /// ball center; `radius` the coefficient-space ball radius around it.
1815    fn contraction_kappa(
1816        &self,
1817        eta0: &Array1<f64>,
1818        eta0_star: f64,
1819        shift: &Array1<f64>,
1820        shift_star: f64,
1821        radius: f64,
1822        lambda_min: f64,
1823    ) -> f64 {
1824        let mut drift = 0.0_f64;
1825        for i in 0..self.n {
1826            let dev = shift[i] + self.row_norm[i] * radius;
1827            let t_sup = self.family.third_abs_sup(eta0[i] - dev, eta0[i] + dev);
1828            drift += t_sup * dev * self.row_sq[i];
1829        }
1830        let dev_star = shift_star + self.star_norm * radius;
1831        drift += self
1832            .family
1833            .third_abs_sup(eta0_star - dev_star, eta0_star + dev_star)
1834            * dev_star
1835            * self.star_sq;
1836        drift / lambda_min
1837    }
1838
1839    /// Certified bound on `‖beta − β̂(z)‖` at a claimed optimum: fresh
1840    /// factorization, one residual solve, contraction certificate on the
1841    /// ball `B(beta, 2r)`. `+∞` on refusal — never an assumed zero.
1842    fn stationary_error_bound(&self, beta: &Array1<f64>, z: f64) -> f64 {
1843        let hess = self.penalized_hessian(beta);
1844        let Ok(eigs) = hess.eigh(Side::Lower) else {
1845            return f64::INFINITY;
1846        };
1847        let lambda_min = eigs.0.iter().copied().fold(f64::INFINITY, f64::min);
1848        if !(lambda_min > 0.0) {
1849            return f64::INFINITY;
1850        }
1851        let Ok(chol) = hess.cholesky(Side::Lower) else {
1852            return f64::INFINITY;
1853        };
1854        let r0 = vec_norm(&chol.solvevec(&self.penalized_score(beta, z)));
1855        let eta0 = fast_av(self.x, beta);
1856        let eta0_star = self.x_star.dot(beta);
1857        let zero_shift = Array1::<f64>::zeros(self.n);
1858        let kappa =
1859            self.contraction_kappa(&eta0, eta0_star, &zero_shift, 0.0, 2.0 * r0, lambda_min);
1860        if kappa.is_finite() && kappa < GLM_CONTRACTION_ACCEPT {
1861            r0 / (1.0 - kappa)
1862        } else {
1863            f64::INFINITY
1864        }
1865    }
1866
1867    /// Cold deterministic fit of the augmented problem at candidate `z`:
1868    /// damped Newton (full refactorization per iteration, Armijo
1869    /// backtracking on the convex penalized NLL) from `init`, run to the
1870    /// tight step tolerance. Returns the solution and its certified error
1871    /// bound. This IS the defined symmetric fitting map — the homotopy is
1872    /// only an acceleration of it.
1873    fn cold_fit(&self, z: f64, init: Array1<f64>) -> Result<(Array1<f64>, f64), String> {
1874        let mut beta = init;
1875        let mut nll = self.penalized_nll(&beta, z);
1876        if !nll.is_finite() {
1877            beta = Array1::<f64>::zeros(self.p);
1878            nll = self.penalized_nll(&beta, z);
1879        }
1880        let mut converged = false;
1881        for _ in 0..GLM_NEWTON_MAX_ITERS {
1882            let g = self.penalized_score(&beta, z);
1883            let hess = self.penalized_hessian(&beta);
1884            let chol = hess
1885                .cholesky(Side::Lower)
1886                .map_err(|e| format!("glm homotopy: augmented Hessian not SPD at z={z}: {e:?}"))?;
1887            let step = chol.solvevec(&g);
1888            if self.kkt_converged(&beta, z, GLM_CONVERGENCE_RTOL) {
1889                converged = true;
1890                break;
1891            }
1892            // gᵀH⁻¹g ≥ 0: the Newton direction is a descent direction.
1893            let decrease = g.dot(&step);
1894            let search = backtracking_line_search::<_, std::convert::Infallible>(
1895                BacktrackConfig {
1896                    initial_step: 1.0,
1897                    contraction: 0.5,
1898                    max_steps: GLM_NEWTON_MAX_BACKTRACKS,
1899                },
1900                |t| {
1901                    let mut cand = beta.clone();
1902                    cand.scaled_add(-t, &step);
1903                    let cand_nll = self.penalized_nll(&cand, z);
1904                    Ok(if cand_nll.is_finite() {
1905                        Some((cand_nll, cand))
1906                    } else {
1907                        None
1908                    })
1909                },
1910                |t, cand_nll| cand_nll <= nll - GLM_ARMIJO_C1 * t * decrease,
1911            );
1912            let accepted = match search {
1913                Ok(step) => step,
1914                Err(never) => match never {},
1915            };
1916            match accepted {
1917                Some(step) => {
1918                    beta = step.payload;
1919                    nll = step.value;
1920                }
1921                None => {
1922                    // The Armijo line search could not realize the predicted
1923                    // descent `½·gᵀH⁻¹g`. Near the optimum that decrease
1924                    // underflows the round-off of `penalized_nll` (`~ε·nll`),
1925                    // so a failed line search is the FLOOR of this Newton loop,
1926                    // not a true failure — the iterate is
1927                    // for-all-practical-purposes stationary. Stop iterating and
1928                    // let the certified error bound below decide acceptance
1929                    // (rather than rejecting on an un-improvable gradient
1930                    // floor).
1931                    break;
1932                }
1933            }
1934        }
1935        // Acceptance is decided by the COMPUTED coefficient-error bound, not by
1936        // a gradient-magnitude band. `stationary_error_bound` runs the chord
1937        // contraction certificate on a ball around the iterate: a finite value
1938        // PROVES the true optimum `β̂(z)` lies within `‖β − β̂(z)‖ ≤ bound`.
1939        // The Armijo/round-off floor of this Newton loop (`~√(ε·nll)`) can
1940        // exceed both the strict and the near-stationary gradient bands while
1941        // still being well inside a tight certified ball, so tying acceptance
1942        // to the certificate — the exact quantity the downstream margin gate
1943        // (`candidate_verdict`) consumes — is both honest (a larger bound only
1944        // widens the undecided band) and immune to the n-/scale-dependent
1945        // gradient floor that spuriously rejected reachable optima.
1946        let bound = self.stationary_error_bound(&beta, z);
1947        if !converged && !bound.is_finite() {
1948            // Neither the strict KKT band nor the contraction certificate could
1949            // confirm proximity to a stationary point: a genuine non-convergence.
1950            let g_norm = vec_norm(&self.penalized_score(&beta, z));
1951            let residual = g_norm / (1.0 + self.gradient_natural_scale(&beta, z));
1952            return Err(format!(
1953                "glm homotopy: cold fit did not converge at z={z} \
1954                 (uncertified; relative gradient residual {residual})"
1955            ));
1956        }
1957        Ok((beta, bound))
1958    }
1959
1960    /// Walk the corrected path from `z_from` (where `beta` solves the
1961    /// augmented KKT system) to `z_to` via certified predictor–corrector
1962    /// sub-steps. On success `beta` holds the corrected solution at `z_to`
1963    /// and the certified `‖beta − β̂(z_to)‖` bound is returned. `None` is a
1964    /// certified REFUSAL (budget exhausted, certificate never below ½, or a
1965    /// factorization failure) — the caller falls back to a cold refit; the
1966    /// refusal can cost speed only, never correctness.
1967    fn track(&self, beta: &mut Array1<f64>, z_from: f64, z_to: f64) -> Option<f64> {
1968        let mut z = z_from;
1969        let mut h = z_to - z_from;
1970        let mut arrival_bound = f64::INFINITY;
1971        for _ in 0..GLM_HOMOTOPY_MAX_SUBSTEPS {
1972            let remaining = z_to - z;
1973            if remaining <= 0.0 {
1974                return Some(arrival_bound);
1975            }
1976            h = h.min(remaining);
1977            let hess = self.penalized_hessian(beta);
1978            let lambda_min = hess
1979                .eigh(Side::Lower)
1980                .ok()?
1981                .0
1982                .iter()
1983                .copied()
1984                .fold(f64::INFINITY, f64::min);
1985            if !(lambda_min > 0.0) {
1986                return None;
1987            }
1988            let chol = hess.cholesky(Side::Lower).ok()?;
1989            let b_dir = chol.solvevec(self.x_star);
1990            let eta0 = fast_av(self.x, beta);
1991            let eta0_star = self.x_star.dot(beta);
1992            let xb = fast_av(self.x, &b_dir);
1993            let xb_star = self.x_star.dot(&b_dir);
1994
1995            let mut accepted = false;
1996            for _ in 0..=GLM_HOMOTOPY_MAX_HALVINGS {
1997                let h_eff = h.min(z_to - z);
1998                let z_new = if h_eff >= z_to - z { z_to } else { z + h_eff };
1999                let mut beta_pred = beta.clone();
2000                beta_pred.scaled_add(h_eff, &b_dir);
2001                let s0 = chol.solvevec(&self.penalized_score(&beta_pred, z_new));
2002                let r0 = vec_norm(&s0);
2003                let radius = 2.0 * r0;
2004                let shift = xb.mapv(|t| (h_eff * t).abs());
2005                let kappa = self.contraction_kappa(
2006                    &eta0,
2007                    eta0_star,
2008                    &shift,
2009                    (h_eff * xb_star).abs(),
2010                    radius,
2011                    lambda_min,
2012                );
2013                if kappa.is_finite() && kappa < GLM_CONTRACTION_ACCEPT {
2014                    // Chord corrector on the already-factored H₀: certified
2015                    // geometric contraction toward the unique root.
2016                    let mut bcur = beta_pred;
2017                    let mut step = s0;
2018                    let mut r = r0;
2019                    for _ in 0..GLM_CORRECTOR_MAX_ITERS {
2020                        if self.kkt_converged(&bcur, z_new, GLM_CONVERGENCE_RTOL) {
2021                            break;
2022                        }
2023                        let mut next = bcur.clone();
2024                        next.scaled_add(-1.0, &step);
2025                        let next_step = chol.solvevec(&self.penalized_score(&next, z_new));
2026                        let r_next = vec_norm(&next_step);
2027                        if !(r_next < r) {
2028                            // Floating-point floor: stop here; acceptance is
2029                            // decided by the residual level below.
2030                            break;
2031                        }
2032                        bcur = next;
2033                        step = next_step;
2034                        r = r_next;
2035                    }
2036                    if self.kkt_converged(&bcur, z_new, GLM_STALL_ACCEPT_RTOL) {
2037                        // Re-certify at the final iterate and carry the
2038                        // COMPUTED distance-to-root bound.
2039                        let mut diff = bcur.clone();
2040                        diff.scaled_add(-1.0, beta);
2041                        let shift_fin = fast_av(self.x, &diff).mapv(f64::abs);
2042                        let kappa_fin = self.contraction_kappa(
2043                            &eta0,
2044                            eta0_star,
2045                            &shift_fin,
2046                            self.x_star.dot(&diff).abs(),
2047                            2.0 * r,
2048                            lambda_min,
2049                        );
2050                        if kappa_fin.is_finite() && kappa_fin < GLM_CONTRACTION_ACCEPT {
2051                            arrival_bound = r / (1.0 - kappa_fin);
2052                            *beta = bcur;
2053                            z = z_new;
2054                            // Grow the trial step on an easy acceptance.
2055                            h = 2.0 * h_eff;
2056                            accepted = true;
2057                            break;
2058                        }
2059                    }
2060                }
2061                h = 0.5 * h_eff;
2062                if !(h > 0.0) {
2063                    return None;
2064                }
2065            }
2066            if !accepted {
2067                return None;
2068            }
2069        }
2070        if z_to - z <= 0.0 {
2071            Some(arrival_bound)
2072        } else {
2073            None
2074        }
2075    }
2076
2077    /// Propagated score-error bound for one row: `|Δe| ≤ |Δμ| ≤
2078    /// sup b″ · ‖x‖ · bound`, with the weight sup COMPUTED over the η-interval
2079    /// the coefficient ball can reach.
2080    fn score_delta(&self, eta: f64, x_norm: f64, beta_error_bound: f64) -> f64 {
2081        if beta_error_bound == 0.0 {
2082            return 0.0;
2083        }
2084        if !beta_error_bound.is_finite() {
2085            return f64::INFINITY;
2086        }
2087        let dev = x_norm * beta_error_bound;
2088        self.family.weight_abs_sup(eta - dev, eta + dev) * dev
2089    }
2090
2091    /// Rank the candidate with the margin gate: `decided` is true iff every
2092    /// possible score perturbation within the certified bound leaves the
2093    /// membership verdict unchanged.
2094    fn candidate_verdict(
2095        &self,
2096        z: f64,
2097        alpha: f64,
2098        beta: &Array1<f64>,
2099        beta_error_bound: f64,
2100    ) -> GlmCandidateVerdict {
2101        let eta = fast_av(self.x, beta);
2102        let eta_star = self.x_star.dot(beta);
2103        let e_star = (z - self.family.mean(eta_star)).abs();
2104        let delta_star = self.score_delta(eta_star, self.star_norm, beta_error_bound);
2105        let mut count = 0usize;
2106        let mut count_certain = 0usize;
2107        let mut count_possible = 0usize;
2108        for i in 0..self.n {
2109            let e_i = (self.y[i] - self.family.mean(eta[i])).abs();
2110            let tol = self.score_delta(eta[i], self.row_norm[i], beta_error_bound) + delta_star;
2111            let gap = e_i - e_star;
2112            if gap >= 0.0 {
2113                count += 1;
2114            }
2115            if gap >= tol {
2116                count_certain += 1;
2117            }
2118            if gap >= -tol {
2119                count_possible += 1;
2120            }
2121        }
2122        let n1 = (self.n + 1) as f64;
2123        let member = (1.0 + count as f64) > alpha * n1;
2124        let member_lo = (1.0 + count_certain as f64) > alpha * n1;
2125        let member_hi = (1.0 + count_possible as f64) > alpha * n1;
2126        GlmCandidateVerdict {
2127            p_value: (1.0 + count as f64) / n1,
2128            member,
2129            decided: member_lo == member_hi,
2130        }
2131    }
2132
2133    /// Assemble the exact full-conformal set over the (strictly increasing)
2134    /// candidate list: cold fit at the first candidate, certified homotopy
2135    /// tracking between consecutive candidates with cold-refit fallback on
2136    /// certificate refusal, and the margin gate on every verdict.
2137    pub fn prediction_set(
2138        &self,
2139        candidates: &[f64],
2140        alpha: f64,
2141    ) -> Result<GlmHomotopyConformalSet, String> {
2142        if candidates.is_empty() {
2143            return Err("glm homotopy: empty candidate list".to_string());
2144        }
2145        if !(0.0..1.0).contains(&alpha) {
2146            return Err(format!(
2147                "glm homotopy: alpha must be in [0, 1), got {alpha}"
2148            ));
2149        }
2150        if candidates.windows(2).any(|w| !(w[0] < w[1])) {
2151            return Err("glm homotopy: candidates must be strictly increasing".to_string());
2152        }
2153        for &z in candidates {
2154            self.family.validate_candidate(z)?;
2155        }
2156
2157        let (mut beta, mut bound) = self.cold_fit(candidates[0], Array1::<f64>::zeros(self.p))?;
2158        let mut out: Vec<GlmHomotopyCandidate> = Vec::with_capacity(candidates.len());
2159        let mut members: Vec<f64> = Vec::new();
2160        let mut refit_fallbacks = 0usize;
2161        let mut margin_refits = 0usize;
2162        let mut ties_unresolved = 0usize;
2163        let mut max_bound = 0.0_f64;
2164        let mut prev_z = candidates[0];
2165        for (idx, &z) in candidates.iter().enumerate() {
2166            let mut cold = idx == 0;
2167            if idx > 0 {
2168                match self.track(&mut beta, prev_z, z) {
2169                    Some(b) => bound = b,
2170                    None => {
2171                        let (refit_beta, refit_bound) = self.cold_fit(z, beta.clone())?;
2172                        beta = refit_beta;
2173                        bound = refit_bound;
2174                        refit_fallbacks += 1;
2175                        cold = true;
2176                    }
2177                }
2178            }
2179            let mut verdict = self.candidate_verdict(z, alpha, &beta, bound);
2180            if !verdict.decided && !cold {
2181                let (refit_beta, refit_bound) = self.cold_fit(z, beta.clone())?;
2182                beta = refit_beta;
2183                bound = refit_bound;
2184                cold = true;
2185                margin_refits += 1;
2186                verdict = self.candidate_verdict(z, alpha, &beta, bound);
2187            }
2188            if !verdict.decided {
2189                ties_unresolved += 1;
2190            }
2191            if bound.is_finite() {
2192                max_bound = max_bound.max(bound);
2193            } else {
2194                max_bound = f64::INFINITY;
2195            }
2196            if verdict.member {
2197                members.push(z);
2198            }
2199            out.push(GlmHomotopyCandidate {
2200                z,
2201                p_value: verdict.p_value,
2202                member: verdict.member,
2203                beta: beta.clone(),
2204                beta_error_bound: bound,
2205                cold_refit: cold,
2206            });
2207            prev_z = z;
2208        }
2209        Ok(GlmHomotopyConformalSet {
2210            members,
2211            candidates: out,
2212            alpha,
2213            n_augmented: self.n + 1,
2214            refit_fallbacks,
2215            margin_refits,
2216            ties_unresolved,
2217            max_beta_error_bound: max_bound,
2218        })
2219    }
2220}
2221
2222// ─────────────────────────────────────────────────────────────────────────
2223// Jackknife+ / CV+ (Barber, Candès, Ramdas & Tibshirani 2021)
2224// ─────────────────────────────────────────────────────────────────────────
2225
2226/// A jackknife+ (or CV+) prediction interval at miscoverage `α`, with the
2227/// honest ±∞ convention of the split-conformal calibrator: when `n` is too
2228/// small for the required order statistic to exist, the corresponding
2229/// endpoint is infinite rather than silently clipped.
2230#[derive(Clone, Copy, Debug)]
2231pub struct JackknifePlusInterval {
2232    pub lo: f64,
2233    pub hi: f64,
2234    pub alpha: f64,
2235    /// Number of leave-one-out (or out-of-fold) residual/prediction pairs.
2236    pub n: usize,
2237}
2238
2239impl JackknifePlusInterval {
2240    /// Whether both endpoints are finite (enough points to certify the
2241    /// requested level).
2242    pub fn certifies_finite(&self) -> bool {
2243        self.lo.is_finite() && self.hi.is_finite()
2244    }
2245}
2246
2247/// The jackknife+ interval assembly of Barber et al. (2021), exact order
2248/// statistics:
2249///
2250/// ```text
2251///   Ĉ_α = [ q̂⁻_α { μ̂₋ᵢ(x_*) − Rᵢ } ,  q̂⁺_α { μ̂₋ᵢ(x_*) + Rᵢ } ]
2252/// ```
2253///
2254/// where `Rᵢ = |yᵢ − μ̂₋ᵢ(xᵢ)|` are the leave-one-out absolute residuals,
2255/// `q̂⁺_α` is the `⌈(1−α)(n+1)⌉`-th smallest value (1-based; `+∞` when that
2256/// rank exceeds `n`), and `q̂⁻_α` the `⌊α(n+1)⌋`-th smallest (`−∞` when that
2257/// rank is below 1). Guarantee: `P(Y_* ∈ Ĉ_α) ≥ 1 − 2α` for exchangeable
2258/// data and a symmetric fitting map — no model correctness assumed.
2259///
2260/// The CV+ variant is THIS SAME assembly fed with K-fold out-of-fold
2261/// quantities (`μ̂₋ₖ₍ᵢ₎(x_*)` and `Rᵢ = |yᵢ − μ̂₋ₖ₍ᵢ₎(xᵢ)|`), so no second code
2262/// path exists to drift — but its GUARANTEE is weaker, not identical: K-fold
2263/// folds do not have leave-one-out symmetry, and Barber et al. (2021, Thm 4)
2264/// prove only `P(Y_* ∈ Ĉ_α) ≥ 1 − 2α − (1 − K/n)/(K + 1)` for CV+. The extra
2265/// slack vanishes at K = n (where CV+ IS jackknife+); any CV+ caller must
2266/// state that bound, not the jackknife+ one.
2267pub fn jackknife_plus_interval(
2268    loo_test_predictions: &Array1<f64>,
2269    loo_abs_residuals: &Array1<f64>,
2270    alpha: f64,
2271) -> Result<JackknifePlusInterval, String> {
2272    let n = loo_test_predictions.len();
2273    if n == 0 {
2274        return Err("jackknife+: empty leave-one-out inputs".to_string());
2275    }
2276    if loo_abs_residuals.len() != n {
2277        return Err(format!(
2278            "jackknife+: {} predictions but {} residuals",
2279            n,
2280            loo_abs_residuals.len()
2281        ));
2282    }
2283    if !(alpha.is_finite() && alpha > 0.0 && alpha < 1.0) {
2284        return Err(format!("jackknife+: alpha must be in (0, 1), got {alpha}"));
2285    }
2286    for (i, (&m, &r)) in loo_test_predictions
2287        .iter()
2288        .zip(loo_abs_residuals.iter())
2289        .enumerate()
2290    {
2291        if !m.is_finite() {
2292            return Err(format!(
2293                "jackknife+: non-finite LOO prediction at index {i}"
2294            ));
2295        }
2296        if !(r.is_finite() && r >= 0.0) {
2297            return Err(format!(
2298                "jackknife+: LOO residual at index {i} must be finite and non-negative, got {r}"
2299            ));
2300        }
2301    }
2302    let n1 = (n + 1) as f64;
2303    let rank_hi = (n1 * (1.0 - alpha)).ceil() as usize;
2304    let rank_lo = (n1 * alpha).floor() as usize;
2305    let hi = if rank_hi > n {
2306        f64::INFINITY
2307    } else {
2308        let mut upper: Vec<f64> = (0..n)
2309            .map(|i| loo_test_predictions[i] + loo_abs_residuals[i])
2310            .collect();
2311        upper.sort_by(|a, b| a.partial_cmp(b).expect("finite jackknife+ endpoints"));
2312        upper[rank_hi - 1]
2313    };
2314    let lo = if rank_lo < 1 {
2315        f64::NEG_INFINITY
2316    } else {
2317        let mut lower: Vec<f64> = (0..n)
2318            .map(|i| loo_test_predictions[i] - loo_abs_residuals[i])
2319            .collect();
2320        lower.sort_by(|a, b| a.partial_cmp(b).expect("finite jackknife+ endpoints"));
2321        lower[rank_lo - 1]
2322    };
2323    Ok(JackknifePlusInterval { lo, hi, alpha, n })
2324}
2325
2326/// EXACT jackknife+ for the penalized Gaussian-identity fit at frozen `Sλ`,
2327/// with the leave-one-out quantities computed in closed form (no refits):
2328/// the LOO fit is a rank-one Sherman–Morrison downdate of the single
2329/// factored normal matrix `M = XᵀX + Sλ`, so
2330///
2331/// ```text
2332///   rᵢ = yᵢ − xᵢᵀβ̂ ,  hᵢ = xᵢᵀM⁻¹xᵢ ,
2333///   Rᵢ = |rᵢ| / (1 − hᵢ) ,                    (exact LOO residual)
2334///   μ̂₋ᵢ(x_*) = x_*ᵀβ̂ − (x_*ᵀM⁻¹xᵢ)·rᵢ/(1 − hᵢ)   (exact LOO test prediction)
2335/// ```
2336///
2337/// — the same factored-Hessian leave-one-out algebra the ALO module
2338/// (src/inference/alo.rs) applies on the working-response scale, specialized
2339/// here to the Gaussian-identity case where it is exact rather than
2340/// approximate. Unit prior weights are required for the exchangeability
2341/// guarantee, as everywhere in this module.
2342pub fn gaussian_jackknife_plus(
2343    x: &Array2<f64>,
2344    y: &Array1<f64>,
2345    prior_weights: &Array1<f64>,
2346    s_lambda: &Array2<f64>,
2347    x_star: &Array1<f64>,
2348    alpha: f64,
2349) -> Result<JackknifePlusInterval, String> {
2350    let n = x.nrows();
2351    let p = x.ncols();
2352    if y.len() != n || prior_weights.len() != n {
2353        return Err("gaussian jackknife+: row-count mismatch".to_string());
2354    }
2355    if s_lambda.nrows() != p || s_lambda.ncols() != p || x_star.len() != p {
2356        return Err("gaussian jackknife+: column-count mismatch".to_string());
2357    }
2358    if prior_weights.iter().any(|&w| (w - 1.0).abs() > 1e-12) {
2359        return Err(
2360            "gaussian jackknife+ requires unit prior weights: a reweighted training row \
2361             is not exchangeable with the test row, so the finite-sample coverage proof \
2362             does not apply"
2363                .to_string(),
2364        );
2365    }
2366    let m = x.t().dot(x) + s_lambda;
2367    let chol = m
2368        .cholesky(Side::Lower)
2369        .map_err(|e| format!("gaussian jackknife+: normal matrix not SPD: {e:?}"))?;
2370    let beta = chol.solvevec(&x.t().dot(y));
2371    let mu = fast_av(x, &beta);
2372    let mu_star = x_star.dot(&beta);
2373    let b = chol.solvevec(x_star);
2374    let xt = x.t().as_standard_layout().into_owned();
2375    let minv_xt = chol.solve_mat(&xt);
2376    let mut loo_preds = Array1::<f64>::zeros(n);
2377    let mut loo_resids = Array1::<f64>::zeros(n);
2378    for i in 0..n {
2379        let h_i = x.row(i).dot(&minv_xt.column(i));
2380        let one_minus_h = 1.0 - h_i;
2381        if !(one_minus_h > 1e-10) {
2382            return Err(format!(
2383                "gaussian jackknife+: leverage hᵢ = {h_i} at row {i} leaves no leave-one-out \
2384                 information (1 − hᵢ ≤ 1e-10); the rank-one downdate is exact only for hᵢ < 1"
2385            ));
2386        }
2387        let r_i = y[i] - mu[i];
2388        let c_i = x.row(i).dot(&b); // x_*ᵀ M⁻¹ xᵢ by symmetry
2389        loo_resids[i] = (r_i / one_minus_h).abs();
2390        loo_preds[i] = mu_star - c_i * r_i / one_minus_h;
2391    }
2392    jackknife_plus_interval(&loo_preds, &loo_resids, alpha)
2393}
2394
2395/// Test-point-independent sufficient statistics for the exact penalized
2396/// Gaussian-identity jackknife+, factored ONCE from `(X, y, Sλ)` so any number
2397/// of test points reuse the single Cholesky of `M = XᵀX + Sλ`.
2398///
2399/// For each training row `i` the leave-one-out fit is the rank-one
2400/// Sherman–Morrison downdate of `M`, giving (in closed form, no refits):
2401///
2402/// ```text
2403///   vᵢ = M⁻¹ xᵢ                         (p-vector, one column of M⁻¹Xᵀ)
2404///   hᵢ = xᵢᵀ vᵢ ,  cᵢ = rᵢ / (1 − hᵢ)   (signed LOO residual)
2405///   Rᵢ = |cᵢ|                            (LOO absolute residual)
2406/// ```
2407///
2408/// At a test point `x_*` the LOO prediction is then a single inner product per
2409/// row, `μ̂₋ᵢ(x_*) = x_*ᵀβ̂ − (x_*ᵀvᵢ)·cᵢ`, so [`interval`](Self::interval) is
2410/// `O(n·p)` after the `O(n·p²)` factorization here. This is the substrate the
2411/// `predict(interval=level)` magic default replays: the stats are exactly the
2412/// `{vᵢ, cᵢ, Rᵢ}` of `gaussian_jackknife_plus`, which is recovered exactly when
2413/// fed a single `x_*` (the in-module test asserts that equivalence).
2414///
2415/// Unit prior weights are required, as everywhere in this module: a reweighted
2416/// training row is not exchangeable with the test row, so the finite-sample
2417/// coverage proof does not apply. The constructor rejects non-unit weights and
2418/// rows with `1 − hᵢ ≤ 1e-10` (no leave-one-out information).
2419#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2420pub struct GaussianJackknifePlusStats {
2421    /// Fitted coefficients `β̂ = M⁻¹Xᵀy`.
2422    beta: Array1<f64>,
2423    /// `M⁻¹Xᵀ` (p × n): column `i` is `vᵢ = M⁻¹xᵢ`.
2424    minv_xt: Array2<f64>,
2425    /// Signed leave-one-out residuals `cᵢ = rᵢ/(1 − hᵢ)` (n).
2426    signed_loo: Array1<f64>,
2427    /// Absolute leave-one-out residuals `Rᵢ = |cᵢ|` (n).
2428    abs_loo: Array1<f64>,
2429}
2430
2431impl GaussianJackknifePlusStats {
2432    /// Factor the test-point-independent jackknife+ statistics from the
2433    /// training design, response, prior weights, and penalty matrix.
2434    pub fn new(
2435        x: &Array2<f64>,
2436        y: &Array1<f64>,
2437        prior_weights: &Array1<f64>,
2438        s_lambda: &Array2<f64>,
2439    ) -> Result<Self, String> {
2440        let n = x.nrows();
2441        let p = x.ncols();
2442        if y.len() != n || prior_weights.len() != n {
2443            return Err("gaussian jackknife+ stats: row-count mismatch".to_string());
2444        }
2445        if s_lambda.nrows() != p || s_lambda.ncols() != p {
2446            return Err("gaussian jackknife+ stats: column-count mismatch".to_string());
2447        }
2448        if prior_weights.iter().any(|&w| (w - 1.0).abs() > 1e-12) {
2449            return Err(
2450                "gaussian jackknife+ requires unit prior weights: a reweighted training row \
2451                 is not exchangeable with the test row, so the finite-sample coverage proof \
2452                 does not apply"
2453                    .to_string(),
2454            );
2455        }
2456        let m = x.t().dot(x) + s_lambda;
2457        Self::from_design_and_normal_matrix(x, y, &m)
2458    }
2459
2460    /// Same exact jackknife+ statistics as [`new`](Self::new), but the
2461    /// penalized normal matrix `M = XᵀX + Sλ` is supplied directly rather than
2462    /// reassembled from `Sλ`. For a Gaussian-identity unit-weight fit the
2463    /// converged penalized Hessian stored in [`FitGeometry`] *is* this `M` (the
2464    /// working weights are unity and the matrix is dispersion-unscaled), so
2465    /// persisting the design + `M` at fit time and replaying through this
2466    /// constructor reproduces the certified interval with no penalty
2467    /// re-derivation — the seam the saved-model `predict(interval=…)` magic
2468    /// uses.
2469    ///
2470    /// `prior_weights` is validated to be unity for the exchangeability
2471    /// guarantee, identically to [`new`](Self::new).
2472    pub fn from_design_unit_weight_normal_matrix(
2473        x: &Array2<f64>,
2474        y: &Array1<f64>,
2475        prior_weights: &Array1<f64>,
2476        m: &Array2<f64>,
2477    ) -> Result<Self, String> {
2478        let n = x.nrows();
2479        if y.len() != n || prior_weights.len() != n {
2480            return Err("gaussian jackknife+ stats: row-count mismatch".to_string());
2481        }
2482        if prior_weights.iter().any(|&w| (w - 1.0).abs() > 1e-12) {
2483            return Err(
2484                "gaussian jackknife+ requires unit prior weights: a reweighted training row \
2485                 is not exchangeable with the test row, so the finite-sample coverage proof \
2486                 does not apply"
2487                    .to_string(),
2488            );
2489        }
2490        Self::from_design_and_normal_matrix(x, y, m)
2491    }
2492
2493    fn from_design_and_normal_matrix(
2494        x: &Array2<f64>,
2495        y: &Array1<f64>,
2496        m: &Array2<f64>,
2497    ) -> Result<Self, String> {
2498        let n = x.nrows();
2499        let p = x.ncols();
2500        if y.len() != n {
2501            return Err("gaussian jackknife+ stats: row-count mismatch".to_string());
2502        }
2503        if m.nrows() != p || m.ncols() != p {
2504            return Err("gaussian jackknife+ stats: normal-matrix shape mismatch".to_string());
2505        }
2506        let chol = m
2507            .cholesky(Side::Lower)
2508            .map_err(|e| format!("gaussian jackknife+ stats: normal matrix not SPD: {e:?}"))?;
2509        let beta = chol.solvevec(&x.t().dot(y));
2510        let mu = fast_av(x, &beta);
2511        let xt = x.t().as_standard_layout().into_owned();
2512        let minv_xt = chol.solve_mat(&xt);
2513        let mut signed_loo = Array1::<f64>::zeros(n);
2514        let mut abs_loo = Array1::<f64>::zeros(n);
2515        for i in 0..n {
2516            let h_i = x.row(i).dot(&minv_xt.column(i));
2517            let one_minus_h = 1.0 - h_i;
2518            if !(one_minus_h > 1e-10) {
2519                return Err(format!(
2520                    "gaussian jackknife+ stats: leverage hᵢ = {h_i} at row {i} leaves no \
2521                     leave-one-out information (1 − hᵢ ≤ 1e-10); the rank-one downdate is \
2522                     exact only for hᵢ < 1"
2523                ));
2524            }
2525            let c_i = (y[i] - mu[i]) / one_minus_h;
2526            signed_loo[i] = c_i;
2527            abs_loo[i] = c_i.abs();
2528        }
2529        Ok(Self {
2530            beta,
2531            minv_xt,
2532            signed_loo,
2533            abs_loo,
2534        })
2535    }
2536
2537    /// Number of training rows backing the leave-one-out construction.
2538    pub fn n(&self) -> usize {
2539        self.abs_loo.len()
2540    }
2541
2542    /// Coefficient dimension `p`.
2543    pub fn p(&self) -> usize {
2544        self.beta.len()
2545    }
2546
2547    /// Full-model coefficient vector `β̂ = M⁻¹Xᵀy`. The plug-in mean at a
2548    /// test point `x_*` is `x_*ᵀ β̂`. Exposed so the pyffi layer can emit the
2549    /// `mean` / `linear_predictor` columns alongside the jackknife+ bounds
2550    /// without re-running the predictor stack.
2551    pub fn beta(&self) -> &Array1<f64> {
2552        &self.beta
2553    }
2554
2555    /// Jackknife+ interval at one test row `x_*` and miscoverage `alpha`,
2556    /// returning the Barber et al. (2021) set with guarantee
2557    /// `P(Y_* ∈ Ĉ) ≥ 1 − 2·alpha`.
2558    pub fn interval(
2559        &self,
2560        x_star: &Array1<f64>,
2561        alpha: f64,
2562    ) -> Result<JackknifePlusInterval, String> {
2563        let p = self.beta.len();
2564        if x_star.len() != p {
2565            return Err(format!(
2566                "gaussian jackknife+ stats: x_* has {} entries but the fit has {p} coefficients",
2567                x_star.len()
2568            ));
2569        }
2570        let n = self.abs_loo.len();
2571        let mu_star = x_star.dot(&self.beta);
2572        let mut loo_preds = Array1::<f64>::zeros(n);
2573        for i in 0..n {
2574            // x_*ᵀ vᵢ = x_*ᵀ M⁻¹ xᵢ.
2575            let c = x_star.dot(&self.minv_xt.column(i));
2576            loo_preds[i] = mu_star - c * self.signed_loo[i];
2577        }
2578        jackknife_plus_interval(&loo_preds, &self.abs_loo, alpha)
2579    }
2580}
2581
2582/// Persistable substrate for the EXACT Gaussian-identity full-conformal set
2583/// (#942 Layer 1 + the Layer-3 frozen-ρ self-diagnostic), the analogue of
2584/// [`GaussianJackknifePlusStats`] for the exact set.
2585///
2586/// Unlike jackknife+, the exact full-conformal set has no test-point-independent
2587/// factorization: every test covariate `x_*` enters the augmented normal matrix
2588/// `M = XᵀX + x_*x_*ᵀ + Sλ`, so the substrate persists the training design `X`,
2589/// response `y`, and the (frozen) penalty `Sλ` and rebuilds
2590/// [`ExactGaussianFullConformal`] per test row — one Cholesky per test point,
2591/// zero refits. Valid for any penalized smooth with an arbitrary `Sλ` and basis.
2592///
2593/// `Sλ` is recovered once at fit time from the converged penalized Hessian
2594/// `M₀ = XᵀX + Sλ` (the Gaussian-identity, unit-weight, dispersion-unscaled
2595/// normal matrix stored in [`FitGeometry`]) as `Sλ = M₀ − XᵀX`, so no penalty
2596/// re-derivation is needed — exactly the seam the jackknife+ substrate uses.
2597///
2598/// The frozen-ρ self-diagnostic treats the entire frozen penalty as carrying a
2599/// single global log-smoothing parameter `ρ` with `S(ρ) = eᵖ·Sλ` and runs the
2600/// closed-form [`GaussianRemlRhoResponse::certified_full_conformal`]: it
2601/// re-selects the global ρ̂(z) on the augmented data, bounds the score
2602/// perturbation freezing ρ̂ could induce, and reports whether freezing is
2603/// accepted under the stated rho-grid Lipschitz assumption. This is a sound,
2604/// conservative global-scale check that applies to any penalized smooth (it does
2605/// not require the model to be single-penalty); per-penalty re-selection is the
2606/// research-core Layer 3 and is not asserted here.
2607///
2608/// Unit prior weights are required, as everywhere in this module: a reweighted
2609/// training row is not exchangeable with the test row.
2610#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
2611pub struct ExactFullConformalSubstrate {
2612    /// Training design `X` (n × p).
2613    x: Array2<f64>,
2614    /// Training response `y` (n).
2615    y: Array1<f64>,
2616    /// Frozen penalty `Sλ = M₀ − XᵀX` at the fitted smoothing parameters (p × p).
2617    s_lambda: Array2<f64>,
2618}
2619
2620/// One test row's exact full-conformal verdict: the outer `[lower, upper]`
2621/// envelope of the exact set, plus the frozen-ρ self-diagnostics flag.
2622#[derive(Clone, Debug)]
2623pub struct ExactFullConformalInterval {
2624    /// Outer envelope `[min lo, max hi]` of the exact (possibly multi-interval)
2625    /// set, inheriting its coverage (it is a superset). Endpoints may be
2626    /// infinite (honest unboundedness in low-information / high-leverage regimes).
2627    pub lo: f64,
2628    pub hi: f64,
2629    /// The exact set itself (a union of intervals).
2630    pub set: FullConformalSet,
2631    /// `true` when freezing the global smoothing parameter is ACCEPTED under the
2632    /// rho-grid Lipschitz assumption (the frozen exact set equals the honest
2633    /// ρ-re-selecting set); `false` when the certificate REFUSED (the frozen set
2634    /// may differ from the honest set and the caller should treat the envelope
2635    /// as the frozen-ρ approximation, not the certified honest set).
2636    pub frozen_rho_certified: bool,
2637}
2638
2639impl ExactFullConformalSubstrate {
2640    /// Build the substrate from the training design, response, prior weights,
2641    /// and the converged penalized normal matrix `M₀ = XᵀX + Sλ`. Recovers the
2642    /// frozen penalty `Sλ = M₀ − XᵀX` once. Rejects non-unit prior weights and
2643    /// shape mismatches, identically to the rest of this module.
2644    pub fn from_design_unit_weight_normal_matrix(
2645        x: &Array2<f64>,
2646        y: &Array1<f64>,
2647        prior_weights: &Array1<f64>,
2648        m: &Array2<f64>,
2649    ) -> Result<Self, String> {
2650        let n = x.nrows();
2651        let p = x.ncols();
2652        if y.len() != n || prior_weights.len() != n {
2653            return Err("exact full conformal substrate: row-count mismatch".to_string());
2654        }
2655        if m.nrows() != p || m.ncols() != p {
2656            return Err("exact full conformal substrate: normal-matrix shape mismatch".to_string());
2657        }
2658        if prior_weights.iter().any(|&w| (w - 1.0).abs() > 1e-12) {
2659            return Err(
2660                "exact full conformal requires unit prior weights: a reweighted training row \
2661                 is not exchangeable with the test row, so the finite-sample coverage proof \
2662                 does not apply"
2663                    .to_string(),
2664            );
2665        }
2666        // Sλ = M₀ − XᵀX (frozen at the fitted smoothing parameters).
2667        let s_lambda = m - &x.t().dot(x);
2668        Ok(Self {
2669            x: x.clone(),
2670            y: y.clone(),
2671            s_lambda,
2672        })
2673    }
2674
2675    /// Coefficient dimension `p`.
2676    pub fn p(&self) -> usize {
2677        self.x.ncols()
2678    }
2679
2680    /// Training-row count `n`.
2681    pub fn n(&self) -> usize {
2682        self.x.nrows()
2683    }
2684
2685    /// The exact full-conformal verdict at one test row `x_*` and miscoverage
2686    /// `alpha`: the exact set, its outer envelope, and the frozen-ρ
2687    /// self-diagnostics flag. One Cholesky per call, zero refits.
2688    pub fn interval(
2689        &self,
2690        x_star: &Array1<f64>,
2691        alpha: f64,
2692    ) -> Result<ExactFullConformalInterval, String> {
2693        if x_star.len() != self.p() {
2694            return Err(format!(
2695                "exact full conformal: x_* has {} entries but the fit has {} coefficients",
2696                x_star.len(),
2697                self.p()
2698            ));
2699        }
2700        // The AUTHORITATIVE exact set is built at the user's fitted penalty `Sλ`
2701        // (ρ frozen exactly at the fit), so the reported set reflects the model
2702        // the user trained — not a re-optimized global scale.
2703        let weights = Array1::<f64>::ones(self.n());
2704        let engine =
2705            ExactGaussianFullConformal::new(&self.x, &self.y, &weights, &self.s_lambda, x_star)?;
2706        let set = engine.prediction_set(alpha);
2707
2708        // Frozen-ρ self-diagnostic: treat the whole frozen penalty as carrying a
2709        // single global log-smoothing parameter `ρ` with `S(ρ) = eᵖ·Sλ` and run
2710        // the closed-form certificate. It re-selects the global ρ̂(z) on the
2711        // augmented data and decides whether freezing the global scale is safe.
2712        // This is a sound conservative check around the global REML optimum (a
2713        // properly fitted model already sits at ρ̂₀ ≈ 0, where this set coincides
2714        // with the authoritative set above); per-penalty re-selection is the
2715        // research-core Layer 3 and is not asserted here. A degenerate certificate
2716        // computation must NOT void the exact set, so its failure maps to "not
2717        // certified" rather than an error.
2718        let frozen_rho_certified =
2719            GaussianRemlRhoResponse::new(&self.x, &self.y, &self.s_lambda, x_star)
2720                .and_then(|response| response.certified_full_conformal(alpha))
2721                .map(|certified| {
2722                    matches!(
2723                        certified.certificate,
2724                        FrozenRhoCertificate::Certified { .. }
2725                    )
2726                })
2727                .unwrap_or(false);
2728
2729        let (lo, hi) = if set.intervals.is_empty() {
2730            // No candidate qualifies (pathological tiny α·(n+1)); collapse to the
2731            // frozen plug-in mean μ̂_* = x_*ᵀβ̂, β̂ = (XᵀX+Sλ)⁻¹Xᵀy — the only
2732            // honest scalar answer.
2733            let m = &self.x.t().dot(&self.x) + &self.s_lambda;
2734            let chol = m.cholesky(Side::Lower).map_err(|e| {
2735                format!("exact full conformal: frozen normal matrix not SPD: {e:?}")
2736            })?;
2737            let beta = chol.solvevec(&self.x.t().dot(&self.y));
2738            let mu_point = x_star.dot(&beta);
2739            (mu_point, mu_point)
2740        } else {
2741            let mut lo = f64::INFINITY;
2742            let mut hi = f64::NEG_INFINITY;
2743            for itv in &set.intervals {
2744                lo = lo.min(itv.lo);
2745                hi = hi.max(itv.hi);
2746            }
2747            (lo, hi)
2748        };
2749        Ok(ExactFullConformalInterval {
2750            lo,
2751            hi,
2752            set,
2753            frozen_rho_certified,
2754        })
2755    }
2756}
2757
2758#[cfg(test)]
2759mod tests {
2760    use super::*;
2761    use ndarray::{Array1, Array2};
2762
2763    /// Small penalized smooth: verify the breakpoint-scan set against a
2764    /// dense brute-force grid of explicit augmented refits (independent
2765    /// linear-algebra path), and check basic coverage sanity.
2766    #[test]
2767    fn exact_set_matches_brute_force_refits() {
2768        let n = 24usize;
2769        let p = 5usize;
2770        let mut x = Array2::<f64>::zeros((n, p));
2771        let mut y = Array1::<f64>::zeros(n);
2772        for i in 0..n {
2773            let t = i as f64 / (n as f64 - 1.0);
2774            for j in 0..p {
2775                x[[i, j]] = (t * (j as f64 + 1.0) * std::f64::consts::PI).sin();
2776            }
2777            y[i] = 1.2 * (2.0 * std::f64::consts::PI * t).sin()
2778                + 0.3 * (17.0 * (i as f64) + 0.5).sin();
2779        }
2780        let mut s_lambda = Array2::<f64>::eye(p);
2781        s_lambda *= 0.7;
2782        let weights = Array1::<f64>::ones(n);
2783        let mut x_star = Array1::<f64>::zeros(p);
2784        for j in 0..p {
2785            x_star[j] = (0.37 * (j as f64 + 1.0) * std::f64::consts::PI).sin();
2786        }
2787
2788        let engine =
2789            ExactGaussianFullConformal::new(&x, &y, &weights, &s_lambda, &x_star).expect("engine");
2790        let alpha = 0.2;
2791        let set = engine.prediction_set(alpha);
2792        assert!(!set.intervals.is_empty(), "set should be non-empty");
2793
2794        // Independent oracle: explicit augmented refit per grid z.
2795        let m_base = x.t().dot(&x) + &s_lambda;
2796        let oracle = |z: f64| -> bool {
2797            let mut m = m_base.clone();
2798            for i in 0..p {
2799                for j in 0..p {
2800                    m[[i, j]] += x_star[i] * x_star[j];
2801                }
2802            }
2803            let chol = m.cholesky(Side::Lower).expect("oracle chol");
2804            let mut rhs = x.t().dot(&y);
2805            for j in 0..p {
2806                rhs[j] += x_star[j] * z;
2807            }
2808            let beta = chol.solvevec(&rhs);
2809            let e_star = (z - x_star.dot(&beta)).abs();
2810            let count = (0..n)
2811                .filter(|&i| {
2812                    let mu_i: f64 = x.row(i).dot(&beta);
2813                    (y[i] - mu_i).abs() >= e_star
2814                })
2815                .count();
2816            (1.0 + count as f64) > alpha * (n as f64 + 1.0)
2817        };
2818
2819        let z_lo = set.intervals.first().map(|i| i.lo).unwrap_or(-5.0) - 2.0;
2820        let z_hi = set.intervals.last().map(|i| i.hi).unwrap_or(5.0) + 2.0;
2821        let z_lo = if z_lo.is_finite() { z_lo } else { -50.0 };
2822        let z_hi = if z_hi.is_finite() { z_hi } else { 50.0 };
2823        let grid = 4001usize;
2824        for g in 0..grid {
2825            let z = z_lo + (z_hi - z_lo) * g as f64 / (grid as f64 - 1.0);
2826            let in_set = set.intervals.iter().any(|itv| z >= itv.lo && z <= itv.hi);
2827            assert_eq!(
2828                in_set,
2829                oracle(z),
2830                "breakpoint scan disagrees with brute-force refit at z={z}"
2831            );
2832        }
2833
2834        // The fitted value at x_* must be in the set at α=0.2 for any sane
2835        // problem (its residual is small by construction of the fit).
2836        let chol = m_base.cholesky(Side::Lower).expect("chol");
2837        let beta_unaug = chol.solvevec(&x.t().dot(&y));
2838        let mu_star = x_star.dot(&beta_unaug);
2839        assert!(
2840            set.intervals
2841                .iter()
2842                .any(|itv| mu_star >= itv.lo && mu_star <= itv.hi),
2843            "point prediction should be inside its own conformal set"
2844        );
2845
2846        // Margin is non-negative; critical boundary ties are reported as
2847        // zero rather than skipped.
2848        assert!(set.boundary_margin >= 0.0);
2849    }
2850
2851    #[test]
2852    fn boundary_tie_has_zero_margin_and_refuses() {
2853        let x = Array2::from_shape_vec((1, 1), vec![0.0]).expect("x");
2854        let y = Array1::from_vec(vec![0.0]);
2855        let weights = Array1::ones(1);
2856        let s_lambda = Array2::from_shape_vec((1, 1), vec![1.0]).expect("s");
2857        let x_star = Array1::from_vec(vec![1.0]);
2858        let engine =
2859            ExactGaussianFullConformal::new(&x, &y, &weights, &s_lambda, &x_star).expect("engine");
2860
2861        let set = engine.prediction_set(0.5);
2862        assert_eq!(set.intervals.len(), 1);
2863        assert_eq!(set.intervals[0].lo, 0.0);
2864        assert_eq!(set.intervals[0].hi, 0.0);
2865        assert_eq!(set.boundary_margin, 0.0);
2866        assert!(matches!(
2867            FrozenRhoCertificate::decide(0.0, set.boundary_margin),
2868            FrozenRhoCertificate::Refused { .. }
2869        ));
2870    }
2871
2872    #[test]
2873    fn identically_tied_all_real_set_has_zero_margin_and_refuses() {
2874        let x = Array2::from_shape_vec((1, 1), vec![1.0]).expect("x");
2875        let y = Array1::from_vec(vec![0.0]);
2876        let weights = Array1::ones(1);
2877        let s_lambda = Array2::from_shape_vec((1, 1), vec![0.0]).expect("s");
2878        let x_star = Array1::from_vec(vec![1.0]);
2879        let engine =
2880            ExactGaussianFullConformal::new(&x, &y, &weights, &s_lambda, &x_star).expect("engine");
2881
2882        let set = engine.prediction_set(0.5);
2883        assert_eq!(set.intervals.len(), 1);
2884        assert_eq!(set.intervals[0].lo, f64::NEG_INFINITY);
2885        assert_eq!(set.intervals[0].hi, f64::INFINITY);
2886        assert_eq!(set.boundary_margin, 0.0);
2887        assert!(matches!(
2888            FrozenRhoCertificate::decide(0.0, set.boundary_margin),
2889            FrozenRhoCertificate::Refused { .. }
2890        ));
2891    }
2892
2893    #[test]
2894    fn strictly_separated_all_real_margin_can_accept() {
2895        let engine = ExactGaussianFullConformal {
2896            u: Array1::from_vec(vec![1.0, 1.0, 0.0]),
2897            w: Array1::from_vec(vec![1.0, -1.0, 0.1]),
2898            n: 2,
2899        };
2900
2901        let set = engine.prediction_set(0.5);
2902        assert_eq!(set.intervals.len(), 1);
2903        assert_eq!(set.intervals[0].lo, f64::NEG_INFINITY);
2904        assert_eq!(set.intervals[0].hi, f64::INFINITY);
2905        assert!(set.boundary_margin > 0.5, "margin={}", set.boundary_margin);
2906        assert!(matches!(
2907            FrozenRhoCertificate::decide(0.5, set.boundary_margin),
2908            FrozenRhoCertificate::Certified { .. }
2909        ));
2910    }
2911
2912    /// Scalar penalized intercept-only logistic fit on augmented Bernoulli
2913    /// data: maximize `Σ_{n+1 rows} [y η − log(1+eʸ)] − ½λη²` by Newton.
2914    /// The map is symmetric BY CONSTRUCTION (it sees the responses only
2915    /// through their sum over the n+1 exchangeable rows), so it satisfies
2916    /// the `SymmetricAugmentedFit` contract exactly — making the coverage
2917    /// theorem checkable by enumeration below.
2918    fn bernoulli_intercept_scores(train: &[f64], z: f64, lambda: f64) -> Array1<f64> {
2919        let n1 = train.len() + 1;
2920        let sum_y: f64 = train.iter().sum::<f64>() + z;
2921        let mut eta = 0.0_f64;
2922        for _ in 0..200 {
2923            let mu = 1.0 / (1.0 + (-eta).exp());
2924            let g = sum_y - (n1 as f64) * mu - lambda * eta;
2925            let h = -(n1 as f64) * mu * (1.0 - mu) - lambda;
2926            let step = g / h;
2927            eta -= step;
2928            if step.abs() < 1e-14 {
2929                break;
2930            }
2931        }
2932        let mu = 1.0 / (1.0 + (-eta).exp());
2933        let mut scores = Array1::<f64>::zeros(n1);
2934        for (i, &yi) in train.iter().enumerate() {
2935            scores[i] = (yi - mu).abs();
2936        }
2937        scores[n1 - 1] = (z - mu).abs();
2938        scores
2939    }
2940
2941    /// Finite-sample validity as a THEOREM CHECK, not a simulation: for the
2942    /// intercept-only penalized-logistic map above, enumerate EVERY Bernoulli
2943    /// training dataset (2ⁿ of them) and both test outcomes, and compute the
2944    /// exact coverage probability `P(y_* ∈ C_α)` under iid Bernoulli(θ).
2945    /// Full conformal guarantees ≥ 1 − α for every θ and every α — if the
2946    /// rank convention, the p-value denominator, or the tie handling were
2947    /// wrong by even one unit, some (θ, α) cell here would dip below the
2948    /// bound. Also pins informativeness: the set is not the trivial {0, 1}
2949    /// on every dataset (an always-trivial set would satisfy coverage
2950    /// vacuously).
2951    #[test]
2952    fn bernoulli_full_conformal_exact_coverage_by_total_enumeration() {
2953        let n = 7usize;
2954        let lambda = 0.5_f64;
2955        for &theta in &[0.2_f64, 0.5, 0.8] {
2956            for &alpha in &[0.10_f64, 0.25] {
2957                let mut coverage = 0.0_f64;
2958                let mut any_strict_subset = false;
2959                for mask in 0u32..(1u32 << n) {
2960                    let train: Vec<f64> = (0..n).map(|i| f64::from((mask >> i) & 1)).collect();
2961                    let p_train: f64 = train
2962                        .iter()
2963                        .map(|&y| if y > 0.5 { theta } else { 1.0 - theta })
2964                        .product();
2965                    let mut map = |z: f64| -> Result<Array1<f64>, String> {
2966                        Ok(bernoulli_intercept_scores(&train, z, lambda))
2967                    };
2968                    let set = bernoulli_full_conformal(&mut map, alpha).expect("bernoulli set");
2969                    assert!(set.lower_tail_unresolved.is_none());
2970                    assert!(set.upper_tail_unresolved.is_none());
2971                    let holds_zero = set.members.contains(&0.0);
2972                    let holds_one = set.members.contains(&1.0);
2973                    if !(holds_zero && holds_one) {
2974                        any_strict_subset = true;
2975                    }
2976                    coverage += p_train
2977                        * ((1.0 - theta) * f64::from(u8::from(holds_zero))
2978                            + theta * f64::from(u8::from(holds_one)));
2979                }
2980                assert!(
2981                    coverage >= 1.0 - alpha - 1e-12,
2982                    "exact full-conformal coverage must be ≥ 1−α for every θ: \
2983                     θ={theta} α={alpha} coverage={coverage}"
2984                );
2985                if alpha == 0.25 {
2986                    assert!(
2987                        any_strict_subset,
2988                        "θ={theta} α={alpha}: the set must be informative (a strict \
2989                         subset of the support on at least one dataset), otherwise \
2990                         the coverage bound is satisfied vacuously"
2991                    );
2992                }
2993            }
2994        }
2995
2996        // Concrete informativeness pin: an all-zeros training run at α=0.25
2997        // must exclude z=1 — the augmented fit at z=1 has μ̂ ≈ 0.21, so the
2998        // test row's score 1−μ̂ ≈ 0.79 strictly dominates every training
2999        // score (≈ 0.21) and its p-value is 1/8 = 0.125 ≤ α.
3000        let train = vec![0.0; n];
3001        let mut map = |z: f64| -> Result<Array1<f64>, String> {
3002            Ok(bernoulli_intercept_scores(&train, z, lambda))
3003        };
3004        let set = bernoulli_full_conformal(&mut map, 0.25).expect("set");
3005        assert_eq!(
3006            set.members,
3007            vec![0.0],
3008            "all-zeros training data at α=0.25 must yield the set {{0}}"
3009        );
3010    }
3011
3012    /// Windowed (count-style) enumeration: tail flags must report exactly
3013    /// whether the retained set continues through the window edge. A cleared
3014    /// flag is only a contiguous-edge statement, not a global monotone-tail
3015    /// theorem about unexamined candidates.
3016    #[test]
3017    fn windowed_discrete_tail_flags_are_honest() {
3018        // Score map: the augmented "fit" is the mean of the n+1 responses;
3019        // scores are absolute deviations from it. Symmetric trivially.
3020        let train = [3.0_f64, 4.0, 5.0, 4.0, 3.0, 5.0, 4.0];
3021        let mut map = |z: f64| -> Result<Array1<f64>, String> {
3022            let n1 = train.len() + 1;
3023            let mean = (train.iter().sum::<f64>() + z) / n1 as f64;
3024            let mut s = Array1::<f64>::zeros(n1);
3025            for (i, &yi) in train.iter().enumerate() {
3026                s[i] = (yi - mean).abs();
3027            }
3028            s[n1 - 1] = (z - mean).abs();
3029            Ok(s)
3030        };
3031        let alpha = 0.2;
3032
3033        // Wide window: both edges are excluded, so no retained component
3034        // continues contiguously through either edge.
3035        let wide: Vec<f64> = (0..=12).map(|k| k as f64).collect();
3036        let set = discrete_full_conformal_window(&mut map, &wide, alpha).expect("wide");
3037        assert!(!set.members.is_empty(), "wide window must retain the bulk");
3038        assert!(set.lower_tail_unresolved.is_none());
3039        assert!(set.upper_tail_unresolved.is_none());
3040        let lo_member = *set.members.first().expect("non-empty");
3041        let hi_member = *set.members.last().expect("non-empty");
3042
3043        // Window cut INSIDE the set: the corresponding flag must fire.
3044        let cut: Vec<f64> = (0..=(hi_member as i64 - 1)).map(|k| k as f64).collect();
3045        let cut_set = discrete_full_conformal_window(&mut map, &cut, alpha).expect("cut");
3046        assert_eq!(
3047            cut_set.upper_tail_unresolved,
3048            Some(cut[cut.len() - 1]),
3049            "a window whose top edge is retained must report the upper tail unresolved"
3050        );
3051        assert!(
3052            lo_member > 0.0 || cut_set.lower_tail_unresolved.is_some(),
3053            "lower flag must mirror the same contract"
3054        );
3055
3056        // Exhaustive constructor clears flags by definition.
3057        let exhaustive =
3058            discrete_full_conformal_exhaustive(&mut map, &wide, alpha).expect("exhaustive");
3059        assert!(exhaustive.lower_tail_unresolved.is_none());
3060        assert!(exhaustive.upper_tail_unresolved.is_none());
3061
3062        // Engine contract errors: unsorted candidates and shrinking score
3063        // vectors are refused loudly.
3064        assert!(discrete_full_conformal_window(&mut map, &[2.0, 1.0], alpha).is_err());
3065        let mut bad_map = {
3066            let mut flip = false;
3067            move |z: f64| -> Result<Array1<f64>, String> {
3068                if !z.is_finite() {
3069                    return Err("bad-map fixture received non-finite candidate".to_string());
3070                }
3071                flip = !flip;
3072                Ok(Array1::<f64>::zeros(if flip { 5 } else { 4 }))
3073            }
3074        };
3075        assert!(discrete_full_conformal_window(&mut bad_map, &[0.0, 1.0], alpha).is_err());
3076    }
3077
3078    /// A smooth Gaussian fixture: cosine basis design (column 0 constant,
3079    /// column 1 the first harmonic — both unpenalized), a quartic-frequency
3080    /// curvature penalty on the higher harmonics (`rank = p − 2`, nullity 2),
3081    /// and a one-harmonic truth plus tiny deterministic noise.
3082    fn gauss_reml_fixture(n: usize, p: usize) -> (Array2<f64>, Array1<f64>, Array2<f64>) {
3083        use std::f64::consts::PI;
3084        let mut x = Array2::<f64>::zeros((n, p));
3085        let mut y = Array1::<f64>::zeros(n);
3086        for i in 0..n {
3087            let t = i as f64 / (n as f64 - 1.0);
3088            for j in 0..p {
3089                x[[i, j]] = (j as f64 * PI * t).cos();
3090            }
3091            y[i] = (2.0 * PI * t).sin() + 0.05 * (13.0 * i as f64 + 0.7).sin();
3092        }
3093        let mut s = Array2::<f64>::zeros((p, p));
3094        for j in 0..p {
3095            s[[j, j]] = if j < 2 { 0.0 } else { (j as f64).powi(4) };
3096        }
3097        (x, y, s)
3098    }
3099
3100    fn cosine_row(p: usize, t: f64) -> Array1<f64> {
3101        use std::f64::consts::PI;
3102        let mut r = Array1::<f64>::zeros(p);
3103        for j in 0..p {
3104            r[j] = (j as f64 * PI * t).cos();
3105        }
3106        r
3107    }
3108
3109    /// The gradient-is-differential contract for the closed-form Gaussian REML
3110    /// response (#1021 philosophy applied here): every analytic derivative the
3111    /// IFT consumes (`G`, `∂²Ṽ/∂ρ²`, `∂²Ṽ/∂ρ∂z`) must equal the central
3112    /// finite-difference of the SAME value path `Ṽ`. A desync here would make
3113    /// `dρ̂/dz`, and therefore the certificate bound, silently wrong.
3114    #[test]
3115    fn gaussian_reml_rho_derivatives_match_finite_difference() {
3116        let (x, y, s) = gauss_reml_fixture(40, 8);
3117        let x_star = cosine_row(8, 0.5);
3118        let resp = GaussianRemlRhoResponse::new(&x, &y, &s, &x_star).expect("response");
3119        assert_eq!(
3120            resp.rank_s(),
3121            6,
3122            "quartic penalty with two zeros has rank p−2"
3123        );
3124
3125        let rho = 0.4_f64;
3126        let z = 0.3_f64;
3127        let ev = resp.eval(rho, Some(z)).expect("eval");
3128        let v = |r: f64, zz: f64| resp.penalized_laml_criterion(r, Some(zz)).expect("v");
3129
3130        let h = 1e-4_f64;
3131        let g_fd = (v(rho + h, z) - v(rho - h, z)) / (2.0 * h);
3132        assert!(
3133            (ev.grad - g_fd).abs() <= 1e-4 * (1.0 + ev.grad.abs()),
3134            "G mismatch: analytic={} fd={}",
3135            ev.grad,
3136            g_fd
3137        );
3138        let hess_fd = (v(rho + h, z) - 2.0 * v(rho, z) + v(rho - h, z)) / (h * h);
3139        assert!(
3140            (ev.hess - hess_fd).abs() <= 1e-3 * (1.0 + ev.hess.abs()),
3141            "∂²Ṽ/∂ρ² mismatch: analytic={} fd={}",
3142            ev.hess,
3143            hess_fd
3144        );
3145        let k = 1e-4_f64;
3146        let cross_fd = (v(rho + h, z + k) - v(rho + h, z - k) - v(rho - h, z + k)
3147            + v(rho - h, z - k))
3148            / (4.0 * h * k);
3149        assert!(
3150            (ev.cross - cross_fd).abs() <= 1e-3 * (1.0 + ev.cross.abs()),
3151            "∂²Ṽ/∂ρ∂z mismatch: analytic={} fd={}",
3152            ev.cross,
3153            cross_fd
3154        );
3155
3156        // The un-augmented criterion drops the cross term and the +1 row.
3157        let ev0 = resp.eval(rho, None).expect("eval0");
3158        assert_eq!(ev0.cross, 0.0);
3159        let v0 = |r: f64| resp.penalized_laml_criterion(r, None).expect("v0");
3160        let g0_fd = (v0(rho + h) - v0(rho - h)) / (2.0 * h);
3161        assert!((ev0.grad - g0_fd).abs() <= 1e-4 * (1.0 + ev0.grad.abs()));
3162    }
3163
3164    /// The exact smoothing response `dρ̂/dz` (outer IFT) must equal the
3165    /// finite-difference of the ACTUAL re-selection map `z ↦ ρ̂(z)` — i.e. the
3166    /// IFT derivative is the derivative of the thing it claims to differentiate,
3167    /// not a parallel formula. This is the honesty check the issue demands for
3168    /// the ρ-response.
3169    #[test]
3170    fn gaussian_reml_smoothing_response_matches_reselection() {
3171        let (x, y, s) = gauss_reml_fixture(45, 8);
3172        let x_star = cosine_row(8, 0.42);
3173        let resp = GaussianRemlRhoResponse::new(&x, &y, &s, &x_star).expect("response");
3174
3175        for &z in &[0.15_f64, 0.4, 0.75] {
3176            let rho_z = resp.select_rho(Some(z)).expect("select");
3177            // ρ̂(z) is a genuine stationary point of the augmented criterion.
3178            let g = resp.eval(rho_z, Some(z)).expect("eval").grad;
3179            assert!(g.abs() < 1e-6, "select_rho not stationary: G={g} at z={z}");
3180
3181            let analytic = resp.drho_dz(rho_z, z).expect("drho");
3182            let hh = 2e-3_f64;
3183            let fd = (resp.select_rho(Some(z + hh)).expect("u")
3184                - resp.select_rho(Some(z - hh)).expect("d"))
3185                / (2.0 * hh);
3186            assert!(
3187                (analytic - fd).abs() <= 1e-3 + 5e-2 * analytic.abs(),
3188                "dρ̂/dz IFT vs re-selection FD mismatch at z={z}: analytic={analytic} fd={fd}"
3189            );
3190        }
3191    }
3192
3193    /// Layer-3 grid-check sweep, stated as the conditional invariant that
3194    /// actually holds for the current rho-excursion machinery:
3195    ///
3196    /// Whenever the conditional check accepts, the cheap frozen-ρ set must
3197    /// agree with the honest set that re-selects ρ̂ at every candidate on a
3198    /// dense audit grid. This does not prove the continuous rho-excursion
3199    /// supremum; it verifies the accepted cases under the exposed probe-grid
3200    /// assumption.
3201    ///
3202    /// We do NOT demand that any specific problem accept: a benign problem
3203    /// can still carry a tie or near-tie boundary whose tiny margin the check
3204    /// legitimately cannot clear — refusing there is correct, not a bug.
3205    #[test]
3206    fn frozen_rho_grid_check_is_conditional_when_it_accepts() {
3207        let mut soundness_checks = 0usize;
3208        for &(n, p) in &[(45usize, 8usize), (90, 6)] {
3209            let (x, y, s) = gauss_reml_fixture(n, p);
3210            for &t_star in &[0.4_f64, 0.5, 0.6] {
3211                let x_star = cosine_row(p, t_star);
3212                let resp = GaussianRemlRhoResponse::new(&x, &y, &s, &x_star).expect("response");
3213                for &alpha in &[0.15_f64, 0.25] {
3214                    let cert = resp.certified_full_conformal(alpha).expect("cert");
3215                    if !matches!(cert.certificate, FrozenRhoCertificate::Certified { .. }) {
3216                        continue;
3217                    }
3218                    assert_eq!(cert.rho_probe_count, 65);
3219                    assert!(cert.observed_sup_drho_dz >= 0.0);
3220                    // Verify soundness for the first few certified configs (the
3221                    // honest oracle re-selects ρ̂ per grid point, so this is the
3222                    // expensive arm; a handful audits the conditional path).
3223                    if soundness_checks >= 4 {
3224                        continue;
3225                    }
3226                    let frozen = &cert.frozen_set;
3227                    if frozen.intervals.is_empty() {
3228                        continue;
3229                    }
3230                    soundness_checks += 1;
3231                    let lo = frozen.intervals.first().unwrap().lo;
3232                    let hi = frozen.intervals.last().unwrap().hi;
3233                    let span = (hi - lo).max(1.0);
3234                    let z_lo = if lo.is_finite() {
3235                        lo - 0.5 * span
3236                    } else {
3237                        -12.0
3238                    };
3239                    let z_hi = if hi.is_finite() {
3240                        hi + 0.5 * span
3241                    } else {
3242                        12.0
3243                    };
3244                    let grid = 200usize;
3245                    for g in 0..=grid {
3246                        let z = z_lo + (z_hi - z_lo) * (g as f64) / (grid as f64);
3247                        let in_frozen = frozen
3248                            .intervals
3249                            .iter()
3250                            .any(|itv| z >= itv.lo && z <= itv.hi);
3251                        let honest = resp.honest_membership(z, alpha).expect("honest");
3252                        assert_eq!(
3253                            in_frozen, honest,
3254                            "conditionally accepted set disagrees with the honest ρ-re-selecting set \
3255                             at z={z} (n={n}, t*={t_star}, α={alpha}, excursion={}, lip={})",
3256                            cert.rho_excursion, cert.score_rho_lipschitz
3257                        );
3258                    }
3259                }
3260            }
3261        }
3262    }
3263
3264    /// The conditional check must REFUSE to accept when the smoothing
3265    /// response is genuinely large: a high-leverage extrapolated test point at
3266    /// small n makes ρ̂(z) swing with z, so the excursion bound exceeds the
3267    /// boundary margin. The machinery must not vacuously always-accept, and
3268    /// must still return a usable frozen set on refusal.
3269    #[test]
3270    fn frozen_rho_certificate_refuses_under_large_smoothing_response() {
3271        let (x, y, s) = gauss_reml_fixture(12, 6);
3272        let x_star = cosine_row(6, 1.9); // far extrapolation ⇒ high leverage
3273        let resp = GaussianRemlRhoResponse::new(&x, &y, &s, &x_star).expect("response");
3274        let cert = resp.certified_full_conformal(0.2).expect("cert");
3275        assert!(
3276            matches!(cert.certificate, FrozenRhoCertificate::Refused { .. }),
3277            "high-leverage small-n problem should refuse; got {:?} (excursion={}, margin via set)",
3278            cert.certificate,
3279            cert.rho_excursion
3280        );
3281        // A refusal still hands back the cheap frozen set for the caller to
3282        // either widen with local refits or fall back from — never nothing.
3283        assert!(cert.rho_excursion >= 0.0);
3284    }
3285
3286    // ── Layer 2 (continuous GLM homotopy) + jackknife+ tests ─────────────
3287
3288    /// Independent damped-Newton refit of the augmented canonical GLM at a
3289    /// single candidate z — explicit per-row loops, its own line search, no
3290    /// shared assembly with the engine under test.
3291    fn oracle_glm_refit(
3292        x: &Array2<f64>,
3293        y: &Array1<f64>,
3294        s: &Array2<f64>,
3295        x_star: &Array1<f64>,
3296        z: f64,
3297        mean: &dyn Fn(f64) -> f64,
3298        weight: &dyn Fn(f64) -> f64,
3299        nll_term: &dyn Fn(f64, f64) -> f64,
3300    ) -> Array1<f64> {
3301        let n = x.nrows();
3302        let p = x.ncols();
3303        let pen_nll = |b: &Array1<f64>| -> f64 {
3304            let mut acc = 0.0;
3305            for i in 0..n {
3306                acc += nll_term(x.row(i).dot(b), y[i]);
3307            }
3308            acc += nll_term(x_star.dot(b), z);
3309            acc + 0.5 * b.dot(&s.dot(b))
3310        };
3311        let mut beta = Array1::<f64>::zeros(p);
3312        let mut cur = pen_nll(&beta);
3313        for _ in 0..400 {
3314            let mut g = s.dot(&beta);
3315            let mut h = s.clone();
3316            for i in 0..n {
3317                let eta = x.row(i).dot(&beta);
3318                let r = mean(eta) - y[i];
3319                let w = weight(eta);
3320                for a in 0..p {
3321                    g[a] += x[[i, a]] * r;
3322                    for b in 0..p {
3323                        h[[a, b]] += w * x[[i, a]] * x[[i, b]];
3324                    }
3325                }
3326            }
3327            let eta_s = x_star.dot(&beta);
3328            let r_s = mean(eta_s) - z;
3329            let w_s = weight(eta_s);
3330            for a in 0..p {
3331                g[a] += x_star[a] * r_s;
3332                for b in 0..p {
3333                    h[[a, b]] += w_s * x_star[a] * x_star[b];
3334                }
3335            }
3336            let chol = h.cholesky(Side::Lower).expect("oracle chol");
3337            let step = chol.solvevec(&g);
3338            if vec_norm(&step) <= 1e-13 * (1.0 + vec_norm(&beta)) {
3339                break;
3340            }
3341            let search = backtracking_line_search::<_, std::convert::Infallible>(
3342                BacktrackConfig::default(),
3343                |t| {
3344                    let mut cand = beta.clone();
3345                    cand.scaled_add(-t, &step);
3346                    let cand_nll = pen_nll(&cand);
3347                    Ok(if cand_nll.is_finite() {
3348                        Some((cand_nll, cand))
3349                    } else {
3350                        None
3351                    })
3352                },
3353                |_t, cand_nll| cand_nll <= cur,
3354            );
3355            let accepted = match search {
3356                Ok(step) => step,
3357                Err(never) => match never {},
3358            };
3359            let step = accepted.unwrap_or_else(|| panic!("oracle line search failed at z={z}"));
3360            beta = step.payload;
3361            cur = step.value;
3362        }
3363        beta
3364    }
3365
3366    /// Conformal membership computed directly from an oracle refit.
3367    fn oracle_glm_membership(
3368        x: &Array2<f64>,
3369        y: &Array1<f64>,
3370        x_star: &Array1<f64>,
3371        z: f64,
3372        alpha: f64,
3373        beta: &Array1<f64>,
3374        mean: &dyn Fn(f64) -> f64,
3375    ) -> bool {
3376        let n = x.nrows();
3377        let e_star = (z - mean(x_star.dot(beta))).abs();
3378        let count = (0..n)
3379            .filter(|&i| (y[i] - mean(x.row(i).dot(beta))).abs() >= e_star)
3380            .count();
3381        (1.0 + count as f64) > alpha * (n as f64 + 1.0)
3382    }
3383
3384    /// (#942 Layer 2 test a) The tracked β̂(z) path must match a direct
3385    /// augmented refit at every candidate WITHIN THE CERTIFIED corrector
3386    /// bound — for both supported families — and the homotopy must have
3387    /// actually tracked (not silently cold-refit everything). Membership
3388    /// verdicts must agree with the independent oracle exactly.
3389    #[test]
3390    fn glm_homotopy_tracks_exact_refit_path_within_certified_bound() {
3391        use std::f64::consts::PI;
3392        let n = 16usize;
3393        let p = 3usize;
3394        let mut x = Array2::<f64>::zeros((n, p));
3395        let mut y = Array1::<f64>::zeros(n);
3396        for i in 0..n {
3397            let t = i as f64 / (n as f64 - 1.0);
3398            for j in 0..p {
3399                x[[i, j]] = (j as f64 * PI * t).cos();
3400            }
3401            y[i] = (1.0 + (2.0 * PI * t).sin()).exp().round();
3402        }
3403        let mut s = Array2::<f64>::eye(p);
3404        s *= 1.5;
3405        let weights = Array1::<f64>::ones(n);
3406        let x_star = cosine_row(p, 0.37);
3407        let alpha = 0.2;
3408
3409        // Poisson-log arm over a count window.
3410        let eng = GlmHomotopyFullConformal::new(
3411            CanonicalGlmFamily::PoissonLog,
3412            &x,
3413            &y,
3414            &weights,
3415            &s,
3416            &x_star,
3417        )
3418        .expect("poisson engine");
3419        let candidates: Vec<f64> = (0..=6).map(|k| k as f64).collect();
3420        let set = eng.prediction_set(&candidates, alpha).expect("poisson set");
3421        assert_eq!(set.candidates.len(), candidates.len());
3422        assert_eq!(set.n_augmented, n + 1);
3423        assert!(
3424            set.candidates.iter().skip(1).any(|c| !c.cold_refit),
3425            "the homotopy never tracked a single transition on a benign Poisson fixture \
3426             — the certified predictor–corrector path is vacuous"
3427        );
3428        let mean_p = |eta: f64| eta.exp();
3429        let weight_p = |eta: f64| eta.exp();
3430        let nll_p = |eta: f64, yv: f64| eta.exp() - yv * eta;
3431        for c in &set.candidates {
3432            let beta_ref = oracle_glm_refit(&x, &y, &s, &x_star, c.z, &mean_p, &weight_p, &nll_p);
3433            let mut diff = c.beta.clone();
3434            diff.scaled_add(-1.0, &beta_ref);
3435            let err = vec_norm(&diff);
3436            assert!(
3437                c.beta_error_bound.is_finite(),
3438                "certified bound must be finite on a benign fixture (z={})",
3439                c.z
3440            );
3441            assert!(
3442                err <= c.beta_error_bound + 1e-7,
3443                "tracked β̂({}) is {err} from the oracle refit, exceeding the certified \
3444                 corrector bound {} (+ oracle tolerance)",
3445                c.z,
3446                c.beta_error_bound
3447            );
3448            assert!(
3449                c.beta_error_bound < 1e-6,
3450                "certified bound {} at z={} is uselessly loose on a benign fixture",
3451                c.beta_error_bound,
3452                c.z
3453            );
3454            let member_ref = oracle_glm_membership(&x, &y, &x_star, c.z, alpha, &beta_ref, &mean_p);
3455            assert_eq!(
3456                c.member, member_ref,
3457                "homotopy membership disagrees with the oracle refit at z={}",
3458                c.z
3459            );
3460        }
3461        assert_eq!(
3462            set.members.len(),
3463            set.candidates.iter().filter(|c| c.member).count()
3464        );
3465
3466        // Bernoulli-logit arm: support {0, 1}, same path-vs-refit contract.
3467        let mut yb = Array1::<f64>::zeros(n);
3468        for i in 0..n {
3469            let t = i as f64 / (n as f64 - 1.0);
3470            yb[i] = f64::from(u8::from((2.0 * PI * t).sin() > -0.2));
3471        }
3472        let engb = GlmHomotopyFullConformal::new(
3473            CanonicalGlmFamily::BernoulliLogit,
3474            &x,
3475            &yb,
3476            &weights,
3477            &s,
3478            &x_star,
3479        )
3480        .expect("bernoulli engine");
3481        let setb = engb
3482            .prediction_set(&[0.0, 1.0], alpha)
3483            .expect("bernoulli set");
3484        let mean_b = |eta: f64| 1.0 / (1.0 + (-eta).exp());
3485        let weight_b = |eta: f64| {
3486            let mu = 1.0 / (1.0 + (-eta).exp());
3487            mu * (1.0 - mu)
3488        };
3489        let nll_b = |eta: f64, yv: f64| eta.max(0.0) + (-eta.abs()).exp().ln_1p() - yv * eta;
3490        assert!(
3491            !setb.candidates[1].cold_refit,
3492            "the logistic third derivative is globally ≤ 1/(6√3); tracking 0→1 must certify"
3493        );
3494        for c in &setb.candidates {
3495            let beta_ref = oracle_glm_refit(&x, &yb, &s, &x_star, c.z, &mean_b, &weight_b, &nll_b);
3496            let mut diff = c.beta.clone();
3497            diff.scaled_add(-1.0, &beta_ref);
3498            assert!(
3499                vec_norm(&diff) <= c.beta_error_bound + 1e-7,
3500                "Bernoulli tracked path off the refit at z={} beyond the certified bound",
3501                c.z
3502            );
3503            let member_ref =
3504                oracle_glm_membership(&x, &yb, &x_star, c.z, alpha, &beta_ref, &mean_b);
3505            assert_eq!(c.member, member_ref);
3506        }
3507    }
3508
3509    /// (#1192) A benign UNPENALIZED Poisson fixture must produce a valid
3510    /// conformal set: the cold fit drives the raw penalized gradient down to
3511    /// its floating-point round-off floor (~1e-7 at moderate n), where the
3512    /// Armijo line search can no longer make sufficient-decrease progress
3513    /// because the convex NLL is flat to machine precision. That stalled
3514    /// iterate IS stationary and must be ACCEPTED, not aborted with a spurious
3515    /// "cold fit did not converge". With `S = 0` there is no penalty curvature
3516    /// to suppress the gradient floor, so this is the regime that exposed the
3517    /// abort.
3518    #[test]
3519    fn glm_homotopy_unpenalized_poisson_accepts_roundoff_floor_cold_fit() {
3520        use std::f64::consts::PI;
3521        let n = 24usize;
3522        let p = 3usize;
3523        let mut x = Array2::<f64>::zeros((n, p));
3524        let mut y = Array1::<f64>::zeros(n);
3525        for i in 0..n {
3526            let t = i as f64 / (n as f64 - 1.0);
3527            for j in 0..p {
3528                x[[i, j]] = (j as f64 * PI * t).cos();
3529            }
3530            y[i] = (1.0 + (2.0 * PI * t).sin()).exp().round();
3531        }
3532        // Unpenalized: no ridge to bound the gradient floor away from ε.
3533        let s = Array2::<f64>::zeros((p, p));
3534        let weights = Array1::<f64>::ones(n);
3535        let x_star = cosine_row(p, 0.37);
3536        let alpha = 0.2;
3537
3538        let eng = GlmHomotopyFullConformal::new(
3539            CanonicalGlmFamily::PoissonLog,
3540            &x,
3541            &y,
3542            &weights,
3543            &s,
3544            &x_star,
3545        )
3546        .expect("poisson engine");
3547        let candidates: Vec<f64> = (0..=6).map(|k| k as f64).collect();
3548        let set = eng
3549            .prediction_set(&candidates, alpha)
3550            .expect("unpenalized poisson cold fit must converge to the round-off floor");
3551        assert_eq!(set.candidates.len(), candidates.len());
3552
3553        // Every accepted cold fit must be a GENUINE stationary point: agree
3554        // with an independent oracle refit to within the certified bound.
3555        let mean_p = |eta: f64| eta.exp();
3556        let weight_p = |eta: f64| eta.exp();
3557        let nll_p = |eta: f64, yv: f64| eta.exp() - yv * eta;
3558        for c in &set.candidates {
3559            let beta_ref = oracle_glm_refit(&x, &y, &s, &x_star, c.z, &mean_p, &weight_p, &nll_p);
3560            let mut diff = c.beta.clone();
3561            diff.scaled_add(-1.0, &beta_ref);
3562            assert!(
3563                vec_norm(&diff) <= c.beta_error_bound + 1e-6,
3564                "accepted β̂({}) is off the oracle refit beyond the certified bound",
3565                c.z
3566            );
3567            let member_ref = oracle_glm_membership(&x, &y, &x_star, c.z, alpha, &beta_ref, &mean_p);
3568            assert_eq!(
3569                c.member, member_ref,
3570                "unpenalized membership disagrees with oracle at z={}",
3571                c.z
3572            );
3573        }
3574        assert_eq!(
3575            set.members.len(),
3576            set.candidates.iter().filter(|c| c.member).count()
3577        );
3578    }
3579
3580    /// (#1192) The round-off-floor acceptance must NOT silently swallow a
3581    /// genuinely non-stationary iterate: a fit deliberately truncated far
3582    /// from the optimum (gradient orders of magnitude above the round-off
3583    /// floor) must still be REJECTED. Guards against turning the fix into a
3584    /// blanket "accept anything that stalls".
3585    #[test]
3586    fn glm_homotopy_truncated_fit_still_rejected() {
3587        use std::f64::consts::PI;
3588        let n = 24usize;
3589        let p = 3usize;
3590        let mut x = Array2::<f64>::zeros((n, p));
3591        let mut y = Array1::<f64>::zeros(n);
3592        for i in 0..n {
3593            let t = i as f64 / (n as f64 - 1.0);
3594            for j in 0..p {
3595                x[[i, j]] = (j as f64 * PI * t).cos();
3596            }
3597            y[i] = (1.0 + (2.0 * PI * t).sin()).exp().round();
3598        }
3599        let s = Array2::<f64>::zeros((p, p));
3600        let weights = Array1::<f64>::ones(n);
3601        let x_star = cosine_row(p, 0.37);
3602        let eng = GlmHomotopyFullConformal::new(
3603            CanonicalGlmFamily::PoissonLog,
3604            &x,
3605            &y,
3606            &weights,
3607            &s,
3608            &x_star,
3609        )
3610        .expect("poisson engine");
3611        // β = 0 is far from the optimum: a large raw gradient, not the floor.
3612        let beta0 = Array1::<f64>::zeros(p);
3613        assert!(
3614            !eng.kkt_converged(&beta0, 3.0, GLM_STALL_ACCEPT_RTOL),
3615            "a far-from-stationary iterate must NOT pass the near-stationary band"
3616        );
3617    }
3618
3619    /// (#942 Layer 2 test c) When the third-order bound explodes — a huge
3620    /// candidate jump at a high-leverage test row under Poisson-log, where
3621    /// `b‴ = eʸ` grows with the candidate — the step certificate must
3622    /// REFUSE within its budget and fall back to a cold refit, and the
3623    /// fallback must preserve exactness (memberships still equal the
3624    /// independent oracle's).
3625    #[test]
3626    fn glm_homotopy_certificate_refuses_and_falls_back_on_third_order_explosion() {
3627        use std::f64::consts::PI;
3628        let n = 16usize;
3629        let p = 3usize;
3630        let mut x = Array2::<f64>::zeros((n, p));
3631        let mut y = Array1::<f64>::zeros(n);
3632        for i in 0..n {
3633            let t = i as f64 / (n as f64 - 1.0);
3634            for j in 0..p {
3635                x[[i, j]] = (j as f64 * PI * t).cos();
3636            }
3637            y[i] = (1.0 + 2.0 * t).round();
3638        }
3639        let mut s = Array2::<f64>::eye(p);
3640        s *= 0.5;
3641        let weights = Array1::<f64>::ones(n);
3642        let mut x_star = cosine_row(p, 0.31);
3643        x_star.mapv_inplace(|v| 6.0 * v);
3644        let eng = GlmHomotopyFullConformal::new(
3645            CanonicalGlmFamily::PoissonLog,
3646            &x,
3647            &y,
3648            &weights,
3649            &s,
3650            &x_star,
3651        )
3652        .expect("engine");
3653        let alpha = 0.2;
3654        let set = eng
3655            .prediction_set(&[1.0, 2000.0], alpha)
3656            .expect("set under extreme jump");
3657        assert!(
3658            set.refit_fallbacks >= 1,
3659            "a 1 → 2000 Poisson candidate jump at ‖x_*‖ = {} must exhaust the certified \
3660             step budget (b‴ = eʸ explodes along the path) and fall back to a cold refit; \
3661             got {} fallbacks",
3662            x_star.dot(&x_star).sqrt(),
3663            set.refit_fallbacks
3664        );
3665        assert!(
3666            set.candidates[1].cold_refit,
3667            "the candidate decided through the fallback must be marked cold"
3668        );
3669        // Exactness preserved under fallback: the verdicts and coefficients
3670        // still match the independent oracle within the computed bound.
3671        let mean_p = |eta: f64| eta.exp();
3672        let weight_p = |eta: f64| eta.exp();
3673        let nll_p = |eta: f64, yv: f64| eta.exp() - yv * eta;
3674        for c in &set.candidates {
3675            let beta_ref = oracle_glm_refit(&x, &y, &s, &x_star, c.z, &mean_p, &weight_p, &nll_p);
3676            let mut diff = c.beta.clone();
3677            diff.scaled_add(-1.0, &beta_ref);
3678            assert!(
3679                vec_norm(&diff) <= c.beta_error_bound + 1e-6,
3680                "fallback coefficients at z={} drifted {} from the oracle refit (bound {})",
3681                c.z,
3682                vec_norm(&diff),
3683                c.beta_error_bound
3684            );
3685            let member_ref = oracle_glm_membership(&x, &y, &x_star, c.z, alpha, &beta_ref, &mean_p);
3686            assert_eq!(
3687                c.member, member_ref,
3688                "fallback membership at z={} disagrees with the oracle refit",
3689                c.z
3690            );
3691        }
3692    }
3693
3694    /// (#942 test b) The closed-form Sherman–Morrison jackknife+ must equal
3695    /// the brute-force construction from n actual leave-one-out refits —
3696    /// endpoints assembled with the exact Barber et al. (2021) order
3697    /// statistics — and the ±∞ honesty must engage exactly when the order
3698    /// statistic does not exist.
3699    #[test]
3700    fn jackknife_plus_matches_brute_force_loo_refits() {
3701        use std::f64::consts::PI;
3702        let n = 20usize;
3703        let p = 4usize;
3704        let mut x = Array2::<f64>::zeros((n, p));
3705        let mut y = Array1::<f64>::zeros(n);
3706        for i in 0..n {
3707            let t = i as f64 / (n as f64 - 1.0);
3708            for j in 0..p {
3709                x[[i, j]] = (j as f64 * PI * t).cos();
3710            }
3711            y[i] = (2.0 * PI * t).sin() + 0.15 * (11.0 * i as f64 + 0.3).sin();
3712        }
3713        let mut s = Array2::<f64>::eye(p);
3714        s *= 0.7;
3715        let weights = Array1::<f64>::ones(n);
3716        let x_star = cosine_row(p, 0.43);
3717        let alpha = 0.2;
3718
3719        let jk = gaussian_jackknife_plus(&x, &y, &weights, &s, &x_star, alpha).expect("jk+");
3720
3721        // Brute force: n explicit LOO refits, manual order statistics.
3722        let mut lower_vals: Vec<f64> = Vec::with_capacity(n);
3723        let mut upper_vals: Vec<f64> = Vec::with_capacity(n);
3724        for i in 0..n {
3725            let mut m = s.clone();
3726            let mut rhs = Array1::<f64>::zeros(p);
3727            for r in 0..n {
3728                if r == i {
3729                    continue;
3730                }
3731                for a in 0..p {
3732                    rhs[a] += x[[r, a]] * y[r];
3733                    for b in 0..p {
3734                        m[[a, b]] += x[[r, a]] * x[[r, b]];
3735                    }
3736                }
3737            }
3738            let chol = m.cholesky(Side::Lower).expect("loo chol");
3739            let beta = chol.solvevec(&rhs);
3740            let mu_star = x_star.dot(&beta);
3741            let resid = (y[i] - x.row(i).dot(&beta)).abs();
3742            lower_vals.push(mu_star - resid);
3743            upper_vals.push(mu_star + resid);
3744        }
3745        lower_vals.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
3746        upper_vals.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
3747        let rank_hi = ((n as f64 + 1.0) * (1.0 - alpha)).ceil() as usize;
3748        let rank_lo = ((n as f64 + 1.0) * alpha).floor() as usize;
3749        assert!(
3750            rank_lo >= 1 && rank_hi <= n,
3751            "fixture sized to certify finite endpoints"
3752        );
3753        let lo_bf = lower_vals[rank_lo - 1];
3754        let hi_bf = upper_vals[rank_hi - 1];
3755        assert!(
3756            (jk.lo - lo_bf).abs() <= 1e-8 * (1.0 + lo_bf.abs()),
3757            "jackknife+ lower endpoint {} disagrees with brute-force LOO refits {}",
3758            jk.lo,
3759            lo_bf
3760        );
3761        assert!(
3762            (jk.hi - hi_bf).abs() <= 1e-8 * (1.0 + hi_bf.abs()),
3763            "jackknife+ upper endpoint {} disagrees with brute-force LOO refits {}",
3764            jk.hi,
3765            hi_bf
3766        );
3767        assert!(jk.certifies_finite());
3768        assert!(jk.lo < jk.hi);
3769        assert_eq!(jk.n, n);
3770
3771        // Honest ±∞: at α = 0.04 with n = 20, ⌈21·0.96⌉ = 21 > n and
3772        // ⌊21·0.04⌋ = 0 < 1 — both order statistics are out of range, so
3773        // both endpoints must be infinite, exactly like the split module's
3774        // +∞ multiplier convention.
3775        let tight = gaussian_jackknife_plus(&x, &y, &weights, &s, &x_star, 0.04).expect("tight");
3776        assert!(tight.hi.is_infinite() && tight.hi > 0.0);
3777        assert!(tight.lo.is_infinite() && tight.lo < 0.0);
3778        assert!(!tight.certifies_finite());
3779
3780        // The assembly is the pure-core seam CV+ also routes through:
3781        // degenerate one-point input keeps the exact rank arithmetic honest.
3782        let one_pred = Array1::<f64>::from(vec![1.0]);
3783        let one_res = Array1::<f64>::from(vec![0.5]);
3784        let tiny = jackknife_plus_interval(&one_pred, &one_res, 0.2).expect("tiny");
3785        assert!(tiny.hi.is_infinite() && tiny.lo.is_infinite());
3786    }
3787
3788    /// The precomputed `GaussianJackknifePlusStats` substrate (factored once,
3789    /// replayed per test point — the form the predict-path magic uses) must be
3790    /// bit-for-bit equivalent to the single-shot `gaussian_jackknife_plus` at
3791    /// every test point. This pins the refactor: the saved-model replay can
3792    /// never silently drift from the certified reference.
3793    #[test]
3794    fn jackknife_plus_stats_replay_matches_single_shot() {
3795        use std::f64::consts::PI;
3796        let n = 18usize;
3797        let p = 4usize;
3798        let mut x = Array2::<f64>::zeros((n, p));
3799        let mut y = Array1::<f64>::zeros(n);
3800        for i in 0..n {
3801            let t = i as f64 / (n as f64 - 1.0);
3802            for j in 0..p {
3803                x[[i, j]] = (j as f64 * PI * t).cos();
3804            }
3805            y[i] = (2.0 * PI * t).sin() + 0.2 * (7.0 * i as f64 + 0.9).sin();
3806        }
3807        let mut s = Array2::<f64>::eye(p);
3808        s *= 0.55;
3809        let weights = Array1::<f64>::ones(n);
3810        let alpha = 0.1;
3811
3812        let stats = GaussianJackknifePlusStats::new(&x, &y, &weights, &s).expect("stats");
3813        assert_eq!(stats.n(), n);
3814        assert_eq!(stats.p(), p);
3815
3816        for k in 0..7 {
3817            let x_star = cosine_row(p, 0.13 + 0.11 * k as f64);
3818            let single =
3819                gaussian_jackknife_plus(&x, &y, &weights, &s, &x_star, alpha).expect("single");
3820            let replay = stats.interval(&x_star, alpha).expect("replay");
3821            assert!(
3822                (single.lo - replay.lo).abs() <= 1e-12 * (1.0 + single.lo.abs()),
3823                "stats replay lower {} != single-shot {} at test point {k}",
3824                replay.lo,
3825                single.lo
3826            );
3827            assert!(
3828                (single.hi - replay.hi).abs() <= 1e-12 * (1.0 + single.hi.abs()),
3829                "stats replay upper {} != single-shot {} at test point {k}",
3830                replay.hi,
3831                single.hi
3832            );
3833            assert_eq!(single.n, replay.n);
3834        }
3835
3836        // Eligibility gate: a reweighted training row is rejected (no
3837        // exchangeability), never silently certified.
3838        let mut bad_w = Array1::<f64>::ones(n);
3839        bad_w[3] = 2.0;
3840        assert!(GaussianJackknifePlusStats::new(&x, &y, &bad_w, &s).is_err());
3841    }
3842
3843    /// Held-out empirical coverage smoke: across many fresh draws of a
3844    /// synthetic Gaussian-identity problem, the jackknife+ interval at a held-
3845    /// out test point must cover the realized response at least at the
3846    /// requested `1 − 2α` rate (small Monte-Carlo slack), with finite width.
3847    #[test]
3848    fn jackknife_plus_empirical_coverage_smoke() {
3849        use std::f64::consts::PI;
3850        let n = 40usize;
3851        let p = 4usize;
3852        let alpha = 0.1; // target coverage ≥ 1 − 2α = 0.8
3853        let s = {
3854            let mut s = Array2::<f64>::eye(p);
3855            s *= 0.4;
3856            s
3857        };
3858        let weights = Array1::<f64>::ones(n);
3859
3860        // Deterministic LCG so the smoke is reproducible without an RNG dep.
3861        // `state` is threaded explicitly so `normal` can draw from the same
3862        // stream without a second mutable borrow of a captured `unif` closure.
3863        let mut state: u64 = 0x9e37_79b9_7f4a_7c15;
3864        let unif = |state: &mut u64| {
3865            *state = state
3866                .wrapping_mul(6364136223846793005)
3867                .wrapping_add(1442695040888963407);
3868            ((*state >> 11) as f64) / ((1u64 << 53) as f64)
3869        };
3870        // Box–Muller standard normal.
3871        let normal = |state: &mut u64| {
3872            let u1 = unif(state).max(1e-12);
3873            let u2 = unif(state);
3874            (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
3875        };
3876        let beta_true = [0.8_f64, -0.5, 0.3, 0.15];
3877        let sigma = 0.5_f64;
3878        let design_row = |z: f64| {
3879            let mut r = Array1::<f64>::zeros(p);
3880            for j in 0..p {
3881                r[j] = (j as f64 * PI * z).cos();
3882            }
3883            r
3884        };
3885
3886        let trials = 200usize;
3887        let mut covered = 0usize;
3888        for _ in 0..trials {
3889            let mut x = Array2::<f64>::zeros((n, p));
3890            let mut yv = Array1::<f64>::zeros(n);
3891            for i in 0..n {
3892                let z = unif(&mut state);
3893                let row = design_row(z);
3894                let mut eta = 0.0;
3895                for j in 0..p {
3896                    x[[i, j]] = row[j];
3897                    eta += beta_true[j] * row[j];
3898                }
3899                yv[i] = eta + sigma * normal(&mut state);
3900            }
3901            let stats = match GaussianJackknifePlusStats::new(&x, &yv, &weights, &s) {
3902                Ok(s) => s,
3903                Err(_) => continue,
3904            };
3905            let z_star = unif(&mut state);
3906            let x_star = design_row(z_star);
3907            let mut eta_star = 0.0;
3908            for j in 0..p {
3909                eta_star += beta_true[j] * x_star[j];
3910            }
3911            let y_star = eta_star + sigma * normal(&mut state);
3912            let itv = stats.interval(&x_star, alpha).expect("coverage interval");
3913            assert!(
3914                itv.certifies_finite(),
3915                "coverage trial produced infinite width"
3916            );
3917            if y_star >= itv.lo && y_star <= itv.hi {
3918                covered += 1;
3919            }
3920        }
3921        let rate = covered as f64 / trials as f64;
3922        // Distribution-free guarantee is ≥ 0.8; allow Monte-Carlo slack below.
3923        assert!(
3924            rate >= 0.74,
3925            "jackknife+ empirical coverage {rate} fell below the 1−2α target with slack"
3926        );
3927    }
3928}