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