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