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}