Skip to main content

gam_solve/
constrained_gaussian_reml.rs

1//! Analytic active-face adjoint for constrained Gaussian REML.
2//!
3//! At a locally constant active set, `A_a beta = b_a`, every coefficient
4//! perturbation lies in `null(A_a)`.  If `Z` is an orthonormal basis of that
5//! tangent space and
6//!
7//! ```text
8//! H = X' W X + lambda S,
9//! P = Z (Z' H Z)^-1 Z',
10//! Q = Z (Z' S Z)^+ Z',
11//! ```
12//!
13//! then `P` is the response kernel for the constrained KKT system and `Q` is
14//! the penalty pseudo-inverse on the same face.  Crucially, the formulas below
15//! retain the *full affine coefficient* `beta`; replacing it by `Z' beta`
16//! loses the penalty cross and constant terms whenever `b_a != 0`.
17
18use crate::active_set::{
19    feasible_point_for_linear_constraints, solve_quadratic_with_linear_constraints,
20};
21use crate::estimate::EstimationError;
22use crate::gaussian_reml::{
23    GaussianRemlBackwardResult, gaussian_reml_multi_closed_form_with_cache,
24};
25use faer::Side;
26use gam_linalg::faer_ndarray::{
27    FaerCholesky, FaerEigh, default_rrqr_rank_alpha, rrqr_nullspace_basis, rrqr_with_permutation,
28};
29use gam_problem::LinearInequalityConstraints;
30use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
31use opt::{Bfgs, Bounds, FirstOrderSample, FusedObjective, GradientTolerance, ObjectiveEvalError};
32
33/// Inputs to the single-penalty constrained Gaussian REML fit.
34///
35/// The public operation defines one criterion in both regimes: ordinary
36/// closed-form Gaussian REML when the unconstrained optimum is interior, and
37/// the exact affine-face restriction of that same criterion when constraints
38/// bind.  There is deliberately no generic-GAM rho prior, shrinkage floor, or
39/// ALO correction hidden in this low-level primitive.
40pub struct ConstrainedGaussianRemlForwardProblem<'a> {
41    pub x: ArrayView2<'a, f64>,
42    pub y: ArrayView2<'a, f64>,
43    pub penalty: ArrayView2<'a, f64>,
44    pub weights: Option<ArrayView1<'a, f64>>,
45    pub constraints: Option<&'a LinearInequalityConstraints>,
46    pub init_lambda: Option<f64>,
47}
48
49/// Accepted state of the constrained Gaussian REML fit.
50pub struct ConstrainedGaussianRemlForwardResult {
51    pub lambda: f64,
52    pub coefficients: Array2<f64>,
53    pub fitted: Array2<f64>,
54    pub reml_score: f64,
55    pub edf: f64,
56    pub active_indices: Array1<u64>,
57}
58
59/// Inputs to the constrained Gaussian REML active-face VJP.
60///
61/// Constraint geometry is deliberately non-differentiable.  `coefficients`
62/// and `lambda` are the accepted forward state; unlike the old reduced helper,
63/// this routine never launches a second smoothing optimization in backward.
64pub struct ConstrainedGaussianRemlBackwardProblem<'a> {
65    pub x: ArrayView2<'a, f64>,
66    pub y: ArrayView2<'a, f64>,
67    pub penalty: ArrayView2<'a, f64>,
68    pub weights: Option<ArrayView1<'a, f64>>,
69    pub a_inequality: ArrayView2<'a, f64>,
70    pub b_inequality: ArrayView1<'a, f64>,
71    pub active_indices: ArrayView1<'a, u64>,
72    pub lambda: f64,
73    pub coefficients: ArrayView2<'a, f64>,
74    pub grad_coefficients: Option<ArrayView2<'a, f64>>,
75    pub grad_fitted: Option<ArrayView2<'a, f64>>,
76    pub grad_lambda: f64,
77    pub grad_log_lambda: f64,
78    pub grad_reml_score: f64,
79    pub grad_edf: f64,
80}
81
82#[derive(Clone)]
83struct ActiveFace {
84    a: Array2<f64>,
85    b: Array1<f64>,
86    z: Array2<f64>,
87}
88
89#[derive(Clone)]
90struct AffineFaceProfile {
91    x: Array2<f64>,
92    y: Array2<f64>,
93    penalty: Array2<f64>,
94    weights: Array1<f64>,
95    face: ActiveFace,
96    beta_particular: Array2<f64>,
97    tangent_gram: Array2<f64>,
98    tangent_penalty: Array2<f64>,
99    tangent_rhs_data: Array2<f64>,
100    tangent_penalty_particular: Array2<f64>,
101    penalty_rank: usize,
102    penalty_logdet: f64,
103    residual_df: f64,
104}
105
106struct AffineFaceEvaluation {
107    rho: f64,
108    lambda: f64,
109    score: f64,
110    rho_gradient: f64,
111    rho_curvature: f64,
112    edf: f64,
113    beta: Array2<f64>,
114    fitted: Array2<f64>,
115}
116
117struct TangentPenaltyGeometry {
118    rank: usize,
119    logdet: f64,
120    pseudoinverse: Array2<f64>,
121}
122
123struct FaceState {
124    penalty: Array2<f64>,
125    weights: Array1<f64>,
126    beta: Array2<f64>,
127    residual: Array2<f64>,
128    gram: Array2<f64>,
129    p_response: Array2<f64>,
130    q_penalty: Array2<f64>,
131    penalty_rank: usize,
132    residual_df: f64,
133}
134
135struct VjpAccumulator {
136    x: Array2<f64>,
137    y: Array2<f64>,
138    penalty: Array2<f64>,
139    weights: Array1<f64>,
140    lambda: f64,
141}
142
143impl VjpAccumulator {
144    fn zeros(n: usize, p: usize, d: usize) -> Self {
145        Self {
146            x: Array2::zeros((n, p)),
147            y: Array2::zeros((n, d)),
148            penalty: Array2::zeros((p, p)),
149            weights: Array1::zeros(n),
150            lambda: 0.0,
151        }
152    }
153}
154
155/// Fit the pure single-penalty Gaussian REML criterion on the accepted affine
156/// KKT face.  The active face and smoothing parameter are alternated until the
157/// constrained quadratic solve returns the same face optimized by the scalar
158/// REML step.  Because there are finitely many faces, a repeated non-fixed face
159/// is a typed optimization failure rather than a best-effort fit.
160pub fn constrained_gaussian_reml_forward(
161    problem: ConstrainedGaussianRemlForwardProblem<'_>,
162) -> Result<ConstrainedGaussianRemlForwardResult, EstimationError> {
163    validate_forward_problem(&problem)?;
164    let canonical_constraints = problem
165        .constraints
166        .map(LinearInequalityConstraints::canonicalized)
167        .transpose()
168        .map_err(|message| EstimationError::InvalidInput(message))?;
169
170    let unconstrained = gaussian_reml_multi_closed_form_with_cache(
171        problem.x,
172        problem.y,
173        problem.penalty,
174        problem.weights,
175        problem.init_lambda,
176        None,
177    )?;
178    let Some(constraints) = canonical_constraints.as_ref() else {
179        return Ok(unconstrained_result(unconstrained));
180    };
181    if constraints.a.nrows() == 0 {
182        return Ok(unconstrained_result(unconstrained));
183    }
184    let unconstrained_active = binding_rows(constraints, unconstrained.coefficients.column(0));
185    if unconstrained_active.is_empty() {
186        return Ok(unconstrained_result(unconstrained));
187    }
188
189    let n = problem.x.nrows();
190    let p = problem.x.ncols();
191    let penalty = symmetric_average(problem.penalty);
192    let weights = problem
193        .weights
194        .map_or_else(|| Array1::ones(n), |values| values.to_owned());
195    let weighted_x = &problem.x.to_owned() * &weights.view().insert_axis(Axis(1));
196    let gram = problem.x.t().dot(&weighted_x);
197    let weighted_y = &problem.y.to_owned() * &weights.view().insert_axis(Axis(1));
198    let rhs = problem.x.t().dot(&weighted_y).column(0).to_owned();
199    let mut beta_start =
200        feasible_point_for_linear_constraints(constraints, p).ok_or_else(|| {
201            EstimationError::ParameterConstraintViolation(
202                "constrained Gaussian REML could not construct a feasible coefficient seed"
203                    .to_string(),
204            )
205        })?;
206    let mut rho = unconstrained.rho;
207    let mut active_hint: Vec<usize> = unconstrained_active
208        .iter()
209        .map(|&index| index as usize)
210        .collect();
211    let mut visited_faces: Vec<Vec<u64>> = Vec::new();
212
213    loop {
214        let lambda = gam_problem::checked_exp_log_strength(rho)
215            .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
216        let hessian = &gram + &(penalty.clone() * lambda);
217        let (qp_beta, qp_active) = solve_quadratic_with_linear_constraints(
218            &hessian,
219            &rhs,
220            &beta_start,
221            constraints,
222            Some(&active_hint),
223        )?;
224        let active = binding_rows(constraints, qp_beta.view());
225        if active.is_empty() {
226            return Err(EstimationError::GradientUnavailable {
227                context: "constrained Gaussian REML forward",
228                mode: "active-face selection reached an unresolved constraint transition",
229            });
230        }
231        let active_key = active.to_vec();
232        if visited_faces.iter().any(|seen| seen == &active_key) {
233            return Err(EstimationError::GradientUnavailable {
234                context: "constrained Gaussian REML forward",
235                mode: "active-face smoothing iteration cycles at a constraint transition",
236            });
237        }
238        visited_faces.push(active_key);
239
240        let profile = AffineFaceProfile::new(
241            problem.x,
242            problem.y,
243            penalty.view(),
244            weights.view(),
245            constraints,
246            active.view(),
247        )?;
248        let accepted = optimize_affine_face(&profile, rho)?;
249
250        let accepted_hessian = &gram + &(penalty.clone() * accepted.lambda);
251        let accepted_beta = accepted.beta.column(0).to_owned();
252        let (qp_check, next_hint) = solve_quadratic_with_linear_constraints(
253            &accepted_hessian,
254            &rhs,
255            &accepted_beta,
256            constraints,
257            Some(&qp_active),
258        )?;
259        let next_active = binding_rows(constraints, qp_check.view());
260        if next_active == active {
261            let beta_scale = accepted_beta
262                .iter()
263                .chain(qp_check.iter())
264                .fold(1.0_f64, |scale, &value| scale.max(value.abs()));
265            let agreement = accepted_beta
266                .iter()
267                .zip(qp_check.iter())
268                .fold(0.0_f64, |maximum, (&left, &right)| {
269                    maximum.max((left - right).abs())
270                });
271            let resolution = crate::pirls::ACTIVE_SET_PRIMAL_FEASIBILITY_TOL * beta_scale;
272            if agreement > resolution {
273                return Err(EstimationError::GradientUnavailable {
274                    context: "constrained Gaussian REML forward",
275                    mode: "affine-face optimum does not agree with its constrained KKT solve",
276                });
277            }
278            return Ok(ConstrainedGaussianRemlForwardResult {
279                lambda: accepted.lambda,
280                coefficients: accepted.beta,
281                fitted: accepted.fitted,
282                reml_score: accepted.score,
283                edf: accepted.edf,
284                active_indices: active,
285            });
286        }
287
288        beta_start = qp_check;
289        rho = accepted.rho;
290        active_hint = next_hint;
291    }
292}
293
294fn validate_forward_problem(
295    problem: &ConstrainedGaussianRemlForwardProblem<'_>,
296) -> Result<(), EstimationError> {
297    let n = problem.x.nrows();
298    let p = problem.x.ncols();
299    if problem.y.dim() != (n, 1) || problem.penalty.dim() != (p, p) {
300        crate::bail_invalid_estim!(
301            "constrained Gaussian REML forward input shapes are inconsistent"
302        );
303    }
304    if let Some(weights) = problem.weights
305        && weights.len() != n
306    {
307        crate::bail_invalid_estim!(
308            "constrained Gaussian REML weights length {} does not match row count {n}",
309            weights.len()
310        );
311    }
312    if let Some(constraints) = problem.constraints
313        && (constraints.a.ncols() != p || constraints.b.len() != constraints.a.nrows())
314    {
315        crate::bail_invalid_estim!(
316            "constrained Gaussian REML constraint dimensions are inconsistent"
317        );
318    }
319    if problem
320        .x
321        .iter()
322        .chain(problem.y.iter())
323        .chain(problem.penalty.iter())
324        .any(|value| !value.is_finite())
325    {
326        crate::bail_invalid_estim!("constrained Gaussian REML inputs must be finite");
327    }
328    if let Some(weights) = problem.weights
329        && weights
330            .iter()
331            .any(|value| !value.is_finite() || *value < 0.0)
332    {
333        crate::bail_invalid_estim!(
334            "constrained Gaussian REML weights must be finite and non-negative"
335        );
336    }
337    Ok(())
338}
339
340fn unconstrained_result(
341    result: crate::gaussian_reml::GaussianRemlMultiResult,
342) -> ConstrainedGaussianRemlForwardResult {
343    ConstrainedGaussianRemlForwardResult {
344        lambda: result.lambda,
345        coefficients: result.coefficients,
346        fitted: result.fitted,
347        reml_score: result.reml_score,
348        edf: result.edf,
349        active_indices: Array1::zeros(0),
350    }
351}
352
353fn binding_rows(
354    constraints: &LinearInequalityConstraints,
355    beta: ArrayView1<'_, f64>,
356) -> Array1<u64> {
357    let beta_scale = beta
358        .iter()
359        .fold(1.0_f64, |scale, &value| scale.max(value.abs()));
360    let mut active = Vec::new();
361    for row in 0..constraints.a.nrows() {
362        let normal = constraints.a.row(row);
363        if normal.iter().all(|&value| value == 0.0) {
364            continue;
365        }
366        let slack = normal.dot(&beta) - constraints.b[row];
367        let resolution = crate::pirls::ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
368            * beta_scale.max(constraints.b[row].abs().max(1.0));
369        if slack <= resolution {
370            active.push(row as u64);
371        }
372    }
373    Array1::from_vec(active)
374}
375
376impl AffineFaceProfile {
377    fn new(
378        x: ArrayView2<'_, f64>,
379        y: ArrayView2<'_, f64>,
380        penalty: ArrayView2<'_, f64>,
381        weights: ArrayView1<'_, f64>,
382        constraints: &LinearInequalityConstraints,
383        active_indices: ArrayView1<'_, u64>,
384    ) -> Result<Self, EstimationError> {
385        let face = active_face_from_parts(
386            constraints.a.view(),
387            constraints.b.view(),
388            active_indices,
389            x.ncols(),
390        )?;
391        let normal_gram = face.a.dot(&face.a.t());
392        let normal_factor = normal_gram
393            .cholesky(Side::Lower)
394            .map_err(EstimationError::LinearSystemSolveFailed)?;
395        let normal_coordinates = normal_factor.solvevec(&face.b);
396        let beta_particular_vec = face.a.t().dot(&normal_coordinates);
397        let beta_particular = beta_particular_vec.insert_axis(Axis(1));
398        let tangent_design = x.dot(&face.z);
399        let weighted_tangent_design = &tangent_design * &weights.view().insert_axis(Axis(1));
400        let tangent_gram = tangent_design.t().dot(&weighted_tangent_design);
401        let tangent_penalty = face.z.t().dot(&penalty).dot(&face.z);
402        let base_response = y.to_owned() - &x.dot(&beta_particular);
403        let weighted_base_response = &base_response * &weights.view().insert_axis(Axis(1));
404        let tangent_rhs_data = tangent_design.t().dot(&weighted_base_response);
405        let tangent_penalty_particular = face.z.t().dot(&penalty).dot(&beta_particular);
406        let penalty_geometry = tangent_penalty_geometry(&tangent_penalty)?;
407        let penalty_rank = penalty_geometry.rank;
408        let penalty_logdet = penalty_geometry.logdet;
409        let n_effective = weights.iter().filter(|&&value| value > 0.0).count();
410        let penalty_nullity = face.z.ncols().saturating_sub(penalty_rank);
411        if n_effective <= penalty_nullity {
412            crate::bail_invalid_estim!(
413                "constrained Gaussian REML requires more positive-weight rows than tangent penalty nullity"
414            );
415        }
416        Ok(Self {
417            x: x.to_owned(),
418            y: y.to_owned(),
419            penalty: penalty.to_owned(),
420            weights: weights.to_owned(),
421            face,
422            beta_particular,
423            tangent_gram,
424            tangent_penalty,
425            tangent_rhs_data,
426            tangent_penalty_particular,
427            penalty_rank,
428            penalty_logdet,
429            residual_df: (n_effective - penalty_nullity) as f64,
430        })
431    }
432
433    fn evaluate(&self, rho: f64) -> Result<AffineFaceEvaluation, EstimationError> {
434        let lambda = gam_problem::checked_exp_log_strength(rho)
435            .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
436        let tangent_dim = self.face.z.ncols();
437        let (gamma, inverse, logdet_h) = if tangent_dim == 0 {
438            (Array2::zeros((0, 1)), Array2::zeros((0, 0)), 0.0)
439        } else {
440            let hessian = &self.tangent_gram + &(self.tangent_penalty.clone() * lambda);
441            let factor = hessian
442                .cholesky(Side::Lower)
443                .map_err(EstimationError::LinearSystemSolveFailed)?;
444            let rhs = &self.tangent_rhs_data - &(self.tangent_penalty_particular.clone() * lambda);
445            let gamma = factor.solve_mat(&rhs);
446            let inverse = factor.solve_mat(&Array2::<f64>::eye(tangent_dim));
447            let logdet_h = 2.0 * factor.diag().iter().map(|value| value.ln()).sum::<f64>();
448            (gamma, inverse, logdet_h)
449        };
450        let beta = &self.beta_particular + &self.face.z.dot(&gamma);
451        let fitted = self.x.dot(&beta);
452        let residual = &self.y - &fitted;
453        let weighted_rss =
454            (&residual * &residual * &self.weights.view().insert_axis(Axis(1))).sum();
455        let penalty_beta = self.penalty.dot(&beta);
456        let energy = (&beta * &penalty_beta).sum();
457        let penalized_deviance = weighted_rss + lambda * energy;
458        if !penalized_deviance.is_finite() || penalized_deviance <= 0.0 {
459            crate::bail_invalid_estim!(
460                "constrained Gaussian REML profiled deviance must be positive"
461            );
462        }
463
464        let inverse_penalty = inverse.dot(&self.tangent_penalty);
465        let trace_inverse_penalty = trace(&inverse_penalty);
466        let trace_inverse_penalty_squared = trace(&inverse_penalty.dot(&inverse_penalty));
467        let tangent_penalty_beta = self.face.z.t().dot(&penalty_beta);
468        let curvature_energy = if tangent_dim == 0 {
469            0.0
470        } else {
471            (&tangent_penalty_beta * &inverse.dot(&tangent_penalty_beta)).sum()
472        };
473        let logdet_penalty = self.penalty_logdet + self.penalty_rank as f64 * rho;
474        let score = 0.5 * (logdet_h - logdet_penalty)
475            + 0.5
476                * self.residual_df
477                * (1.0 + (2.0 * std::f64::consts::PI * penalized_deviance / self.residual_df).ln());
478        let lambda_energy = lambda * energy;
479        let rho_gradient = 0.5 * (lambda * trace_inverse_penalty - self.penalty_rank as f64)
480            + 0.5 * self.residual_df * lambda_energy / penalized_deviance;
481        let rho_curvature = 0.5
482            * (lambda * trace_inverse_penalty - lambda * lambda * trace_inverse_penalty_squared)
483            + 0.5
484                * self.residual_df
485                * ((lambda_energy - 2.0 * lambda * lambda * curvature_energy) / penalized_deviance
486                    - (lambda_energy / penalized_deviance).powi(2));
487        let edf = tangent_dim as f64 - lambda * trace_inverse_penalty;
488        Ok(AffineFaceEvaluation {
489            rho,
490            lambda,
491            score,
492            rho_gradient,
493            rho_curvature,
494            edf,
495            beta,
496            fitted,
497        })
498    }
499}
500
501fn tangent_penalty_geometry(
502    penalty: &Array2<f64>,
503) -> Result<TangentPenaltyGeometry, EstimationError> {
504    if penalty.is_empty() {
505        return Ok(TangentPenaltyGeometry {
506            rank: 0,
507            logdet: 0.0,
508            pseudoinverse: Array2::zeros(penalty.dim()),
509        });
510    }
511    let (eigenvalues, eigenvectors) = penalty
512        .eigh(Side::Lower)
513        .map_err(EstimationError::EigendecompositionFailed)?;
514    let scale = eigenvalues
515        .iter()
516        .fold(0.0_f64, |maximum, &value| maximum.max(value.abs()));
517    let tolerance =
518        default_rrqr_rank_alpha() * f64::EPSILON * penalty.nrows().max(1) as f64 * scale;
519    let mut rank = 0usize;
520    let mut logdet = 0.0;
521    let mut scaled_eigenvectors = Array2::<f64>::zeros(eigenvectors.dim());
522    for (index, &value) in eigenvalues.iter().enumerate() {
523        if !value.is_finite() {
524            return Err(EstimationError::PenaltySpectrumNonFinite {
525                context: "constrained Gaussian REML tangent penalty".to_string(),
526                index,
527                value,
528            });
529        }
530        if value < -tolerance {
531            return Err(EstimationError::PenaltySpectrumIndefinite {
532                context: "constrained Gaussian REML tangent penalty".to_string(),
533                index,
534                value,
535                tolerance,
536                scale,
537            });
538        }
539        if value > tolerance {
540            rank += 1;
541            logdet += value.ln();
542            for row in 0..eigenvectors.nrows() {
543                scaled_eigenvectors[[row, index]] = eigenvectors[[row, index]] / value;
544            }
545        }
546    }
547    Ok(TangentPenaltyGeometry {
548        rank,
549        logdet,
550        pseudoinverse: scaled_eigenvectors.dot(&eigenvectors.t()),
551    })
552}
553
554fn optimize_affine_face(
555    profile: &AffineFaceProfile,
556    initial_rho: f64,
557) -> Result<AffineFaceEvaluation, EstimationError> {
558    let bound = crate::estimate::RHO_BOUND;
559    let seed_rho = initial_rho.clamp(-bound, bound);
560    let seed = profile.evaluate(seed_rho)?;
561    let seed_point = Array1::from_vec(vec![seed_rho]);
562    let initial_sample = FirstOrderSample {
563        value: seed.score,
564        gradient: Array1::from_vec(vec![seed.rho_gradient]),
565    };
566    let objective_profile = profile.clone();
567    let objective = FusedObjective::new(move |point: &Array1<f64>| {
568        objective_profile
569            .evaluate(point[0])
570            .map(|evaluation| FirstOrderSample {
571                value: evaluation.score,
572                gradient: Array1::from_vec(vec![evaluation.rho_gradient]),
573            })
574            .map_err(|error| ObjectiveEvalError::fatal(error.to_string()))
575    });
576    let bound_resolution = f64::EPSILON.sqrt();
577    let stationarity_resolution = rho_stationarity_resolution();
578    let bounds = Bounds::new(
579        Array1::from_vec(vec![-bound]),
580        Array1::from_vec(vec![bound]),
581        bound_resolution,
582    )
583    .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
584    let mut optimizer = Bfgs::new(seed_point.clone(), objective)
585        .with_initial_sample(seed_point, initial_sample)
586        .with_bounds(bounds)
587        .with_gradient_tolerance(GradientTolerance::relative_to_cost(stationarity_resolution));
588    let solution = optimizer.run().map_err(|error| {
589        EstimationError::RemlOptimizationFailed(format!(
590            "affine-face Gaussian REML optimization did not converge: {error}"
591        ))
592    })?;
593    let mut accepted = polish_affine_rho(profile, solution.final_point[0])?;
594    for endpoint in [-bound, bound] {
595        let candidate = profile.evaluate(endpoint)?;
596        let projected_stationary = (endpoint < 0.0 && candidate.rho_gradient >= 0.0)
597            || (endpoint > 0.0 && candidate.rho_gradient <= 0.0);
598        if projected_stationary && candidate.score < accepted.score {
599            accepted = candidate;
600        }
601    }
602    Ok(accepted)
603}
604
605fn polish_affine_rho(
606    profile: &AffineFaceProfile,
607    initial_rho: f64,
608) -> Result<AffineFaceEvaluation, EstimationError> {
609    let bound = crate::estimate::RHO_BOUND;
610    let resolution = rho_stationarity_resolution();
611    let bound_resolution = f64::EPSILON.sqrt();
612    let mut rho = initial_rho.clamp(-bound, bound);
613    loop {
614        let current = profile.evaluate(rho)?;
615        let at_lower = rho <= -bound + bound_resolution * bound.max(1.0);
616        let at_upper = rho >= bound - bound_resolution * bound.max(1.0);
617        if (at_lower && current.rho_gradient >= 0.0) || (at_upper && current.rho_gradient <= 0.0) {
618            return Ok(current);
619        }
620        let curvature_resolution = resolution * (1.0 + current.rho_gradient.abs());
621        if !current.rho_curvature.is_finite() || current.rho_curvature <= curvature_resolution {
622            return Err(EstimationError::GradientUnavailable {
623                context: "constrained Gaussian REML forward",
624                mode: "affine-face smoothing optimum has unresolved positive curvature",
625            });
626        }
627        if current.rho_gradient.abs() <= resolution * (1.0 + current.score.abs()) {
628            return Ok(current);
629        }
630        let mut candidate_rho =
631            (rho - current.rho_gradient / current.rho_curvature).clamp(-bound, bound);
632        if candidate_rho.to_bits() == rho.to_bits() {
633            return Err(EstimationError::GradientUnavailable {
634                context: "constrained Gaussian REML forward",
635                mode: "affine-face smoothing root is below floating-point resolution",
636            });
637        }
638        let mut candidate = profile.evaluate(candidate_rho)?;
639        while candidate.score >= current.score {
640            candidate_rho = 0.5 * (rho + candidate_rho);
641            if candidate_rho.to_bits() == rho.to_bits() {
642                return Err(EstimationError::GradientUnavailable {
643                    context: "constrained Gaussian REML forward",
644                    mode: "affine-face smoothing polish cannot resolve a descent step",
645                });
646            }
647            candidate = profile.evaluate(candidate_rho)?;
648        }
649        rho = candidate_rho;
650    }
651}
652
653fn rho_stationarity_resolution() -> f64 {
654    let central_difference_scale = f64::EPSILON.cbrt();
655    central_difference_scale * central_difference_scale
656}
657
658/// Exact VJP on a certified, locally constant affine active face.
659pub fn constrained_gaussian_reml_backward(
660    problem: ConstrainedGaussianRemlBackwardProblem<'_>,
661) -> Result<GaussianRemlBackwardResult, EstimationError> {
662    validate_problem(&problem)?;
663    let constraints = LinearInequalityConstraints::new(
664        problem.a_inequality.to_owned(),
665        problem.b_inequality.to_owned(),
666    )
667    .and_then(|constraints| constraints.canonicalized())
668    .map_err(EstimationError::InvalidInput)?;
669    let face = active_face_from_parts(
670        constraints.a.view(),
671        constraints.b.view(),
672        problem.active_indices,
673        problem.x.ncols(),
674    )?;
675    let state = face_state(&problem, &face)?;
676    certify_strict_complementarity(&problem, &state, &face, &constraints)?;
677
678    let n = problem.x.nrows();
679    let p = problem.x.ncols();
680    let d = problem.y.ncols();
681    let mut out = VjpAccumulator::zeros(n, p, d);
682
683    // Coefficient and fitted-value outputs share the KKT mode-response channel.
684    let mut mode_seed = Array2::<f64>::zeros((p, d));
685    if let Some(seed) = problem.grad_coefficients {
686        mode_seed += &seed;
687    }
688    if let Some(seed) = problem.grad_fitted {
689        mode_seed += &problem.x.t().dot(&seed);
690        out.x += &seed.dot(&state.beta.t());
691    }
692    add_mode_vjp(&problem, &state, mode_seed.view(), 1.0, &mut out);
693
694    out.lambda += problem.grad_lambda;
695    out.lambda += problem.grad_log_lambda / problem.lambda;
696
697    if problem.grad_reml_score != 0.0 {
698        add_score_vjp(&problem, &state, problem.grad_reml_score, &mut out);
699    }
700    if problem.grad_edf != 0.0 {
701        add_face_edf_vjp(&problem, &state, problem.grad_edf, &mut out);
702    }
703
704    // Every output other than the optimized score itself can pull through the
705    // stationary smoothing root.  At an optimizer box face rho is locally
706    // constant, so that root channel is exactly absent.
707    if out.lambda != 0.0 && !rho_is_box_active(problem.lambda) {
708        let curvature = rho_score_curvature(&problem, &state);
709        let scale = rho_curvature_scale(&problem, &state);
710        let resolution = f64::EPSILON.sqrt() * ((n + p + d).max(1) as f64).sqrt() * scale.max(1.0);
711        if !curvature.is_finite() || curvature <= resolution {
712            return Err(EstimationError::GradientUnavailable {
713                context: "constrained Gaussian REML backward",
714                mode: "active-face smoothing root has unresolved curvature",
715            });
716        }
717        let root_seed = -problem.lambda * out.lambda / curvature;
718        // The explicit lambda cotangent has now been consumed by the implicit
719        // root.  The public result has no lambda-gradient field.
720        add_rho_score_vjp(&problem, &state, root_seed, &mut out);
721    }
722
723    symmetrize_in_place(&mut out.penalty);
724    Ok(GaussianRemlBackwardResult {
725        grad_x: out.x,
726        grad_y: out.y,
727        grad_penalty: out.penalty,
728        grad_weights: out.weights,
729    })
730}
731
732fn validate_problem(
733    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
734) -> Result<(), EstimationError> {
735    let n = problem.x.nrows();
736    let p = problem.x.ncols();
737    let d = problem.y.ncols();
738    if d != 1 {
739        crate::bail_invalid_estim!(
740            "constrained Gaussian REML backward requires one response column; got {d}"
741        );
742    }
743    if problem.y.nrows() != n
744        || problem.penalty.dim() != (p, p)
745        || problem.coefficients.dim() != (p, d)
746        || problem.a_inequality.ncols() != p
747        || problem.b_inequality.len() != problem.a_inequality.nrows()
748    {
749        crate::bail_invalid_estim!(
750            "constrained Gaussian REML backward input shapes are inconsistent"
751        );
752    }
753    if problem.active_indices.is_empty() {
754        crate::bail_invalid_estim!(
755            "constrained Gaussian REML active-face backward requires a non-empty active set"
756        );
757    }
758    if let Some(weights) = problem.weights
759        && weights.len() != n
760    {
761        crate::bail_invalid_estim!(
762            "constrained Gaussian REML weights length {} does not match row count {n}",
763            weights.len()
764        );
765    }
766    if let Some(seed) = problem.grad_coefficients
767        && seed.dim() != (p, d)
768    {
769        crate::bail_invalid_estim!(
770            "constrained Gaussian REML coefficient cotangent shape mismatch"
771        );
772    }
773    if let Some(seed) = problem.grad_fitted
774        && seed.dim() != (n, d)
775    {
776        crate::bail_invalid_estim!("constrained Gaussian REML fitted cotangent shape mismatch");
777    }
778    if !problem.lambda.is_finite() || problem.lambda <= 0.0 {
779        crate::bail_invalid_estim!(
780            "constrained Gaussian REML backward requires a positive finite lambda"
781        );
782    }
783    let all_finite = problem
784        .x
785        .iter()
786        .chain(problem.y.iter())
787        .chain(problem.penalty.iter())
788        .chain(problem.coefficients.iter())
789        .chain(problem.a_inequality.iter())
790        .chain(problem.b_inequality.iter())
791        .all(|value| value.is_finite());
792    if !all_finite {
793        crate::bail_invalid_estim!("constrained Gaussian REML backward inputs must be finite");
794    }
795    if let Some(weights) = problem.weights
796        && weights
797            .iter()
798            .any(|value| !value.is_finite() || *value < 0.0)
799    {
800        crate::bail_invalid_estim!(
801            "constrained Gaussian REML weights must be finite and non-negative"
802        );
803    }
804    Ok(())
805}
806
807fn active_face_from_parts(
808    a_inequality: ArrayView2<'_, f64>,
809    b_inequality: ArrayView1<'_, f64>,
810    active_indices: ArrayView1<'_, u64>,
811    p: usize,
812) -> Result<ActiveFace, EstimationError> {
813    let mut seen = vec![false; a_inequality.nrows()];
814    let mut active = Array2::<f64>::zeros((active_indices.len(), p));
815    let mut active_bounds = Array1::<f64>::zeros(active_indices.len());
816    for (row, &raw_index) in active_indices.iter().enumerate() {
817        let index = raw_index as usize;
818        if index >= a_inequality.nrows() {
819            crate::bail_invalid_estim!(
820                "constrained Gaussian REML active index {index} is out of range"
821            );
822        }
823        if seen[index] {
824            crate::bail_invalid_estim!(
825                "constrained Gaussian REML active index {index} occurs more than once"
826            );
827        }
828        seen[index] = true;
829        active.row_mut(row).assign(&a_inequality.row(index));
830        active_bounds[row] = b_inequality[index];
831    }
832
833    // Canonicalize a redundant representation to independent face equations.
834    // RRQR is run on A_a' so its pivoted columns name active constraint rows.
835    let active_t = active.t().to_owned();
836    let rrqr = rrqr_with_permutation(&active_t, default_rrqr_rank_alpha())
837        .map_err(EstimationError::LinearSystemSolveFailed)?;
838    if rrqr.rank == 0 {
839        return Err(EstimationError::GradientUnavailable {
840            context: "constrained Gaussian REML backward",
841            mode: "active constraint rows have zero numerical rank",
842        });
843    }
844    let mut a = Array2::<f64>::zeros((rrqr.rank, p));
845    let mut b = Array1::<f64>::zeros(rrqr.rank);
846    for (row, &source) in rrqr.column_permutation.iter().take(rrqr.rank).enumerate() {
847        a.row_mut(row).assign(&active.row(source));
848        b[row] = active_bounds[source];
849    }
850    let (z, rank) = rrqr_nullspace_basis(&a.t().to_owned(), default_rrqr_rank_alpha())
851        .map_err(EstimationError::LinearSystemSolveFailed)?;
852    if rank != rrqr.rank {
853        return Err(EstimationError::GradientUnavailable {
854            context: "constrained Gaussian REML backward",
855            mode: "active-face rank certificate is inconsistent",
856        });
857    }
858    Ok(ActiveFace { a, b, z })
859}
860
861fn face_state(
862    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
863    face: &ActiveFace,
864) -> Result<FaceState, EstimationError> {
865    let n = problem.x.nrows();
866    let p = problem.x.ncols();
867    let penalty = symmetric_average(problem.penalty);
868    let weights = problem
869        .weights
870        .map_or_else(|| Array1::ones(n), |values| values.to_owned());
871    let beta = problem.coefficients.to_owned();
872    let fitted = problem.x.dot(&beta);
873    let residual = problem.y.to_owned() - &fitted;
874    let weighted_x = &problem.x.to_owned() * &weights.view().insert_axis(Axis(1));
875    let gram = problem.x.t().dot(&weighted_x);
876    let hessian = &gram + &(penalty.clone() * problem.lambda);
877
878    let k = face.z.ncols();
879    let (p_response, q_penalty, penalty_rank) = if k == 0 {
880        (Array2::zeros((p, p)), Array2::zeros((p, p)), 0)
881    } else {
882        let tangent_hessian = face.z.t().dot(&hessian).dot(&face.z);
883        let inverse = tangent_hessian
884            .cholesky(Side::Lower)
885            .map_err(EstimationError::LinearSystemSolveFailed)?
886            .solve_mat(&Array2::<f64>::eye(k));
887        let p_response = face.z.dot(&inverse).dot(&face.z.t());
888        let tangent_penalty = face.z.t().dot(&penalty).dot(&face.z);
889        let penalty_geometry = tangent_penalty_geometry(&tangent_penalty)?;
890        let q_penalty = face.z.dot(&penalty_geometry.pseudoinverse).dot(&face.z.t());
891        (p_response, q_penalty, penalty_geometry.rank)
892    };
893    let n_effective = weights.iter().filter(|&&value| value > 0.0).count();
894    let nullity = k.saturating_sub(penalty_rank);
895    if n_effective <= nullity {
896        crate::bail_invalid_estim!(
897            "constrained Gaussian REML requires more positive-weight rows than tangent penalty nullity"
898        );
899    }
900    Ok(FaceState {
901        penalty,
902        weights,
903        beta,
904        residual,
905        gram,
906        p_response,
907        q_penalty,
908        penalty_rank,
909        residual_df: (n_effective - nullity) as f64,
910    })
911}
912
913fn certify_strict_complementarity(
914    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
915    state: &FaceState,
916    face: &ActiveFace,
917    constraints: &LinearInequalityConstraints,
918) -> Result<(), EstimationError> {
919    let p = problem.x.ncols();
920    let beta = state.beta.column(0);
921    let beta_scale = beta
922        .iter()
923        .fold(1.0_f64, |scale, &value| scale.max(value.abs()));
924    let mut active_mask = vec![false; constraints.a.nrows()];
925    for &index in problem.active_indices {
926        active_mask[index as usize] = true;
927    }
928    for row in 0..constraints.a.nrows() {
929        let a = constraints.a.row(row);
930        if a.iter().all(|&value| value == 0.0) {
931            continue;
932        }
933        let slack = a.dot(&beta) - constraints.b[row];
934        let tolerance = crate::pirls::ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
935            * beta_scale.max(constraints.b[row].abs().max(1.0));
936        if active_mask[row] {
937            if slack.abs() > tolerance {
938                return Err(EstimationError::GradientUnavailable {
939                    context: "constrained Gaussian REML backward",
940                    mode: "reported active row is not on the accepted face",
941                });
942            }
943        } else if slack <= tolerance {
944            return Err(EstimationError::GradientUnavailable {
945                context: "constrained Gaussian REML backward",
946                mode: "inactive constraint is not strictly separated from the face",
947            });
948        }
949    }
950
951    // Stationarity for A beta >= b is grad f - A' mu = 0, mu >= 0.
952    let weighted_residual = &state.residual.column(0) * &state.weights;
953    let gradient =
954        -problem.x.t().dot(&weighted_residual) + problem.lambda * state.penalty.dot(&beta);
955    let normal_gram = face.a.dot(&face.a.t());
956    let normal_inverse = normal_gram
957        .cholesky(Side::Lower)
958        .map_err(EstimationError::LinearSystemSolveFailed)?
959        .solve_mat(&Array2::<f64>::eye(face.a.nrows()));
960    let multipliers = normal_inverse.dot(&face.a.dot(&gradient));
961    let reconstructed = face.a.t().dot(&multipliers);
962    let residual = &gradient - &reconstructed;
963    let residual_inf = residual
964        .iter()
965        .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
966    let gradient_scale = gradient
967        .iter()
968        .chain(reconstructed.iter())
969        .fold(1.0_f64, |scale, &value| scale.max(value.abs()));
970    let arithmetic_uncertainty =
971        f64::EPSILON * ((p + face.a.nrows()).max(1) as f64) * gradient_scale;
972    let normal_left_inverse = normal_inverse.dot(&face.a);
973    let inverse_inf_norm = normal_left_inverse
974        .rows()
975        .into_iter()
976        .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
977        .fold(0.0_f64, f64::max);
978    // The accepted active-set solve is certified only to its public geometric
979    // feasibility resolution.  Propagate that resolution through the normal
980    // left inverse as a dual uncertainty as well: an O(1e-11) positive number
981    // reconstructed from an O(1e-8)-accurate KKT state is not evidence of
982    // strict complementarity.  Using arithmetic error alone incorrectly
983    // blessed exactly the weak active-set transition this guard exists for.
984    let solver_uncertainty = crate::pirls::ACTIVE_SET_PRIMAL_FEASIBILITY_TOL * gradient_scale;
985    let multiplier_uncertainty =
986        inverse_inf_norm * (residual_inf + arithmetic_uncertainty + solver_uncertainty);
987    if multipliers
988        .iter()
989        .any(|&value| !value.is_finite() || value <= multiplier_uncertainty)
990    {
991        return Err(EstimationError::GradientUnavailable {
992            context: "constrained Gaussian REML backward",
993            mode: "active constraint is weakly active; the derivative is set-valued",
994        });
995    }
996    Ok(())
997}
998
999fn add_mode_vjp(
1000    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1001    state: &FaceState,
1002    seed: ArrayView2<'_, f64>,
1003    scale: f64,
1004    out: &mut VjpAccumulator,
1005) {
1006    if scale == 0.0 || seed.iter().all(|&value| value == 0.0) {
1007        return;
1008    }
1009    let u = state.p_response.dot(&seed) * scale;
1010    let xu = problem.x.dot(&u);
1011    let weighted_residual = &state.residual * &state.weights.view().insert_axis(Axis(1));
1012    let weighted_xu = &xu * &state.weights.view().insert_axis(Axis(1));
1013    out.y += &weighted_xu;
1014    out.x += &weighted_residual.dot(&u.t());
1015    out.x -= &weighted_xu.dot(&state.beta.t());
1016    out.weights += &(&xu * &state.residual).sum_axis(Axis(1));
1017    let s_beta = state.penalty.dot(&state.beta);
1018    let raw_penalty = u.dot(&state.beta.t());
1019    out.penalty -= &(symmetric_average(raw_penalty.view()) * problem.lambda);
1020    out.lambda -= (&u * &s_beta).sum();
1021}
1022
1023fn add_score_vjp(
1024    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1025    state: &FaceState,
1026    seed: f64,
1027    out: &mut VjpAccumulator,
1028) {
1029    let d = problem.y.ncols() as f64;
1030    let wxp =
1031        (&problem.x.to_owned() * &state.weights.view().insert_axis(Axis(1))).dot(&state.p_response);
1032    out.x += &(wxp * (seed * d));
1033    out.weights += &(row_quadratic(problem.x, &state.p_response) * (0.5 * seed * d));
1034    out.penalty += &((&state.p_response * problem.lambda - &state.q_penalty) * (0.5 * seed * d));
1035    let trace_ps = trace_product(&state.p_response, &state.penalty);
1036    out.lambda += seed * 0.5 * d * (trace_ps - state.penalty_rank as f64 / problem.lambda);
1037
1038    for output in 0..problem.y.ncols() {
1039        let residual = state.residual.column(output);
1040        let beta = state.beta.column(output);
1041        let weighted_rss = residual.dot(&(&residual * &state.weights));
1042        let s_beta = state.penalty.dot(&beta);
1043        let energy = beta.dot(&s_beta);
1044        let deviance = weighted_rss + problem.lambda * energy;
1045        let tau = state.residual_df / deviance;
1046        let weighted_residual = &residual * &state.weights;
1047        out.y
1048            .column_mut(output)
1049            .scaled_add(seed * tau, &weighted_residual);
1050        let x_term = weighted_residual
1051            .insert_axis(Axis(1))
1052            .dot(&beta.insert_axis(Axis(0)));
1053        out.x -= &(x_term * (seed * tau));
1054        out.weights += &(&residual * &residual * (0.5 * seed * tau));
1055        let beta_outer = beta.insert_axis(Axis(1)).dot(&beta.insert_axis(Axis(0)));
1056        out.penalty += &(beta_outer * (0.5 * seed * tau * problem.lambda));
1057        out.lambda += 0.5 * seed * tau * energy;
1058    }
1059}
1060
1061fn add_face_edf_vjp(
1062    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1063    state: &FaceState,
1064    seed: f64,
1065    out: &mut VjpAccumulator,
1066) {
1067    let pgp = state.p_response.dot(&state.gram).dot(&state.p_response);
1068    let gram_seed = &state.p_response - &pgp;
1069    add_gram_vjp(problem.x, &state.weights, &gram_seed, seed, out);
1070    out.penalty -= &(pgp.clone() * (seed * problem.lambda));
1071    out.lambda -= seed * trace_product(&pgp, &state.penalty);
1072}
1073
1074fn add_rho_score_vjp(
1075    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1076    state: &FaceState,
1077    seed: f64,
1078    out: &mut VjpAccumulator,
1079) {
1080    if seed == 0.0 {
1081        return;
1082    }
1083    let psp = state.p_response.dot(&state.penalty).dot(&state.p_response);
1084    let gram_seed = psp.clone() * (-0.5 * seed * problem.lambda * problem.y.ncols() as f64);
1085    add_gram_vjp(problem.x, &state.weights, &gram_seed, 1.0, out);
1086    out.penalty += &((&state.p_response - &(psp * problem.lambda))
1087        * (0.5 * seed * problem.lambda * problem.y.ncols() as f64));
1088
1089    for output in 0..problem.y.ncols() {
1090        let beta = state.beta.column(output);
1091        let residual = state.residual.column(output);
1092        let s_beta = state.penalty.dot(&beta);
1093        let energy = beta.dot(&s_beta);
1094        let weighted_rss = residual.dot(&(&residual * &state.weights));
1095        let deviance = weighted_rss + problem.lambda * energy;
1096        let energy_seed = 0.5 * seed * state.residual_df * problem.lambda / deviance;
1097        let beta_seed = s_beta.insert_axis(Axis(1)) * (2.0 * energy_seed);
1098        add_mode_vjp(problem, state, beta_seed.view(), 1.0, out);
1099        let beta_outer = beta.insert_axis(Axis(1)).dot(&beta.insert_axis(Axis(0)));
1100        out.penalty += &(&beta_outer * energy_seed);
1101
1102        let deviance_seed =
1103            -0.5 * seed * state.residual_df * problem.lambda * energy / (deviance * deviance);
1104        let weighted_residual = &residual * &state.weights;
1105        out.y
1106            .column_mut(output)
1107            .scaled_add(2.0 * deviance_seed, &weighted_residual);
1108        let x_term = weighted_residual
1109            .insert_axis(Axis(1))
1110            .dot(&beta.insert_axis(Axis(0)));
1111        out.x -= &(x_term * (2.0 * deviance_seed));
1112        out.weights += &(&residual * &residual * deviance_seed);
1113        out.penalty += &(beta_outer * (deviance_seed * problem.lambda));
1114    }
1115}
1116
1117fn rho_score_curvature(
1118    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1119    state: &FaceState,
1120) -> f64 {
1121    let ps = state.p_response.dot(&state.penalty);
1122    let trace_ps = trace(&ps);
1123    let trace_psps = trace(&ps.dot(&ps));
1124    let mut value = 0.5
1125        * problem.y.ncols() as f64
1126        * (problem.lambda * trace_ps - problem.lambda * problem.lambda * trace_psps);
1127    for output in 0..problem.y.ncols() {
1128        let beta = state.beta.column(output);
1129        let residual = state.residual.column(output);
1130        let s_beta = state.penalty.dot(&beta);
1131        let energy = beta.dot(&s_beta);
1132        let curvature_energy = s_beta.dot(&state.p_response.dot(&s_beta));
1133        let deviance = residual.dot(&(&residual * &state.weights)) + problem.lambda * energy;
1134        let first = problem.lambda * energy;
1135        value += 0.5
1136            * state.residual_df
1137            * ((first - 2.0 * problem.lambda * problem.lambda * curvature_energy) / deviance
1138                - (first / deviance).powi(2));
1139    }
1140    value
1141}
1142
1143fn rho_curvature_scale(
1144    problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1145    state: &FaceState,
1146) -> f64 {
1147    let ps = state.p_response.dot(&state.penalty);
1148    let mut scale = problem.y.ncols() as f64 * (problem.lambda * trace(&ps)).abs()
1149        + problem.y.ncols() as f64 * (problem.lambda * problem.lambda * trace(&ps.dot(&ps))).abs();
1150    for output in 0..problem.y.ncols() {
1151        let beta = state.beta.column(output);
1152        let residual = state.residual.column(output);
1153        let s_beta = state.penalty.dot(&beta);
1154        let energy = beta.dot(&s_beta);
1155        let curvature_energy = s_beta.dot(&state.p_response.dot(&s_beta));
1156        let deviance = residual.dot(&(&residual * &state.weights)) + problem.lambda * energy;
1157        scale += state.residual_df
1158            * ((problem.lambda * energy / deviance).abs()
1159                + (2.0 * problem.lambda * problem.lambda * curvature_energy / deviance).abs()
1160                + (problem.lambda * energy / deviance).powi(2));
1161    }
1162    scale
1163}
1164
1165fn add_gram_vjp(
1166    x: ArrayView2<'_, f64>,
1167    weights: &Array1<f64>,
1168    gram_seed: &Array2<f64>,
1169    scale: f64,
1170    out: &mut VjpAccumulator,
1171) {
1172    let symmetric = symmetric_average(gram_seed.view());
1173    let x_seed = x.dot(&symmetric) * (2.0 * scale);
1174    out.x += &(&x_seed * &weights.view().insert_axis(Axis(1)));
1175    out.weights += &(row_quadratic(x, &symmetric) * scale);
1176}
1177
1178fn row_quadratic(x: ArrayView2<'_, f64>, matrix: &Array2<f64>) -> Array1<f64> {
1179    let xm = x.dot(matrix);
1180    (&xm * &x).sum_axis(Axis(1))
1181}
1182
1183fn symmetric_average(matrix: ArrayView2<'_, f64>) -> Array2<f64> {
1184    (&matrix + &matrix.t()) * 0.5
1185}
1186
1187fn symmetrize_in_place(matrix: &mut Array2<f64>) {
1188    for row in 0..matrix.nrows() {
1189        for col in (row + 1)..matrix.ncols() {
1190            let value = 0.5 * (matrix[[row, col]] + matrix[[col, row]]);
1191            matrix[[row, col]] = value;
1192            matrix[[col, row]] = value;
1193        }
1194    }
1195}
1196
1197fn trace(matrix: &Array2<f64>) -> f64 {
1198    matrix.diag().sum()
1199}
1200
1201fn trace_product(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
1202    (&left.t() * right).sum()
1203}
1204
1205fn rho_is_box_active(lambda: f64) -> bool {
1206    let rho = lambda.ln();
1207    let bound = crate::estimate::RHO_BOUND;
1208    rho.abs() >= bound - f64::EPSILON * bound.max(1.0)
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213    use super::*;
1214    use ndarray::{array, s};
1215
1216    struct Fixture {
1217        x: Array2<f64>,
1218        y: Array2<f64>,
1219        penalty: Array2<f64>,
1220        weights: Array1<f64>,
1221        constraints: LinearInequalityConstraints,
1222    }
1223
1224    fn affine_fixture() -> Fixture {
1225        let n = 16usize;
1226        let mut x = Array2::<f64>::zeros((n, 3));
1227        let mut y = Array2::<f64>::zeros((n, 1));
1228        let mut weights = Array1::<f64>::zeros(n);
1229        for row in 0..n {
1230            let t = -1.0 + 2.0 * row as f64 / (n - 1) as f64;
1231            x[[row, 0]] = 1.0;
1232            x[[row, 1]] = t;
1233            x[[row, 2]] = t * t;
1234            y[[row, 0]] = 0.2 - 0.4 * t - 1.8 * t * t + 0.02 * (3.0 * t).sin();
1235            weights[row] = 0.7 + 0.6 * row as f64 / (n - 1) as f64;
1236        }
1237        let penalty = array![[0.30, 0.05, 0.08], [0.05, 1.00, 0.12], [0.08, 0.12, 1.20]];
1238        let constraints =
1239            LinearInequalityConstraints::new(array![[0.0, 0.0, 1.0]], array![0.1]).unwrap();
1240        Fixture {
1241            x,
1242            y,
1243            penalty,
1244            weights,
1245            constraints,
1246        }
1247    }
1248
1249    fn fit_fixture(fixture: &Fixture) -> ConstrainedGaussianRemlForwardResult {
1250        constrained_gaussian_reml_forward(ConstrainedGaussianRemlForwardProblem {
1251            x: fixture.x.view(),
1252            y: fixture.y.view(),
1253            penalty: fixture.penalty.view(),
1254            weights: Some(fixture.weights.view()),
1255            constraints: Some(&fixture.constraints),
1256            init_lambda: None,
1257        })
1258        .unwrap_or_else(|error| panic!("affine constrained REML fit failed: {error}"))
1259    }
1260
1261    fn scalar_loss(result: &ConstrainedGaussianRemlForwardResult) -> f64 {
1262        let coefficient_seed = array![[0.7], [-0.3], [1.1]];
1263        let fitted_seed = Array2::from_shape_fn(result.fitted.dim(), |(row, _)| {
1264            0.2 + row as f64 / result.fitted.nrows() as f64
1265        });
1266        (&result.coefficients * &coefficient_seed).sum()
1267            + (&result.fitted * &fitted_seed).sum()
1268            + 0.23 * result.lambda
1269            + 0.41 * result.lambda.ln()
1270            + 0.67 * result.reml_score
1271            - 0.29 * result.edf
1272    }
1273
1274    fn finite_difference_step(value: f64) -> f64 {
1275        f64::EPSILON.cbrt() * value.abs().max(1.0)
1276    }
1277
1278    fn assert_fd(analytic: f64, numerical: f64, label: &str) {
1279        let tolerance = 64.0 * f64::EPSILON.cbrt() * (1.0 + analytic.abs().max(numerical.abs()));
1280        assert!(
1281            (analytic - numerical).abs() <= tolerance,
1282            "{label}: analytic={analytic:.12e}, numerical={numerical:.12e}, tolerance={tolerance:.3e}"
1283        );
1284    }
1285
1286    #[test]
1287    fn active_nonzero_affine_face_vjp_matches_central_differences() {
1288        let fixture = affine_fixture();
1289        let fit = fit_fixture(&fixture);
1290        assert_eq!(fit.active_indices.as_slice().unwrap(), &[0]);
1291        assert!((fit.coefficients[[2, 0]] - 0.1).abs() <= 1.0e-8);
1292
1293        let coefficient_seed = array![[0.7], [-0.3], [1.1]];
1294        let fitted_seed = Array2::from_shape_fn(fit.fitted.dim(), |(row, _)| {
1295            0.2 + row as f64 / fit.fitted.nrows() as f64
1296        });
1297        let backward = constrained_gaussian_reml_backward(ConstrainedGaussianRemlBackwardProblem {
1298            x: fixture.x.view(),
1299            y: fixture.y.view(),
1300            penalty: fixture.penalty.view(),
1301            weights: Some(fixture.weights.view()),
1302            a_inequality: fixture.constraints.a.view(),
1303            b_inequality: fixture.constraints.b.view(),
1304            active_indices: fit.active_indices.view(),
1305            lambda: fit.lambda,
1306            coefficients: fit.coefficients.view(),
1307            grad_coefficients: Some(coefficient_seed.view()),
1308            grad_fitted: Some(fitted_seed.view()),
1309            grad_lambda: 0.23,
1310            grad_log_lambda: 0.41,
1311            grad_reml_score: 0.67,
1312            grad_edf: -0.29,
1313        })
1314        .unwrap_or_else(|error| panic!("affine constrained REML backward failed: {error}"));
1315
1316        for row in 0..fixture.x.nrows() {
1317            for col in 0..fixture.x.ncols() {
1318                let step = finite_difference_step(fixture.x[[row, col]]);
1319                let mut plus = affine_fixture();
1320                let mut minus = affine_fixture();
1321                plus.x[[row, col]] += step;
1322                minus.x[[row, col]] -= step;
1323                let numerical = (scalar_loss(&fit_fixture(&plus))
1324                    - scalar_loss(&fit_fixture(&minus)))
1325                    / (2.0 * step);
1326                assert_fd(backward.grad_x[[row, col]], numerical, "grad_x");
1327            }
1328        }
1329        for row in 0..fixture.y.nrows() {
1330            let step = finite_difference_step(fixture.y[[row, 0]]);
1331            let mut plus = affine_fixture();
1332            let mut minus = affine_fixture();
1333            plus.y[[row, 0]] += step;
1334            minus.y[[row, 0]] -= step;
1335            let numerical = (scalar_loss(&fit_fixture(&plus)) - scalar_loss(&fit_fixture(&minus)))
1336                / (2.0 * step);
1337            assert_fd(backward.grad_y[[row, 0]], numerical, "grad_y");
1338        }
1339        for row in 0..fixture.penalty.nrows() {
1340            for col in 0..fixture.penalty.ncols() {
1341                let step = finite_difference_step(fixture.penalty[[row, col]]);
1342                let mut plus = affine_fixture();
1343                let mut minus = affine_fixture();
1344                plus.penalty[[row, col]] += step;
1345                minus.penalty[[row, col]] -= step;
1346                let numerical = (scalar_loss(&fit_fixture(&plus))
1347                    - scalar_loss(&fit_fixture(&minus)))
1348                    / (2.0 * step);
1349                assert_fd(backward.grad_penalty[[row, col]], numerical, "grad_penalty");
1350            }
1351        }
1352        for row in 0..fixture.weights.len() {
1353            let step = finite_difference_step(fixture.weights[row]);
1354            let mut plus = affine_fixture();
1355            let mut minus = affine_fixture();
1356            plus.weights[row] += step;
1357            minus.weights[row] -= step;
1358            let numerical = (scalar_loss(&fit_fixture(&plus)) - scalar_loss(&fit_fixture(&minus)))
1359                / (2.0 * step);
1360            assert_fd(backward.grad_weights[row], numerical, "grad_weights");
1361        }
1362    }
1363
1364    #[test]
1365    fn weakly_active_exact_kkt_state_has_no_derivative() {
1366        let n = 24usize;
1367        let mut x = Array2::<f64>::zeros((n, 3));
1368        let mut cubic = Array1::<f64>::zeros(n);
1369        for row in 0..n {
1370            let t = -1.0 + 2.0 * row as f64 / (n - 1) as f64;
1371            x[[row, 0]] = 1.0;
1372            x[[row, 1]] = t;
1373            x[[row, 2]] = t * t;
1374            cubic[row] = t * t * t;
1375        }
1376        let slope_projection = x.column(1).dot(&cubic) / x.column(1).dot(&x.column(1));
1377        let orthogonal = &cubic - &(x.column(1).to_owned() * slope_projection);
1378        let mut y = Array2::<f64>::from_elem((n, 1), 0.3);
1379        y.slice_mut(s![.., 0]).scaled_add(0.1, &orthogonal);
1380        let beta = array![[0.3], [0.0], [0.0]];
1381        let penalty = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1382        let weights = Array1::<f64>::ones(n);
1383        let a = array![[0.0, 0.0, 1.0]];
1384        let b = array![0.0];
1385        let active = array![0_u64];
1386        let seed = Array2::<f64>::ones((3, 1));
1387        let result = constrained_gaussian_reml_backward(ConstrainedGaussianRemlBackwardProblem {
1388            x: x.view(),
1389            y: y.view(),
1390            penalty: penalty.view(),
1391            weights: Some(weights.view()),
1392            a_inequality: a.view(),
1393            b_inequality: b.view(),
1394            active_indices: active.view(),
1395            lambda: 1.0,
1396            coefficients: beta.view(),
1397            grad_coefficients: Some(seed.view()),
1398            grad_fitted: None,
1399            grad_lambda: 0.0,
1400            grad_log_lambda: 0.0,
1401            grad_reml_score: 0.0,
1402            grad_edf: 0.0,
1403        });
1404        match result {
1405            Err(EstimationError::GradientUnavailable { mode, .. }) => {
1406                assert!(mode.contains("weakly active"), "unexpected mode: {mode}");
1407            }
1408            Err(error) => panic!("expected GradientUnavailable, got {error}"),
1409            Ok(_) => panic!("weakly active KKT state unexpectedly returned a gradient"),
1410        }
1411    }
1412}