Skip to main content

gam_solve/
active_set.rs

1use crate::estimate::EstimationError;
2use gam_linalg::faer_ndarray::{FaerArrayView, FaerLinalgError, FaerSvd, array1_to_col_matmut};
3use gam_linalg::utils::{StableSolver, array_is_finite, boundary_hit_step_fraction};
4use faer::linalg::solvers::{Lblt as FaerLblt, Solve as FaerSolve};
5use faer::{Side, Unbind};
6use gam_problem::LinearInequalityConstraints;
7use ndarray::{Array1, Array2, s};
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10
11/// Primal-feasibility tolerance the inequality-constrained active-set Newton
12/// solver guarantees on its returned iterate, measured in the *scaled*
13/// constraint-row coordinate system in which `A * beta >= b` is expressed.
14///
15/// The solver accepts a step when the worst scaled violation
16/// `max_i (b_i - a_i^T beta)` is below this threshold (see the acceptance
17/// gate in [`solve_linear_constrained_newton_step`] and the KKT diagnostics
18/// in [`compute_constraint_kkt_diagnostics`]). Any consumer that re-derives a
19/// raw (un-scaled) feasibility tolerance from a returned iterate must scale
20/// this value by the per-row normalization that the constraint builder
21/// applied; demanding tighter feasibility than this is inconsistent with the
22/// solver contract and will spuriously reject valid boundary solutions.
23pub const ACTIVE_SET_PRIMAL_FEASIBILITY_TOL: f64 = 1e-8;
24
25/// Stationarity tolerance for the strong-KKT acceptance gate: the projected
26/// (working-set) gradient residual ‖∇L − Aᵀλ‖∞, either absolute or relative to
27/// `max(1, ‖∇L‖∞)`, must fall below this to certify a constrained stationary
28/// point. Matched against `ACTIVE_SET_KKT_COMPLEMENTARITY_TOL` so both KKT
29/// residual channels are certified at compatible scales.
30const ACTIVE_SET_KKT_STATIONARITY_TOL: f64 = 2e-6;
31
32/// Complementarity-slackness tolerance for the KKT acceptance gate:
33/// `max_i |λ_i · slack_i|` must fall below this for the
34/// active-inactive partition to be consistent.
35const ACTIVE_SET_KKT_COMPLEMENTARITY_TOL: f64 = 1e-6;
36
37/// Dual-feasibility tolerance for the KKT acceptance gate: every working-set
38/// multiplier must satisfy `λ_i ≥ −ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL` (a
39/// strictly-negative multiplier means the constraint should be released).
40const ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL: f64 = 1e-8;
41
42/// Relaxed stationarity tolerance accepted only on a *genuinely degenerate
43/// boundary face* — one whose active rows are linearly dependent
44/// (`rank(A_active) < n_active`), so the active-row multipliers are non-unique
45/// and the exact projected gradient cannot reach
46/// `ACTIVE_SET_KKT_STATIONARITY_TOL`. Still requires primal feasibility,
47/// complementarity, and a relative-stationarity backstop.
48///
49/// Public so the outer REML / PIRLS validation gate can apply the same
50/// relaxation when the diagnostic reports a rank-deficient active face — a
51/// strict 5e-6 check there would otherwise refuse iterates that the inner
52/// active-set solver legitimately certified via its own `degenerate_boundary_ok`
53/// clause.
54///
55/// NOTE: this is *not* the mechanism that fixes the `shape=concave` /
56/// `shape=convex` cold-vs-warm cache divergence (#873). The B-spline shape path
57/// reparameterizes curvature into independent *coordinate lower bounds*
58/// `γ_j ≥ 0` (see `shape_lower_bounds_local`); any subset of those active rows
59/// is full rank, so `working_set_rank_deficient` stays `false` and this
60/// relaxation never fires for them — and must not be widened to. That bug is a
61/// *seed* problem (a cold seed landing on the cone vertex with every curvature
62/// row tight); it is fixed at the source by
63/// `project_point_strictly_into_feasible_cone`, which starts the inner solve
64/// strictly inside the cone so the strict tolerance is reachable.
65pub(crate) const ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL: f64 = 1e-3;
66
67/// Relative scale on the predicted-decrease test `predicted_delta ≤
68/// −ε·(1 + ‖∇L‖∞·‖d‖∞)`: when the working-set Newton step still buys a
69/// quadratic-model decrease at this relative margin the step is a usable
70/// descent direction even if the KKT residual has not yet tightened.
71const ACTIVE_SET_MODEL_DESCENT_REL_TOL: f64 = 1e-10;
72
73/// KKT diagnostics for inequality-constrained Newton subproblems.
74///
75/// Constraints are represented as `A * beta >= b` in the same coefficient
76/// coordinate system as the returned `beta`.
77///
78/// **Invariants** (held by all producers; not enforced at consumer boundary):
79/// - `n_active <= n_constraints` (a row cannot be active twice).
80/// - All four residual components (`primal_feasibility`, `dual_feasibility`,
81///   `complementarity`, `stationarity`) are `>= 0.0` and finite.
82/// - `active_tolerance >= 0.0` and finite.
83#[derive(Clone, Debug, Serialize, Deserialize)]
84pub struct ConstraintKktDiagnostics {
85    /// Number of inequality rows.
86    pub n_constraints: usize,
87    /// Number of rows considered active (`slack <= active_tolerance`).
88    pub n_active: usize,
89    /// Maximum primal feasibility violation: `max_i max(0, b_i - a_i^T beta)`.
90    pub primal_feasibility: f64,
91    /// Maximum dual feasibility violation: `max_i max(0, -lambda_i)`.
92    pub dual_feasibility: f64,
93    /// Maximum complementarity residual: `max_i |lambda_i * slack_i|`.
94    pub complementarity: f64,
95    /// Stationarity residual: `||grad - A^T lambda||_inf`.
96    pub stationarity: f64,
97    /// Tolerance used to classify active constraints from slacks.
98    pub active_tolerance: f64,
99    /// `true` when the active rows are linearly dependent (`rank(A_active) <
100    /// n_active`) — a *degenerate boundary face*. On such a face the active-row
101    /// multipliers are non-unique and the strict stationarity tolerance is
102    /// unreachable by construction. The inner active-set solver certifies these
103    /// iterates via its `ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL` relaxation;
104    /// the outer validation gate must consult this flag to apply the matching
105    /// relaxation, or it will refuse a legitimately-converged constrained
106    /// optimum and abort the REML startup loop.
107    ///
108    /// NOTE: B-spline `shape=concave`/`shape=convex` faces are *not* degenerate
109    /// — that path reparameterizes curvature into independent coordinate lower
110    /// bounds `γ_j ≥ 0` (full-rank active subsets), so this flag stays `false`
111    /// for them. Their cold-start fragility is a seed problem fixed by the
112    /// strictly-interior seed, not by this relaxation.
113    #[serde(default)]
114    pub working_set_rank_deficient: bool,
115    /// Inf-norm of the (raw, unprojected) gradient at `beta`, `‖gradient‖∞` —
116    /// the natural scale of the stationarity residual. A converged constrained
117    /// optimum drives `stationarity = ‖grad − Aᵀλ‖∞` to zero *relative to* this
118    /// scale, not to a fixed absolute floor: the profiled REML latent objective
119    /// carries an O(n) gradient magnitude even at a genuine stationary point
120    /// (issue #879), so a bare absolute stationarity gate is unreachable there
121    /// by construction. The inner active-set solver already certifies
122    /// convergence on the scale-invariant ratio
123    /// `stationarity / max(gradient_scale, 1)` (its `stationarity_rel` path
124    /// against `ACTIVE_SET_KKT_STATIONARITY_TOL`); the outer validation gate
125    /// [`crate::estimate::reml::outer_eval`]`::enforce_constraint_kkt` consults this
126    /// field to apply the identical relative test, so the two stop on the same
127    /// contract instead of the gate spuriously aborting a constrained optimum
128    /// the solver legitimately reached (issue #989). Defaults to `0.0` when
129    /// deserialized from a model saved before this field existed, which makes
130    /// `max(gradient_scale, 1) = 1` and recovers the bare absolute test.
131    #[serde(default)]
132    pub gradient_scale: f64,
133}
134
135/// Inf-norm `‖g‖∞` used as the scale of the stationarity residual in the
136/// relative KKT criterion shared by the inner active-set solver and the outer
137/// validation gate (see [`ConstraintKktDiagnostics::gradient_scale`]).
138fn gradient_inf_norm(gradient: &Array1<f64>) -> f64 {
139    gradient.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
140}
141
142fn solve_newton_direction_dense(
143    hessian: &Array2<f64>,
144    gradient: &Array1<f64>,
145    direction_out: &mut Array1<f64>,
146) -> Result<(), EstimationError> {
147    if direction_out.len() != gradient.len() {
148        *direction_out = Array1::zeros(gradient.len());
149    }
150
151    let factor = StableSolver::new("active-set newton direction")
152        .factorize(hessian)
153        .map_err(EstimationError::LinearSystemSolveFailed)?;
154    direction_out.assign(gradient);
155    let mut rhsview = array1_to_col_matmut(direction_out);
156    factor.solve_in_place(rhsview.as_mut());
157    direction_out.mapv_inplace(|v| -v);
158    if array_is_finite(direction_out) {
159        return Ok(());
160    }
161    Err(EstimationError::LinearSystemSolveFailed(
162        FaerLinalgError::FactorizationFailed {
163            context: "active-set newton direction non-finite solve",
164        },
165    ))
166}
167
168fn solve_dense_system_via_pseudoinverse(
169    matrix: &Array2<f64>,
170    rhs: &Array1<f64>,
171    out: &mut Array1<f64>,
172) -> Result<(), EstimationError> {
173    if matrix.nrows() != matrix.ncols() || rhs.len() != matrix.nrows() {
174        crate::bail_invalid_estim!("dense pseudoinverse solve dimension mismatch");
175    }
176
177    let (u_opt, singular, vt_opt) = matrix.svd(true, true).map_err(|_| {
178        EstimationError::InvalidInput("dense pseudoinverse solve SVD failed".to_string())
179    })?;
180    let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
181        crate::bail_invalid_estim!("dense pseudoinverse solve missing singular vectors");
182    };
183
184    let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
185    let tol = 100.0
186        * f64::EPSILON
187        * (matrix.nrows().max(matrix.ncols()).max(1) as f64)
188        * max_singular.max(1.0);
189    let mut coeff = u.t().dot(rhs);
190    for (idx, value) in coeff.iter_mut().enumerate() {
191        let sigma = singular[idx];
192        if sigma.abs() > tol {
193            *value /= sigma;
194        } else {
195            *value = 0.0;
196        }
197    }
198    let solution = vt.t().dot(&coeff);
199    if !array_is_finite(&solution) {
200        crate::bail_invalid_estim!("dense pseudoinverse solve produced non-finite values");
201    }
202    if out.len() != solution.len() {
203        *out = Array1::zeros(solution.len());
204    }
205    out.assign(&solution);
206    Ok(())
207}
208
209pub(crate) fn compute_constraint_kkt_diagnostics(
210    beta: &Array1<f64>,
211    gradient: &Array1<f64>,
212    constraints: &LinearInequalityConstraints,
213) -> ConstraintKktDiagnostics {
214    let m = constraints.a.nrows();
215    let active_tolerance = ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
216
217    // Measure feasibility in the *scaled* (geometric) coordinate system the
218    // solver's tolerance is expressed in: normalize each inequality
219    // `a_i·β ≥ b_i` by ‖a_i‖ so its slack becomes the signed Euclidean
220    // distance from β to the constraint hyperplane. Without this, a row with a
221    // large norm — e.g. a B-spline endpoint *derivative* clamp, whose rows
222    // carry ‖a_i‖ ≫ 1 — reports a raw slack inflated by ‖a_i‖, so an iterate
223    // that is feasible to the solver's scaled `ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`
224    // guarantee can still exceed a raw primal gate downstream and be spuriously
225    // refused. Per-row normalization makes the diagnostic scale-invariant and
226    // consistent with that contract. Dual/complementarity/stationarity are
227    // invariant under this positive per-row rescaling (with λ̂_i = ‖a_i‖·λ_i:
228    // Âᵀλ̂ = Aᵀλ and λ̂_i·ŝ_i = λ_i·s_i), so only primal feasibility and the
229    // active-set threshold change meaning — both toward the geometric distance
230    // the tolerance is meant to bound.
231    let p = constraints.a.ncols();
232    let mut a_scaled = constraints.a.clone();
233    let mut b_scaled = constraints.b.clone();
234    for i in 0..m {
235        let n_i = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
236        if n_i > 0.0 {
237            let inv = 1.0 / n_i;
238            a_scaled.row_mut(i).mapv_inplace(|v| v * inv);
239            b_scaled[i] *= inv;
240        }
241    }
242
243    let mut slack = Array1::<f64>::zeros(m);
244    let mut primal_feasibility: f64 = 0.0;
245    for i in 0..m {
246        let s_i = a_scaled.row(i).dot(beta) - b_scaled[i];
247        slack[i] = s_i;
248        primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
249    }
250
251    let active_idx: Vec<usize> = (0..m).filter(|&i| slack[i] <= active_tolerance).collect();
252    let mut lambda = Array1::<f64>::zeros(m);
253    let mut working_set_rank_deficient = false;
254    if !active_idx.is_empty() {
255        let n_active = active_idx.len();
256        let mut a_active = Array2::<f64>::zeros((n_active, p));
257        for (r, &idx) in active_idx.iter().enumerate() {
258            a_active.row_mut(r).assign(&a_scaled.row(idx));
259        }
260        if let Some((_, lambda_active)) =
261            project_stationarity_residual_on_constraint_cone(gradient, &a_active)
262        {
263            for (r, &idx) in active_idx.iter().enumerate() {
264                lambda[idx] = lambda_active[r];
265            }
266        }
267        // Rank-deficiency detection on the (scaled) active rows. Per-row
268        // positive scaling is rank-preserving, so this answers the same
269        // question the inner solver's `CompressedActiveWorkingSet::
270        // is_degenerate_face` does — `rank(A_active) < n_active`. For curvature
271        // constraints the second-difference operator forces dependence
272        // whenever more than `p` rows bind, and for monotonicity the
273        // first-difference operator does so beyond a similar count. The
274        // diagnostic exposes the flag so the outer validation gate can apply
275        // the same `ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL` relaxation
276        // the inner solver does, instead of refusing the iterate at strict
277        // `ACTIVE_SET_KKT_STATIONARITY_TOL`.
278        working_set_rank_deficient = if n_active > p {
279            true
280        } else if n_active > 1 {
281            let groups: Vec<Vec<usize>> = (0..n_active).map(|i| vec![i]).collect();
282            let b_dummy = Array1::<f64>::zeros(n_active);
283            let (reduced_a, _, _, _) =
284                rank_reduce_rows_pivoted_qr_with_dependence(a_active, b_dummy, groups);
285            reduced_a.nrows() < n_active
286        } else {
287            false
288        };
289    }
290
291    let mut dual_feasibility: f64 = 0.0;
292    let mut complementarity: f64 = 0.0;
293    for i in 0..m {
294        dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
295        complementarity = complementarity.max((lambda[i] * slack[i]).abs());
296    }
297    let stationarity = {
298        let mut resid = gradient.to_owned();
299        resid -= &a_scaled.t().dot(&lambda);
300        resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
301    };
302
303    ConstraintKktDiagnostics {
304        n_constraints: m,
305        n_active: active_idx.len(),
306        primal_feasibility,
307        dual_feasibility,
308        complementarity,
309        stationarity,
310        active_tolerance,
311        working_set_rank_deficient,
312        gradient_scale: gradient_inf_norm(gradient),
313    }
314}
315
316pub fn project_stationarity_residual_on_constraint_cone(
317    residual: &Array1<f64>,
318    active_a: &Array2<f64>,
319) -> Option<(Array1<f64>, Array1<f64>)> {
320    let p = residual.len();
321    if active_a.ncols() != p {
322        return None;
323    }
324    if active_a.nrows() == 0 {
325        return Some((residual.clone(), Array1::zeros(0)));
326    }
327
328    let m = active_a.nrows();
329    let residual_scale = residual
330        .iter()
331        .fold(0.0_f64, |acc, &v| acc.max(v.abs()))
332        .max(1.0);
333    let row_scale = active_a
334        .iter()
335        .fold(0.0_f64, |acc, &v| acc.max(v.abs()))
336        .max(1.0);
337    let tol = 100.0 * f64::EPSILON * (p.max(m).max(1) as f64) * residual_scale * row_scale;
338
339    let mut lambda = Array1::<f64>::zeros(m);
340    let mut passive = vec![false; m];
341    let mut projected = residual.clone();
342    let max_iter = (3 * m * m).max(10);
343
344    for _ in 0..max_iter {
345        let dual = active_a.dot(&projected);
346        let entering = (0..m)
347            .filter(|&idx| !passive[idx] && dual[idx] > tol)
348            .max_by(|&left, &right| {
349                dual[left]
350                    .partial_cmp(&dual[right])
351                    .unwrap_or(std::cmp::Ordering::Equal)
352            });
353        let Some(entering) = entering else {
354            lambda.mapv_inplace(|v| if v > tol { v } else { 0.0 });
355            projected = residual - &active_a.t().dot(&lambda);
356            if !array_is_finite(&projected) || !array_is_finite(&lambda) {
357                return None;
358            }
359            return Some((projected, lambda));
360        };
361        passive[entering] = true;
362
363        loop {
364            let passive_rows: Vec<usize> = (0..m).filter(|&idx| passive[idx]).collect();
365            if passive_rows.is_empty() {
366                lambda.fill(0.0);
367                projected.assign(residual);
368                break;
369            }
370
371            let mut a_passive = Array2::<f64>::zeros((passive_rows.len(), p));
372            for (pos, &row) in passive_rows.iter().enumerate() {
373                a_passive.row_mut(pos).assign(&active_a.row(row));
374            }
375            let gram = a_passive.dot(&a_passive.t());
376            let rhs = a_passive.dot(residual);
377            let mut lambda_passive = Array1::<f64>::zeros(passive_rows.len());
378            solve_dense_system_via_pseudoinverse(&gram, &rhs, &mut lambda_passive).ok()?;
379            if !array_is_finite(&lambda_passive) {
380                return None;
381            }
382
383            let all_positive = lambda_passive.iter().all(|&v| v > tol);
384            if all_positive {
385                lambda.fill(0.0);
386                for (pos, &row) in passive_rows.iter().enumerate() {
387                    lambda[row] = lambda_passive[pos];
388                }
389                projected = residual - &active_a.t().dot(&lambda);
390                break;
391            }
392
393            let mut alpha = f64::INFINITY;
394            for (pos, &row) in passive_rows.iter().enumerate() {
395                let candidate = lambda_passive[pos];
396                if candidate <= tol {
397                    let current = lambda[row];
398                    let denom = current - candidate;
399                    if denom > 0.0 {
400                        alpha = alpha.min(current / denom);
401                    }
402                }
403            }
404            if !alpha.is_finite() {
405                alpha = 0.0;
406            }
407            alpha = alpha.clamp(0.0, 1.0);
408
409            for (pos, &row) in passive_rows.iter().enumerate() {
410                lambda[row] += alpha * (lambda_passive[pos] - lambda[row]);
411                if lambda[row] <= tol {
412                    lambda[row] = 0.0;
413                    passive[row] = false;
414                }
415            }
416            if passive.iter().all(|&is_passive| !is_passive) {
417                projected.assign(residual);
418                break;
419            }
420        }
421    }
422
423    // Exhausting the Lawson-Hanson iterations means we do not have the
424    // normal-cone projection required for a KKT certificate.
425    None
426}
427
428pub(crate) fn feasible_point_for_linear_constraints(
429    constraints: &LinearInequalityConstraints,
430    p: usize,
431) -> Option<Array1<f64>> {
432    if constraints.a.ncols() != p
433        || constraints.a.nrows() == 0
434        || constraints.b.len() != constraints.a.nrows()
435    {
436        return None;
437    }
438    if constraints.b.iter().all(|v| v.abs() <= 1e-14) {
439        return Some(Array1::zeros(p));
440    }
441
442    let gram = constraints.a.dot(&constraints.a.t());
443    let (u_opt, singular, vt_opt) = gram.svd(true, true).ok()?;
444    let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
445        return None;
446    };
447    let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
448    let tol = 100.0 * f64::EPSILON * constraints.a.nrows().max(1) as f64 * max_singular.max(1.0);
449    let mut coeff = u.t().dot(&constraints.b);
450    for (idx, value) in coeff.iter_mut().enumerate() {
451        let sigma = singular[idx];
452        if sigma.abs() > tol {
453            *value /= sigma;
454        } else {
455            *value = 0.0;
456        }
457    }
458    let dual = vt.t().dot(&coeff);
459    let beta = constraints.a.t().dot(&dual);
460    if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
461        return None;
462    }
463    let slack = constraints.a.dot(&beta) - &constraints.b;
464    if slack.iter().all(|v| *v >= -1e-8) {
465        Some(beta)
466    } else {
467        None
468    }
469}
470
471/// Strictly-interior margin (in per-row geometric / scaled-slack units) required
472/// of the projected cold-start seed produced by
473/// [`project_point_strictly_into_feasible_cone`]. Each constraint row is shifted
474/// to `a_iᵀβ ≥ b_i + ACTIVE_SET_INTERIOR_SEED_MARGIN·‖a_i‖` so that, scaled by
475/// `‖a_i‖`, every row of the returned seed has slack `≥` this margin. The value
476/// is far above the active-set activation threshold (`tol_active = 1e-10`) so the
477/// initial working set the QP step solver builds from the seed is **empty** — no
478/// row is mistaken for "on the boundary" — yet small enough that the seed stays a
479/// negligible distance from the data-driven projection it is derived from.
480const ACTIVE_SET_INTERIOR_SEED_MARGIN: f64 = 1e-6;
481
482/// The strictly-interior cold-start margin (scaled-slack units) that
483/// [`project_point_strictly_into_feasible_cone`] guarantees on its returned
484/// seed. Exposed so the P-IRLS seed builder can decide, on the same scale,
485/// whether the current seed is already strictly interior (and may be used as-is)
486/// or sits on / outside the cone boundary (and must be projected).
487#[inline]
488pub(crate) fn interior_seed_margin() -> f64 {
489    ACTIVE_SET_INTERIOR_SEED_MARGIN
490}
491
492/// Project `point` to a *strictly interior* feasible point of the polyhedron
493/// `{β : A·β ≥ b}`: the solution of `min_β ½‖β − point‖²` subject to the
494/// margin-shifted system `A·β ≥ b + δ·‖a_i‖`, with `δ =
495/// ACTIVE_SET_INTERIOR_SEED_MARGIN`.
496///
497/// This is the principled feasible cold-start seed for a shape-constrained
498/// (convex / concave / monotone) smooth. It is qualitatively different from
499/// [`feasible_point_for_linear_constraints`], which returns the *minimum-norm*
500/// feasible point — for a homogeneous cone (`b = 0`, as the second-difference
501/// convexity / concavity constraints are) that minimum-norm point is the cone
502/// **vertex** `β = 0` (a flat line) where every constraint row is tight. A
503/// shape-constrained P-IRLS launched from that vertex hands the inner active-set
504/// QP an all-rows-active working set (every row's slack is `0`), and the QP then
505/// stalls on a degenerate, non-stationary face of the cone. The fit's success
506/// then depends on whether a warm-start seed happens to drop it into the right
507/// basin, so the same fit silently diverges (or aborts) between a cold and a
508/// warm cache (#873).
509///
510/// Requiring a strictly-positive margin on every row makes the returned seed an
511/// interior point: the QP step solver starts from an **empty** active set and
512/// adds only the genuinely binding rows, converging to the certified constrained
513/// stationary point regardless of cache state. The projection is the
514/// identity-Hessian instance of [`solve_quadratic_with_linear_constraints`]
515/// (`H = I`, `rhs = point` ⇒ minimizing `½‖β − point‖²`), so the interior seed is
516/// also the *nearest* strictly-interior point to the supplied data-driven
517/// `point` — it inherits whatever curvature `point` already carries. Returns
518/// `None` if the constraints are malformed or the active-set QP cannot certify a
519/// feasible solution, so callers can fall back.
520pub fn project_point_strictly_into_feasible_cone(
521    point: &Array1<f64>,
522    constraints: &LinearInequalityConstraints,
523) -> Option<Array1<f64>> {
524    let p = point.len();
525    let m = constraints.a.nrows();
526    if constraints.a.ncols() != p || m == 0 || constraints.b.len() != m {
527        return None;
528    }
529    let norms: Vec<f64> = (0..m)
530        .map(|i| constraints.a.row(i).dot(&constraints.a.row(i)).sqrt())
531        .collect();
532
533    // Classify rows. An *anti-parallel pair* with ~zero scaled feasible-slab
534    // width is an EQUALITY `rᵀβ = t` encoded as `{rᵀβ ≥ t, −rᵀβ ≥ −t}` (the
535    // canonical encoding emitted by a clamped / anchored boundary condition).
536    // Representing an equality as two opposing inequalities makes the inequality
537    // active-set QP CYCLE: it adds one side, the equality-split multiplier turns
538    // the other negative, it removes it, and the working set repeats until cycle
539    // detection aborts the solve — so the projection would fail and the caller
540    // would fall back to the cone vertex, silently reintroducing the #873 seed
541    // for the *combined* case (`shape=concave`/`convex` with `bc=clamped`). So we
542    // lift such pairs out as genuine equalities, eliminate them through the null
543    // space, and run the strictly-interior QP only on the one-sided rows. A pure
544    // shape cone has no anti-parallel rows, so `equality_rows` is empty and this
545    // reduces to the original single-QP path verbatim.
546    const ANTIPARALLEL_COS_TOL: f64 = -1.0 + 1e-9;
547    const EQUALITY_WIDTH_TOL: f64 = 1e-9;
548    let mut is_equality_member = vec![false; m];
549    let mut equality_rows: Vec<usize> = Vec::new();
550    let mut margin = vec![ACTIVE_SET_INTERIOR_SEED_MARGIN; m];
551    for i in 0..m {
552        if norms[i] == 0.0 {
553            margin[i] = 0.0;
554            continue;
555        }
556        for j in (i + 1)..m {
557            if norms[j] == 0.0 {
558                continue;
559            }
560            let cos = constraints.a.row(i).dot(&constraints.a.row(j)) / (norms[i] * norms[j]);
561            if cos > ANTIPARALLEL_COS_TOL {
562                continue;
563            }
564            // Anti-parallel rows â and −â: row i is `âᵀβ ≥ b_i/‖a_i‖`, row j is
565            // `âᵀβ ≤ −b_j/‖a_j‖`. Scaled feasible-slab width:
566            let width = -constraints.b[j] / norms[j] - constraints.b[i] / norms[i];
567            if width.abs() <= EQUALITY_WIDTH_TOL {
568                // Zero width ⇒ equality. Record it once (row i's orientation) and
569                // exclude both rows from the one-sided interior shift.
570                if !is_equality_member[i] && !is_equality_member[j] {
571                    equality_rows.push(i);
572                }
573                is_equality_member[i] = true;
574                is_equality_member[j] = true;
575            } else {
576                // Genuine (wide) two-sided bound: cap each side's inward shift at
577                // `w/3` so the shifted slab `s_i + s_j ≤ w` stays non-empty.
578                let cap = (width / 3.0).max(0.0);
579                margin[i] = margin[i].min(cap);
580                margin[j] = margin[j].min(cap);
581            }
582        }
583    }
584
585    // One-sided rows (everything not lifted into an equality), shifted strictly
586    // inward by `margin·‖a‖`.
587    let ineq_rows: Vec<usize> = (0..m).filter(|&i| !is_equality_member[i]).collect();
588    let mut a_ineq = Array2::<f64>::zeros((ineq_rows.len(), p));
589    let mut b_ineq = Array1::<f64>::zeros(ineq_rows.len());
590    for (r, &i) in ineq_rows.iter().enumerate() {
591        a_ineq.row_mut(r).assign(&constraints.a.row(i));
592        b_ineq[r] = constraints.b[i] + margin[i] * norms[i];
593    }
594
595    let beta = if equality_rows.is_empty() {
596        // No equalities: the original single strictly-interior QP
597        // (`min ½‖β − point‖²` s.t. the margin-shifted one-sided rows).
598        let interior = LinearInequalityConstraints::new(a_ineq, b_ineq)
599            .expect("shifted interior constraint shape invariant");
600        let identity = Array2::<f64>::eye(p);
601        solve_quadratic_with_linear_constraints(&identity, point, point, &interior, None)
602            .ok()?
603            .0
604    } else {
605        // Eliminate `E β = e` through its null space. From the thin SVD
606        // `E = U Σ Vᵀ` (rank `r`): the row space is `span(v_0..v_{r-1})`, the
607        // minimum-norm particular solution is `β_p = Σ_{i<r} (uᵢᵀe / σᵢ) vᵢ`, and
608        // an orthonormal null basis `Z` (p × (p−r)) is the complement of the row
609        // space (built by Gram-Schmidt of the standard axes — `p` is a single
610        // smooth-term width, so this is cheap and exact). Writing `β = β_p + Z u`
611        // and using `ZᵀZ = I`, the projection becomes the reduced strictly-
612        // interior QP `min ½‖u − Zᵀ(point − β_p)‖²` s.t. `(A_ineq Z) u ≥ b_ineq −
613        // A_ineq β_p`, whose rows carry no anti-parallel pair, so it can't cycle.
614        let k = equality_rows.len();
615        let mut e_mat = Array2::<f64>::zeros((k, p));
616        let mut e_rhs = Array1::<f64>::zeros(k);
617        for (r, &i) in equality_rows.iter().enumerate() {
618            e_mat.row_mut(r).assign(&constraints.a.row(i));
619            e_rhs[r] = constraints.b[i];
620        }
621        let (u_opt, sing, vt_opt) = e_mat.svd(true, true).ok()?;
622        let (u_mat, vt) = (u_opt?, vt_opt?);
623        let smax = sing.iter().fold(0.0_f64, |acc, &v| acc.max(v));
624        let rank_tol = smax.max(1.0) * (k.max(p) as f64) * f64::EPSILON * 100.0;
625        let rank = sing.iter().filter(|&&s| s > rank_tol).count();
626        if rank == 0 || rank >= p {
627            return None;
628        }
629        let mut beta_p = Array1::<f64>::zeros(p);
630        for idx in 0..rank {
631            let coeff = u_mat.column(idx).dot(&e_rhs) / sing[idx];
632            beta_p.scaled_add(coeff, &vt.row(idx));
633        }
634        // Orthonormal null basis: Gram-Schmidt the standard axes against the row
635        // space `vt[0..rank]` and the null vectors collected so far.
636        let mut basis: Vec<Array1<f64>> = (0..rank).map(|i| vt.row(i).to_owned()).collect();
637        let mut z = Array2::<f64>::zeros((p, p - rank));
638        let mut collected = 0usize;
639        for axis in 0..p {
640            if collected == p - rank {
641                break;
642            }
643            let mut v = Array1::<f64>::zeros(p);
644            v[axis] = 1.0;
645            for q in basis.iter() {
646                let c = q.dot(&v);
647                v.scaled_add(-c, q);
648            }
649            let nrm = v.dot(&v).sqrt();
650            if nrm > 1e-8 {
651                v /= nrm;
652                z.column_mut(collected).assign(&v);
653                basis.push(v);
654                collected += 1;
655            }
656        }
657        if collected != p - rank {
658            return None;
659        }
660        let a_red = a_ineq.dot(&z);
661        let b_red = &b_ineq - &a_ineq.dot(&beta_p);
662        let u0 = z.t().dot(&(point - &beta_p));
663        let reduced = LinearInequalityConstraints::new(a_red, b_red)
664            .expect("reduced constraint shape invariant");
665        let identity = Array2::<f64>::eye(z.ncols());
666        let (u_sol, _active) =
667            solve_quadratic_with_linear_constraints(&identity, &u0, &u0, &reduced, None).ok()?;
668        &beta_p + &z.dot(&u_sol)
669    };
670
671    if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
672        return None;
673    }
674    // Certify against the ORIGINAL constraints: every genuine one-sided row must
675    // clear (most of) its requested margin so the QP step solver sees no spurious
676    // active rows; equality-pair rows need only be feasible — they are
677    // legitimately tight.
678    const SEED_FEASIBILITY_TOL: f64 = 1e-9;
679    for i in 0..m {
680        let s = scaled_constraint_slack(&beta, constraints, i);
681        let lower = if is_equality_member[i] {
682            -SEED_FEASIBILITY_TOL
683        } else {
684            0.5 * margin[i] - SEED_FEASIBILITY_TOL
685        };
686        if s < lower {
687            return None;
688        }
689    }
690    Some(beta)
691}
692
693/// Worst primal-feasibility violation across all rows of `constraints`,
694/// measured in the per-row-scaled (geometric) coordinate system documented on
695/// [`ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`]. A row's slack is divided by ‖a_i‖
696/// so the returned value is the signed Euclidean distance from `beta` to the
697/// constraint hyperplane, not the raw dot-product residual. This matches
698/// [`compute_constraint_kkt_diagnostics`] and keeps the in-solver acceptance
699/// gate, the downstream KKT report, and any post-fit feasibility check on a
700/// single scale-invariant metric. A row with ‖a_i‖ = 38 (a typical B-spline
701/// endpoint-derivative clamp at k = 12) has a 1e-6 raw violation that is only
702/// 2.6e-8 in geometric units — accepting on raw alone is anisotropic in
703/// input-space and breaks the published contract.
704fn max_linear_constraint_violation(
705    beta: &Array1<f64>,
706    constraints: &LinearInequalityConstraints,
707) -> (f64, usize) {
708    let mut worst = 0.0_f64;
709    let mut worst_row = 0usize;
710    for i in 0..constraints.a.nrows() {
711        let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
712        let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
713        let slack = (constraints.a.row(i).dot(beta) - constraints.b[i]) * inv;
714        let viol = (-slack).max(0.0);
715        if viol > worst {
716            worst = viol;
717            worst_row = i;
718        }
719    }
720    (worst, worst_row)
721}
722
723/// Per-row signed scaled slack: `(a_i·beta - b_i) / ‖a_i‖`. Returns zero for
724/// degenerate rows with `‖a_i‖ = 0` (those rows carry no geometric content).
725/// Used wherever the active-set solver needs to compare per-row feasibility
726/// against a scale-invariant tolerance.
727#[inline]
728fn scaled_constraint_slack(
729    beta: &Array1<f64>,
730    constraints: &LinearInequalityConstraints,
731    i: usize,
732) -> f64 {
733    let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
734    let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
735    (constraints.a.row(i).dot(beta) - constraints.b[i]) * inv
736}
737
738pub(crate) fn solve_kkt_direction(
739    hessian: &Array2<f64>,
740    gradient: &Array1<f64>,
741    active_a: &Array2<f64>,
742    active_residual: Option<&Array1<f64>>,
743) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
744    let p = hessian.nrows();
745    let m = active_a.nrows();
746    if hessian.ncols() != p || gradient.len() != p || active_a.ncols() != p {
747        crate::bail_invalid_estim!("KKT solve dimension mismatch");
748    }
749    if let Some(residual) = active_residual
750        && residual.len() != m
751    {
752        crate::bail_invalid_estim!(
753            "KKT active residual length mismatch: got {}, expected {}",
754            residual.len(),
755            m
756        );
757    }
758    if m == 0 {
759        let mut d = Array1::<f64>::zeros(p);
760        solve_newton_direction_dense(hessian, gradient, &mut d)?;
761        return Ok((d, Array1::zeros(0)));
762    }
763    let mut kkt = Array2::<f64>::zeros((p + m, p + m));
764    kkt.slice_mut(s![0..p, 0..p]).assign(hessian);
765    kkt.slice_mut(s![0..p, p..(p + m)]).assign(&active_a.t());
766    kkt.slice_mut(s![p..(p + m), 0..p]).assign(active_a);
767
768    let mut rhs = Array1::<f64>::zeros(p + m);
769    for i in 0..p {
770        rhs[i] = -gradient[i];
771    }
772    if let Some(residual) = active_residual {
773        for i in 0..m {
774            rhs[p + i] = residual[i];
775        }
776    }
777    let rhs_target = rhs.clone();
778
779    let kkt_view = FaerArrayView::new(&kkt);
780    let factor = FaerLblt::new(kkt_view.as_ref(), Side::Lower);
781    let mut rhs_col = array1_to_col_matmut(&mut rhs);
782    factor.solve_in_place(rhs_col.as_mut());
783    if !rhs.iter().all(|v| v.is_finite()) {
784        solve_dense_system_via_pseudoinverse(&kkt, &rhs_target, &mut rhs)?;
785    }
786    let d = rhs.slice(s![0..p]).to_owned();
787    let lambda = rhs.slice(s![p..(p + m)]).to_owned();
788    Ok((d, lambda))
789}
790
791#[derive(Clone, Debug)]
792pub(crate) struct CompressedActiveWorkingSet {
793    pub(crate) constraints: LinearInequalityConstraints,
794    pub(crate) groups: Vec<Vec<usize>>,
795    multiplier_dependence: Vec<Vec<ActiveRowDependence>>,
796    pub(crate) original_active_count: usize,
797}
798
799// Visible at `pub` (with private fields) so it can appear in the signature of
800// the `pub` cross-crate `rank_reduce_rows_pivoted_qr_with_dependence`, which the
801// gam-custom-family rank-reduction tests call directly after the #1521 crate
802// carve. The fields stay private: external callers ignore the dependence return.
803#[derive(Clone, Copy, Debug)]
804pub struct ActiveRowDependence {
805    active_pos: usize,
806    coeff: f64,
807}
808
809impl CompressedActiveWorkingSet {
810    fn is_degenerate_face(&self) -> bool {
811        self.constraints.a.nrows() < self.original_active_count
812            || self.groups.iter().any(|group| group.len() > 1)
813    }
814
815    fn reconstructed_active_multipliers(&self, lambda_system: &Array1<f64>) -> Vec<(usize, f64)> {
816        let mut multipliers = Vec::new();
817        for (group_pos, &lambda_system_value) in lambda_system.iter().enumerate() {
818            let lambda_true = -lambda_system_value;
819            if let Some(dependencies) = self.multiplier_dependence.get(group_pos) {
820                for dependency in dependencies {
821                    if dependency.coeff.abs() > f64::EPSILON {
822                        multipliers.push((dependency.active_pos, lambda_true / dependency.coeff));
823                    }
824                }
825            }
826        }
827        multipliers
828    }
829}
830
831pub(crate) fn compress_active_working_set(
832    x: &Array1<f64>,
833    constraints: &LinearInequalityConstraints,
834    active: &[usize],
835) -> Result<CompressedActiveWorkingSet, EstimationError> {
836    let p = constraints.a.ncols();
837    if x.len() != p {
838        crate::bail_invalid_estim!("active working-set compression dimension mismatch");
839    }
840
841    let mut a_out = Array2::<f64>::zeros((active.len(), p));
842    let mut b_out = Array1::<f64>::zeros(active.len());
843    let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(active.len());
844    for (pos, &idx) in active.iter().enumerate() {
845        if idx >= constraints.a.nrows() {
846            crate::bail_invalid_estim!(
847                "active working-set index {} out of bounds for {} constraints",
848                idx,
849                constraints.a.nrows()
850            );
851        }
852        a_out.row_mut(pos).assign(&constraints.a.row(idx));
853        b_out[pos] = constraints.b[idx];
854        groups_out.push(vec![pos]);
855    }
856
857    let (a_out, b_out, groups_out, multiplier_dependence) =
858        rank_reduce_rows_pivoted_qr_with_dependence(a_out, b_out, groups_out);
859
860    Ok(CompressedActiveWorkingSet {
861        constraints: LinearInequalityConstraints::new(a_out, b_out)
862            .expect("compressed active constraint shape invariant"),
863        groups: groups_out,
864        multiplier_dependence,
865        original_active_count: active.len(),
866    })
867}
868
869fn identity_multiplier_dependence(groups: &[Vec<usize>]) -> Vec<Vec<ActiveRowDependence>> {
870    groups
871        .iter()
872        .map(|group| {
873            group
874                .iter()
875                .copied()
876                .map(|active_pos| ActiveRowDependence {
877                    active_pos,
878                    coeff: 1.0,
879                })
880                .collect()
881        })
882        .collect()
883}
884
885pub fn rank_reduce_rows_pivoted_qr_with_dependence(
886    a: Array2<f64>,
887    b: Array1<f64>,
888    groups: Vec<Vec<usize>>,
889) -> (
890    Array2<f64>,
891    Array1<f64>,
892    Vec<Vec<usize>>,
893    Vec<Vec<ActiveRowDependence>>,
894) {
895    let k = a.nrows();
896    let p = a.ncols();
897    if k <= 1 {
898        let multiplier_dependence = identity_multiplier_dependence(&groups);
899        return (a, b, groups, multiplier_dependence);
900    }
901
902    let mut at_faer = faer::Mat::<f64>::zeros(p, k);
903    for i in 0..k {
904        for j in 0..p {
905            at_faer[(j, i)] = a[[i, j]];
906        }
907    }
908
909    let qr = at_faer.as_ref().col_piv_qr();
910    let r_mat = qr.thin_R();
911    let diag_len = r_mat.nrows().min(r_mat.ncols());
912    let leading_diag = if diag_len > 0 {
913        r_mat[(0, 0)].abs()
914    } else {
915        0.0
916    };
917
918    const RANK_ALPHA: f64 = 100.0;
919    let tol = RANK_ALPHA * f64::EPSILON * (k.max(p).max(1) as f64) * leading_diag.max(1.0);
920
921    let rank = (0..diag_len).filter(|&i| r_mat[(i, i)].abs() > tol).count();
922    if rank >= k {
923        let multiplier_dependence = identity_multiplier_dependence(&groups);
924        return (a, b, groups, multiplier_dependence);
925    }
926    if rank == 0 {
927        log::debug!(
928            "rank-reduced active constraints from {} to 0 rows (all active rows numerically zero)",
929            k
930        );
931        return (
932            Array2::<f64>::zeros((0, p)),
933            Array1::<f64>::zeros(0),
934            Vec::new(),
935            Vec::new(),
936        );
937    }
938
939    let (perm_fwd, _) = qr.P().arrays();
940    let kept_orig: Vec<usize> = (0..rank).map(|j| perm_fwd[j].unbound()).collect();
941    let dropped_orig: Vec<usize> = (rank..k).map(|j| perm_fwd[j].unbound()).collect();
942
943    let mut orig_to_out = std::collections::HashMap::with_capacity(rank);
944    let mut a_out = Array2::<f64>::zeros((rank, p));
945    let mut b_out = Array1::<f64>::zeros(rank);
946    let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(rank);
947    let mut multiplier_dependence: Vec<Vec<ActiveRowDependence>> = Vec::with_capacity(rank);
948    for (out_idx, &orig_idx) in kept_orig.iter().enumerate() {
949        a_out.row_mut(out_idx).assign(&a.row(orig_idx));
950        b_out[out_idx] = b[orig_idx];
951        groups_out.push(groups[orig_idx].clone());
952        multiplier_dependence.push(
953            groups[orig_idx]
954                .iter()
955                .copied()
956                .map(|active_pos| ActiveRowDependence {
957                    active_pos,
958                    coeff: 1.0,
959                })
960                .collect(),
961        );
962        orig_to_out.insert(orig_idx, out_idx);
963    }
964
965    for &dropped_idx in &dropped_orig {
966        let dropped_row = a.row(dropped_idx);
967        let dropped_norm = dropped_row.dot(&dropped_row).sqrt();
968        let mut best_positive_align = -1.0_f64;
969        let mut best_positive_target: Option<(usize, f64)> = None;
970        let mut best_abs_align = -1.0_f64;
971        let mut best_signed_target = (kept_orig[0], 1.0_f64);
972        for &kept_idx in &kept_orig {
973            let kept_row = a.row(kept_idx);
974            let kept_norm = kept_row.dot(&kept_row).sqrt();
975            let dot = kept_row.dot(&dropped_row);
976            let align = if kept_norm > 0.0 && dropped_norm > 0.0 {
977                dot / (kept_norm * dropped_norm)
978            } else {
979                dot
980            };
981            let coeff = if kept_norm > 0.0 {
982                dot / (kept_norm * kept_norm)
983            } else {
984                0.0
985            };
986            if align.abs() > best_abs_align {
987                best_abs_align = align.abs();
988                best_signed_target = (kept_idx, coeff);
989            }
990            if coeff > 0.0 && align > best_positive_align {
991                best_positive_align = align;
992                best_positive_target = Some((kept_idx, coeff));
993            }
994        }
995        let (best_target, coeff) = best_positive_target.unwrap_or(best_signed_target);
996        let &out_idx = orig_to_out
997            .get(&best_target)
998            .expect("merge target must be a kept row");
999        for &active_pos in &groups[dropped_idx] {
1000            multiplier_dependence[out_idx].push(ActiveRowDependence { active_pos, coeff });
1001        }
1002        if coeff > 0.0 {
1003            groups_out[out_idx].extend_from_slice(&groups[dropped_idx]);
1004        }
1005    }
1006
1007    for group in &mut groups_out {
1008        group.sort_unstable();
1009        group.dedup();
1010    }
1011    for dependencies in &mut multiplier_dependence {
1012        dependencies.sort_unstable_by_key(|dependency| dependency.active_pos);
1013        dependencies.dedup_by_key(|dependency| dependency.active_pos);
1014    }
1015
1016    let mut row_order: Vec<usize> = (0..groups_out.len()).collect();
1017    row_order.sort_by_key(|&idx| groups_out[idx].first().copied().unwrap_or(usize::MAX));
1018    if row_order.iter().enumerate().any(|(idx, &orig)| idx != orig) {
1019        let mut a_sorted = Array2::<f64>::zeros((rank, p));
1020        let mut b_sorted = Array1::<f64>::zeros(rank);
1021        let mut groups_sorted = Vec::with_capacity(rank);
1022        let mut dependence_sorted = Vec::with_capacity(rank);
1023        for (out_idx, orig_idx) in row_order.into_iter().enumerate() {
1024            a_sorted.row_mut(out_idx).assign(&a_out.row(orig_idx));
1025            b_sorted[out_idx] = b_out[orig_idx];
1026            groups_sorted.push(groups_out[orig_idx].clone());
1027            dependence_sorted.push(multiplier_dependence[orig_idx].clone());
1028        }
1029        a_out = a_sorted;
1030        b_out = b_sorted;
1031        groups_out = groups_sorted;
1032        multiplier_dependence = dependence_sorted;
1033    }
1034
1035    if rank < k {
1036        log::debug!(
1037            "rank-reduced active constraints from {} to {} rows (rank deficiency {})",
1038            k,
1039            rank,
1040            k - rank
1041        );
1042    }
1043
1044    (a_out, b_out, groups_out, multiplier_dependence)
1045}
1046
1047pub(crate) fn working_set_kkt_diagnostics_from_multipliers(
1048    x: &Array1<f64>,
1049    gradient: &Array1<f64>,
1050    working_constraints: &LinearInequalityConstraints,
1051    lambda_active_true: &Array1<f64>,
1052    n_total_constraints: usize,
1053) -> Result<ConstraintKktDiagnostics, EstimationError> {
1054    let p = working_constraints.a.ncols();
1055    if x.len() != p || gradient.len() != p {
1056        crate::bail_invalid_estim!("working-set KKT diagnostic dimension mismatch");
1057    }
1058    if lambda_active_true.len() != working_constraints.a.nrows() {
1059        crate::bail_invalid_estim!(
1060            "working-set KKT multiplier length mismatch: got {}, expected {}",
1061            lambda_active_true.len(),
1062            working_constraints.a.nrows()
1063        );
1064    }
1065    // Primal feasibility and complementarity are measured in the per-row-scaled
1066    // (geometric) coordinate system the public solver contract is expressed in
1067    // (see [`ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`] and
1068    // [`compute_constraint_kkt_diagnostics`]). Without scaling, a row with
1069    // ‖a_i‖ ≫ 1 — e.g. a B-spline endpoint-derivative clamp — reports a raw
1070    // slack inflated by ‖a_i‖, and the in-solver acceptance gate
1071    // (`worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`) becomes anisotropic across
1072    // rows. Complementarity is the SCALED product `λ̂_i · ŝ_i` with
1073    // `λ̂_i = ‖a_i‖·λ_i`; that's invariant under the same per-row rescaling, so
1074    // its semantics are unchanged while the units match the primal column.
1075    let m = working_constraints.a.nrows();
1076    let mut slack = Array1::<f64>::zeros(m);
1077    let mut primal_feasibility: f64 = 0.0;
1078    for i in 0..m {
1079        let s_i = scaled_constraint_slack(x, working_constraints, i);
1080        slack[i] = s_i;
1081        primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
1082    }
1083
1084    let lambda = lambda_active_true.to_owned();
1085
1086    let mut dual_feasibility: f64 = 0.0;
1087    let mut complementarity: f64 = 0.0;
1088    for i in 0..m {
1089        dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
1090        // Scale-invariant complementarity `λ̂_i · ŝ_i` with `λ̂_i = ‖a_i‖·λ_i`
1091        // and `ŝ_i` the already-scaled slack: this product equals the raw
1092        // `λ_i · (a_iᵀx − b_i)`, invariant under per-row rescaling — matching the
1093        // documented contract above (`λ̂_i = ‖a_i‖·λ_i`). `lambda_active_true`
1094        // here is the RAW multiplier, so without the `‖a_i‖` factor this would
1095        // understate complementarity by `1/‖a_i‖` on high-norm rows (e.g. a
1096        // B-spline endpoint-derivative clamp, ‖a‖ ≈ 38).
1097        let norm_i = working_constraints
1098            .a
1099            .row(i)
1100            .dot(&working_constraints.a.row(i))
1101            .sqrt();
1102        complementarity = complementarity.max((norm_i * lambda[i] * slack[i]).abs());
1103    }
1104    let stationarity = {
1105        let mut resid = gradient.to_owned();
1106        resid -= &working_constraints.a.t().dot(&lambda);
1107        resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
1108    };
1109
1110    Ok(ConstraintKktDiagnostics {
1111        n_constraints: n_total_constraints,
1112        n_active: m,
1113        primal_feasibility,
1114        dual_feasibility,
1115        complementarity,
1116        stationarity,
1117        active_tolerance: ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
1118        // `working_constraints` is the already-rank-reduced compressed
1119        // working set, so by construction `rank(working_constraints.a) ==
1120        // n_active`. Whether the *original* (uncompressed) active set was
1121        // rank-deficient is the caller's responsibility to track when it
1122        // needs to surface that to a downstream gate; here we report the
1123        // post-compression view honestly.
1124        working_set_rank_deficient: false,
1125        gradient_scale: gradient_inf_norm(gradient),
1126    })
1127}
1128
1129fn canonicalize_active_constraint_ids(
1130    x: &Array1<f64>,
1131    constraints: &LinearInequalityConstraints,
1132    active: &[usize],
1133) -> Result<Vec<usize>, EstimationError> {
1134    if active.is_empty() {
1135        return Ok(Vec::new());
1136    }
1137    let compressed_working = compress_active_working_set(x, constraints, active)?;
1138    let mut canonical = Vec::with_capacity(compressed_working.groups.len());
1139    for group in &compressed_working.groups {
1140        if let Some(&active_pos) = group.first() {
1141            canonical.push(active[active_pos]);
1142        }
1143    }
1144    Ok(canonical)
1145}
1146
1147fn fallback_projected_gradient_direction(
1148    x: &Array1<f64>,
1149    d_total: &Array1<f64>,
1150    gradient: &Array1<f64>,
1151    working_constraints: &LinearInequalityConstraints,
1152    constraints: &LinearInequalityConstraints,
1153) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
1154    let p = gradient.len();
1155    if x.len() != p || d_total.len() != p || constraints.a.ncols() != p {
1156        crate::bail_invalid_estim!("projected-gradient fallback dimension mismatch");
1157    }
1158
1159    let tangent_direction = if working_constraints.a.nrows() == 0 {
1160        -gradient
1161    } else {
1162        let identity = Array2::<f64>::eye(p);
1163        let residual = &working_constraints.b - &working_constraints.a.dot(x);
1164        let (direction, _) =
1165            solve_kkt_direction(&identity, gradient, &working_constraints.a, Some(&residual))?;
1166        direction
1167    };
1168
1169    if !array_is_finite(&tangent_direction) {
1170        return Ok(None);
1171    }
1172
1173    let step_inf = tangent_direction
1174        .iter()
1175        .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
1176    if step_inf <= 1e-12 {
1177        // The projected-gradient tangent step has collapsed to ~0: the working
1178        // set holds the gradient stationary, so no descent direction remains in
1179        // the working tangent space. Returning `d_total` as-is is ONLY correct
1180        // when the iterate `x = beta_start + d_total` is itself feasible. When
1181        // `x` is infeasible (an inactive row was violated by an earlier
1182        // `alpha`-clipped step and never repaired — the KKT solve only closes
1183        // residuals on ACTIVE rows), returning `d_total` leaks an infeasible
1184        // iterate that the downstream `check_linear_feasibility` gate rejects
1185        // as an "infeasible iterate" (#1108: the interval-censored survival
1186        // surrogate landed here at scaled-violation 5.7e-3..0.12 while the
1187        // exact projection of the SAME point reaches ~0 violation). Project the
1188        // stationary iterate onto the feasible cone and return the corresponding
1189        // direction so the solve returns a feasible point. The returned result
1190        // is `beta_start + dir`, and `x = beta_start + d_total`, so to return a
1191        // feasible `p` the direction is `dir = d_total + (p - x)`.
1192        let (worst, _) = max_linear_constraint_violation(x, constraints);
1193        if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
1194            let projected = project_point_strictly_into_feasible_cone(x, constraints)
1195                .or_else(|| {
1196                    let identity = Array2::<f64>::eye(p);
1197                    solve_quadratic_with_linear_constraints(&identity, x, x, constraints, None)
1198                        .ok()
1199                        .map(|(beta, _active)| beta)
1200                })
1201                .filter(|p_candidate| {
1202                    max_linear_constraint_violation(p_candidate, constraints).0
1203                        <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1204                });
1205            let Some(projected) = projected else {
1206                // No feasible repair available — let the caller report honestly
1207                // rather than returning an infeasible direction.
1208                return Ok(None);
1209            };
1210            let repair = &projected - x;
1211            let active = canonicalize_active_constraint_ids(&projected, constraints, &[])?;
1212            return Ok(Some((d_total + &repair, active)));
1213        }
1214        let active = canonicalize_active_constraint_ids(x, constraints, &[])?;
1215        return Ok(Some((d_total.clone(), active)));
1216    }
1217
1218    let directional_derivative = gradient.dot(&tangent_direction);
1219    if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
1220        return Ok(None);
1221    }
1222
1223    let mut alpha = 1.0_f64;
1224    for i in 0..constraints.a.nrows() {
1225        // Scale the step-fraction test by 1/‖a_i‖ so it matches the geometric
1226        // activation tolerance used elsewhere (see in-loop alpha computation in
1227        // `solve_newton_direction_with_linear_constraints_impl`).
1228        let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1229        let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
1230        let slack = (constraints.a.row(i).dot(x) - constraints.b[i]) * inv;
1231        let ai_d = constraints.a.row(i).dot(&tangent_direction) * inv;
1232        if let Some(candidate) = boundary_hit_step_fraction(slack, ai_d, alpha) {
1233            alpha = candidate;
1234        }
1235    }
1236    if !alpha.is_finite() || alpha <= 0.0 {
1237        return Ok(None);
1238    }
1239
1240    let fallback_step = tangent_direction * alpha;
1241    let new_x = x + &fallback_step;
1242    // Per-row-scaled feasibility, matching ACTIVE_SET_PRIMAL_FEASIBILITY_TOL.
1243    let (worst, _) = max_linear_constraint_violation(&new_x, constraints);
1244    if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
1245        return Ok(None);
1246    }
1247    let active = (0..constraints.a.nrows())
1248        .filter(|&i| scaled_constraint_slack(&new_x, constraints, i) <= 1e-10)
1249        .collect::<Vec<_>>();
1250    let active = canonicalize_active_constraint_ids(&new_x, constraints, &active)?;
1251    Ok(Some((d_total + &fallback_step, active)))
1252}
1253
1254fn log_active_set_transition(
1255    event: &str,
1256    iteration: usize,
1257    active_len: usize,
1258    constraint: Option<usize>,
1259) {
1260    log::debug!(
1261        "[active-set/QP] iter={} event={} active={} constraint={}",
1262        iteration,
1263        event,
1264        active_len,
1265        constraint
1266            .map(|idx| idx.to_string())
1267            .unwrap_or_else(|| "NA".to_string()),
1268    );
1269}
1270
1271/// Record the current working set; returns `false` when this exact set was
1272/// already visited. Both the entering and leaving rules above are
1273/// lowest-index (Bland), so a repeat cannot be a true simplex cycle — it is
1274/// tolerance-band oscillation (a row added by the slack band and dropped by a
1275/// noise-level negative dual, repeatedly). The caller breaks to the post-loop
1276/// KKT exit gate, which inspects the iterate's full KKT residuals and either
1277/// accepts via the projected-gradient fallback or returns an explicit error —
1278/// never a silent acceptance, and no longer a hard seed-killing error on an
1279/// otherwise numerically-converged iterate (#1025).
1280fn record_active_working_set(
1281    visited: &mut HashSet<Vec<usize>>,
1282    active: &[usize],
1283    iteration: usize,
1284) -> bool {
1285    let mut key = active.to_vec();
1286    key.sort_unstable();
1287    if visited.insert(key.clone()) {
1288        return true;
1289    }
1290    log::debug!(
1291        "[active-set/QP] iter={iteration} repeated working set ({} rows); \
1292         deferring to the post-loop KKT exit gate",
1293        key.len()
1294    );
1295    false
1296}
1297
1298fn solve_newton_direction_with_linear_constraints_impl(
1299    hessian: &Array2<f64>,
1300    gradient: &Array1<f64>,
1301    beta: &Array1<f64>,
1302    constraints: &LinearInequalityConstraints,
1303    direction_out: &mut Array1<f64>,
1304    active_hint: Option<&mut Vec<usize>>,
1305    max_iterations: usize,
1306) -> Result<(), EstimationError> {
1307    let p = gradient.len();
1308    if direction_out.len() != p {
1309        *direction_out = Array1::zeros(p);
1310    }
1311    let m = constraints.a.nrows();
1312    if constraints.a.ncols() != p || constraints.b.len() != m || beta.len() != p {
1313        crate::bail_invalid_estim!(
1314            "linear constraint shape mismatch: A={}x{}, b={}, p={}",
1315            constraints.a.nrows(),
1316            constraints.a.ncols(),
1317            constraints.b.len(),
1318            p
1319        );
1320    }
1321
1322    let tol_active = 1e-10;
1323    let tol_step = 1e-12;
1324    let tol_dual = 1e-10;
1325    let mut x = beta.to_owned();
1326    let mut d_total = Array1::<f64>::zeros(p);
1327    let mut g_cur = gradient.to_owned();
1328
1329    let has_active_hint = active_hint
1330        .as_ref()
1331        .map(|hint| !hint.is_empty())
1332        .unwrap_or(false);
1333    if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
1334        let candidate = beta + &*direction_out;
1335        let mut feasible = true;
1336        for i in 0..m {
1337            // Scaled (geometric) slack — matches `tol_active`'s semantics and
1338            // the public solver contract on `ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`.
1339            let slack = scaled_constraint_slack(&candidate, constraints, i);
1340            if slack < -tol_active {
1341                feasible = false;
1342                break;
1343            }
1344        }
1345        if feasible {
1346            return Ok(());
1347        }
1348    }
1349
1350    let mut active: Vec<usize> = Vec::new();
1351    let mut is_active = vec![false; m];
1352    if let Some(hint) = active_hint.as_ref() {
1353        for &idx in hint.iter() {
1354            if idx < m && !is_active[idx] {
1355                active.push(idx);
1356                is_active[idx] = true;
1357                log_active_set_transition("warm-add", 0, active.len(), Some(idx));
1358            }
1359        }
1360    }
1361    for i in 0..m {
1362        // Scaled (geometric) slack: a row with ‖a_i‖ ≫ 1 (e.g. a B-spline
1363        // endpoint-derivative clamp at k = 12, ‖a_i‖ ≈ 38) would otherwise
1364        // require a raw slack of `tol_active·‖a_i‖` ≈ 4e-9 to activate, which
1365        // is below LBLT-solve precision on the KKT system and starves the
1366        // active set of rows that genuinely belong on the boundary.
1367        let slack = scaled_constraint_slack(&x, constraints, i);
1368        if slack <= tol_active && !is_active[i] {
1369            active.push(i);
1370            is_active[i] = true;
1371            log_active_set_transition("initial-boundary-add", 0, active.len(), Some(i));
1372        }
1373    }
1374    let mut visited_working_sets: HashSet<Vec<usize>> = HashSet::new();
1375    record_active_working_set(&mut visited_working_sets, &active, 0);
1376
1377    for iteration in 0..max_iterations {
1378        let compressed_working = compress_active_working_set(&x, constraints, &active)?;
1379        let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
1380        for r in 0..compressed_working.constraints.a.nrows() {
1381            residualw[r] = compressed_working.constraints.b[r]
1382                - compressed_working.constraints.a.row(r).dot(&x);
1383        }
1384        let (d, lambdaw) = solve_kkt_direction(
1385            hessian,
1386            &g_cur,
1387            &compressed_working.constraints.a,
1388            Some(&residualw),
1389        )?;
1390        let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
1391        if step_norm <= tol_step {
1392            // A "stationary" iterate is only a genuine KKT point if it is
1393            // also primal-feasible on the FULL constraint set. The
1394            // "blocking-add" loop further down only catches rows that
1395            // become tight DURING a non-degenerate step, so a tiny step
1396            // entered with a residual violation on an inactive row would
1397            // otherwise leak an infeasible direction through this
1398            // short-circuit unchecked. Grow the active set with the
1399            // most-violated inactive row and re-solve; `residualw` for that
1400            // row will be non-zero on the next KKT solve, so the resulting
1401            // step is non-degenerate.
1402            let (worst, worst_row) = max_linear_constraint_violation(&x, constraints);
1403            if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
1404                active.push(worst_row);
1405                is_active[worst_row] = true;
1406                log_active_set_transition(
1407                    "stationary-infeasible-add",
1408                    iteration,
1409                    active.len(),
1410                    Some(worst_row),
1411                );
1412                if !record_active_working_set(&mut visited_working_sets, &active, iteration) {
1413                    break;
1414                }
1415                continue;
1416            }
1417            if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
1418                // The worst-violating row is already in the active set —
1419                // the KKT step failed to restore its feasibility, typically
1420                // because a previous iteration's `alpha`-clip prevented the
1421                // full residual closure. Fall through to the post-loop exit
1422                // gate, which inspects the iterate's full KKT residuals and
1423                // either tries the projected-gradient fallback or returns
1424                // an explicit error. Both are correct outcomes; silently
1425                // returning the infeasible direction is not.
1426                break;
1427            }
1428            if compressed_working.groups.is_empty() {
1429                direction_out.assign(&d_total);
1430                return Ok(());
1431            }
1432            let remove_pos = compressed_working
1433                .reconstructed_active_multipliers(&lambdaw)
1434                .into_iter()
1435                .filter(|&(_, lambda_true)| lambda_true < -tol_dual)
1436                .min_by_key(|(active_pos, _)| (active[*active_pos], *active_pos))
1437                .map(|(active_pos, _)| active_pos);
1438            if let Some(active_pos) = remove_pos {
1439                let idx = active.remove(active_pos);
1440                is_active[idx] = false;
1441                log_active_set_transition(
1442                    "remove-negative-dual",
1443                    iteration,
1444                    active.len(),
1445                    Some(idx),
1446                );
1447                if !record_active_working_set(&mut visited_working_sets, &active, iteration) {
1448                    break;
1449                }
1450                continue;
1451            }
1452            if let Some(hint) = active_hint {
1453                hint.clear();
1454                hint.extend(canonicalize_active_constraint_ids(
1455                    &x,
1456                    constraints,
1457                    &active,
1458                )?);
1459            }
1460            direction_out.assign(&d_total);
1461            return Ok(());
1462        }
1463
1464        let mut alpha = 1.0_f64;
1465        for i in 0..m {
1466            if is_active[i] {
1467                continue;
1468            }
1469            // boundary_hit_step_fraction is scale-equivariant in `(slack, ai_d)`:
1470            // scaling both by 1/‖a_i‖ leaves `step = slack / -ai_d` unchanged,
1471            // and its directional-tol/finite checks operate on a max-magnitude
1472            // scale of the same triple. Doing the geometric rescale here keeps
1473            // the step-fraction's "moving toward the boundary" test in the same
1474            // scaled coordinates the activation tests use.
1475            let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1476            let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
1477            let slack = (constraints.a.row(i).dot(&x) - constraints.b[i]) * inv;
1478            let ai_d = constraints.a.row(i).dot(&d) * inv;
1479            if let Some(cand) = boundary_hit_step_fraction(slack, ai_d, alpha) {
1480                alpha = cand;
1481            }
1482        }
1483
1484        ndarray::Zip::from(&mut x)
1485            .and(&mut d_total)
1486            .and(&d)
1487            .for_each(|x_i, dt_i, &d_i| {
1488                let alpha_d = alpha * d_i;
1489                *x_i += alpha_d;
1490                *dt_i += alpha_d;
1491            });
1492        g_cur = gradient + &hessian.dot(&d_total);
1493
1494        let mut added_new_active = false;
1495        let mut working_set_repeated = false;
1496        for i in 0..m {
1497            if is_active[i] {
1498                continue;
1499            }
1500            let slack = scaled_constraint_slack(&x, constraints, i);
1501            if slack <= tol_active {
1502                active.push(i);
1503                is_active[i] = true;
1504                added_new_active = true;
1505                log_active_set_transition("blocking-add", iteration, active.len(), Some(i));
1506                working_set_repeated =
1507                    !record_active_working_set(&mut visited_working_sets, &active, iteration);
1508                break;
1509            }
1510        }
1511        if working_set_repeated {
1512            break;
1513        }
1514
1515        if active.is_empty() && !added_new_active {
1516            if let Some(hint) = active_hint {
1517                hint.clear();
1518            }
1519            direction_out.assign(&d_total);
1520            return Ok(());
1521        }
1522    }
1523
1524    let compressed_working = compress_active_working_set(&x, constraints, &active)?;
1525    let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
1526    for r in 0..compressed_working.constraints.a.nrows() {
1527        residualw[r] =
1528            compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
1529    }
1530    let (_, lambdaw) = solve_kkt_direction(
1531        hessian,
1532        &g_cur,
1533        &compressed_working.constraints.a,
1534        Some(&residualw),
1535    )?;
1536    let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
1537    let (worst, row) = max_linear_constraint_violation(&x, constraints);
1538    let working_kkt = working_set_kkt_diagnostics_from_multipliers(
1539        &x,
1540        &g_cur,
1541        &compressed_working.constraints,
1542        &lambda_true,
1543        m,
1544    )?;
1545    let kkt = compute_constraint_kkt_diagnostics(&x, &g_cur, constraints);
1546    let grad_inf = gradient_inf_norm(&g_cur);
1547    let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
1548    let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1549    let hd_total = hessian.dot(&d_total);
1550    let predicted_delta = gradient.dot(&d_total)
1551        + 0.5
1552            * d_total
1553                .iter()
1554                .zip(hd_total.iter())
1555                .map(|(a, b)| a * b)
1556                .sum::<f64>();
1557    let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
1558        || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
1559        && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
1560    let model_descent_ok =
1561        predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
1562    let degenerate_boundary_ok = compressed_working.is_degenerate_face()
1563        && worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1564        && working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1565        && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
1566        && (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
1567            || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
1568    if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1569        && ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
1570            && (kkt_strong_ok || model_descent_ok))
1571            || degenerate_boundary_ok)
1572    {
1573        if let Some(hint) = active_hint {
1574            hint.clear();
1575            hint.extend(canonicalize_active_constraint_ids(
1576                &x,
1577                constraints,
1578                &active,
1579            )?);
1580        }
1581        direction_out.assign(&d_total);
1582        return Ok(());
1583    }
1584    if let Some((fallback_direction, fallback_active)) = fallback_projected_gradient_direction(
1585        &x,
1586        &d_total,
1587        &g_cur,
1588        &compressed_working.constraints,
1589        constraints,
1590    )? {
1591        if let Some(hint) = active_hint {
1592            hint.clear();
1593            hint.extend(fallback_active);
1594        }
1595        direction_out.assign(&fallback_direction);
1596        return Ok(());
1597    }
1598    Err(EstimationError::ParameterConstraintViolation(format!(
1599        "linear-constrained Newton active-set failed to converge; max(Aβ-b violation)={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]; diagnostic-reconstruction[dual={:.3e}, stat={:.3e}]",
1600        working_kkt.primal_feasibility,
1601        working_kkt.dual_feasibility,
1602        working_kkt.complementarity,
1603        working_kkt.stationarity,
1604        working_kkt.n_active,
1605        working_kkt.n_constraints,
1606        kkt.dual_feasibility,
1607        kkt.stationarity
1608    )))
1609}
1610
1611pub(crate) fn solve_newton_direction_with_linear_constraints(
1612    hessian: &Array2<f64>,
1613    gradient: &Array1<f64>,
1614    beta: &Array1<f64>,
1615    constraints: &LinearInequalityConstraints,
1616    direction_out: &mut Array1<f64>,
1617    active_hint: Option<&mut Vec<usize>>,
1618) -> Result<(), EstimationError> {
1619    let max_iterations = (gradient.len() + constraints.a.nrows() + 8) * 4;
1620    solve_newton_direction_with_linear_constraints_impl(
1621        hessian,
1622        gradient,
1623        beta,
1624        constraints,
1625        direction_out,
1626        active_hint,
1627        max_iterations,
1628    )
1629}
1630
1631pub fn solve_quadratic_with_linear_constraints(
1632    hessian: &Array2<f64>,
1633    rhs: &Array1<f64>,
1634    beta_start: &Array1<f64>,
1635    constraints: &LinearInequalityConstraints,
1636    warm_active_set: Option<&[usize]>,
1637) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
1638    if hessian.ncols() != hessian.nrows()
1639        || rhs.len() != hessian.nrows()
1640        || beta_start.len() != hessian.nrows()
1641        || constraints.a.ncols() != hessian.nrows()
1642    {
1643        crate::bail_invalid_estim!("constrained quadratic solve: system dimension mismatch");
1644    }
1645    let gradient = hessian.dot(beta_start) - rhs;
1646    let mut delta = Array1::<f64>::zeros(beta_start.len());
1647    let mut active_hint = warm_active_set.map_or_else(Vec::new, |active| active.to_vec());
1648    solve_newton_direction_with_linear_constraints(
1649        hessian,
1650        &gradient,
1651        beta_start,
1652        constraints,
1653        &mut delta,
1654        Some(&mut active_hint),
1655    )?;
1656    let candidate = beta_start + &delta;
1657    // FINAL FEASIBILITY CONTRACT (#1108). The active-set inner step computes its
1658    // Newton direction only against the ACTIVE working rows and line-searches
1659    // `alpha` against the inactive rows; a row whose approach rate falls inside
1660    // `boundary_hit_step_fraction`'s directional tolerance is not clipped, so the
1661    // returned `candidate` can overshoot a currently-inactive row and land
1662    // outside the cone by a small-but-gate-failing amount (the interval-censored
1663    // survival surrogate leaked 5.5e-3..2.2e-2 raw at cycles 3/9 here, accepted
1664    // into `states`, then rejected by the next cycle's `check_linear_feasibility`
1665    // — `infeasible iterate`). The solver's PUBLIC CONTRACT is a feasible point;
1666    // enforce it at the single chokepoint every caller flows through. A genuinely
1667    // converged feasible solve has `worst ~ 0`, so this is a no-op there and does
1668    // not perturb a well-conditioned constrained fit. When the step did leak,
1669    // project the iterate onto the feasible cone (the exact projection the #1108
1670    // diag proves reaches ~0 violation: the nearest strictly-interior point) and
1671    // return THAT. If no feasible repair is achievable, surface the active-set
1672    // error rather than returning an infeasible point.
1673    let (worst, _) = max_linear_constraint_violation(&candidate, constraints);
1674    if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
1675        return Ok((candidate, active_hint));
1676    }
1677    let repaired = project_point_strictly_into_feasible_cone(&candidate, constraints).filter(|p| {
1678        max_linear_constraint_violation(p, constraints).0 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1679    });
1680    match repaired {
1681        Some(feasible) => {
1682            let active = canonicalize_active_constraint_ids(&feasible, constraints, &[])?;
1683            Ok((feasible, active))
1684        }
1685        None => Err(EstimationError::ParameterConstraintViolation(format!(
1686            "constrained quadratic solve returned an infeasible iterate \
1687             (max scaled violation {worst:.3e}) and no feasible projection could be \
1688             certified onto the constraint cone",
1689        ))),
1690    }
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::{
1696        ACTIVE_SET_INTERIOR_SEED_MARGIN, LinearInequalityConstraints,
1697        compute_constraint_kkt_diagnostics, project_point_strictly_into_feasible_cone,
1698        project_stationarity_residual_on_constraint_cone,
1699        rank_reduce_rows_pivoted_qr_with_dependence, scaled_constraint_slack,
1700        solve_newton_direction_with_linear_constraints_impl,
1701        solve_quadratic_with_linear_constraints,
1702    };
1703    use approx::assert_relative_eq;
1704    use ndarray::{Array1, Array2, array};
1705
1706    /// A `β = 0` seed sits on the boundary of EVERY row of a homogeneous
1707    /// (`b = 0`) convex/concave second-difference cone — it is the cone vertex.
1708    /// The strict-interior projection must move it to a point with a strictly
1709    /// positive scaled slack on every row, so the inner active-set QP starts
1710    /// from an EMPTY working set rather than an all-rows-active degenerate face
1711    /// (the #873 cache-dependence root cause). The zero seed is the worst case:
1712    /// the nearest interior point is unique up to the margin, and a buggy
1713    /// "min-norm" feasibility fallback would return `0` again.
1714    #[test]
1715    fn strict_interior_projection_lifts_vertex_seed_off_every_constraint_row() {
1716        // Signed second-difference rows of a 5-coefficient concave smooth:
1717        // -(β_{i+2} - 2β_{i+1} + β_i) ≥ 0 for i = 0..3.
1718        let p = 5usize;
1719        let rows = p - 2;
1720        let mut a = Array2::<f64>::zeros((rows, p));
1721        for i in 0..rows {
1722            a[[i, i]] = -1.0;
1723            a[[i, i + 1]] = 2.0;
1724            a[[i, i + 2]] = -1.0;
1725        }
1726        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
1727            .expect("test constraint shape invariant");
1728
1729        let vertex = Array1::<f64>::zeros(p);
1730        // The vertex is feasible (all rows exactly tight) but on every boundary.
1731        for i in 0..rows {
1732            assert!(
1733                scaled_constraint_slack(&vertex, &constraints, i).abs() < 1e-12,
1734                "vertex seed should sit exactly on row {i}"
1735            );
1736        }
1737
1738        let interior = project_point_strictly_into_feasible_cone(&vertex, &constraints)
1739            .expect("strict-interior projection of the vertex must succeed");
1740        let min_slack = (0..rows)
1741            .map(|i| scaled_constraint_slack(&interior, &constraints, i))
1742            .fold(f64::INFINITY, f64::min);
1743        assert!(
1744            min_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
1745            "projected seed must be strictly interior on every row; min scaled slack = {min_slack:.3e}"
1746        );
1747    }
1748
1749    /// Mirrors `s(x, shape=concave, bc=clamped)`: shape curvature reparameterized
1750    /// to independent coordinate lower bounds `γ_j ≥ 0` (genuine one-sided rows),
1751    /// MERGED with a boundary condition encoded as an anti-parallel inequality
1752    /// PAIR `{r·β ≥ t, −r·β ≥ −t}` (an equality `r·β = t`). A naive
1753    /// shift-every-row-inward projection turns that pair into the empty set
1754    /// `t+δ ≤ r·β ≤ t−δ`, fails, and the caller falls back to the cone vertex —
1755    /// silently reintroducing the #873 seed for the combined case. The
1756    /// anti-parallel-aware margin must leave the equality pair tight while still
1757    /// pushing the genuine shape rows strictly interior.
1758    #[test]
1759    fn strict_interior_projection_keeps_equality_pairs_tight_with_shape_bounds() {
1760        let p = 5usize;
1761        // Rows 0..3: shape lower bounds γ_2,γ_3,γ_4 ≥ 0 (homogeneous, b = 0).
1762        // Rows 3,4: endpoint equality β_0 = 0 as {e_0·β ≥ 0, −e_0·β ≥ 0}.
1763        let m = 3 + 2;
1764        let mut a = Array2::<f64>::zeros((m, p));
1765        a[[0, 2]] = 1.0;
1766        a[[1, 3]] = 1.0;
1767        a[[2, 4]] = 1.0;
1768        a[[3, 0]] = 1.0;
1769        a[[4, 0]] = -1.0;
1770        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(m))
1771            .expect("test constraint shape invariant");
1772
1773        // A seed that violates the shape bounds (negative curvature coords) and
1774        // the equality (β_0 ≠ 0).
1775        let point = Array1::from_vec(vec![0.7, -0.2, -0.5, -0.3, -0.1]);
1776        let seed = project_point_strictly_into_feasible_cone(&point, &constraints).expect(
1777            "strict-interior projection must succeed when an equality pair is present, \
1778             not collapse to the empty set and fall back to the vertex",
1779        );
1780
1781        // Genuine one-sided shape rows are pushed strictly interior.
1782        for i in 0..3 {
1783            assert!(
1784                scaled_constraint_slack(&seed, &constraints, i)
1785                    >= 0.4 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
1786                "shape row {i} not strictly interior: scaled slack = {:.3e}",
1787                scaled_constraint_slack(&seed, &constraints, i)
1788            );
1789        }
1790        // The equality pair stays tight (β_0 ≈ 0), i.e. the seed is projected
1791        // onto the boundary hyperplane rather than shifted off it.
1792        assert!(
1793            seed[0].abs() <= 1e-6,
1794            "boundary equality must be enforced, got β_0 = {:.3e}",
1795            seed[0]
1796        );
1797    }
1798
1799    /// A seed that already carries genuine (concave) curvature and clears the
1800    /// interior margin is returned essentially unchanged — the projection only
1801    /// nudges boundary/violating seeds, it does not discard usable curvature.
1802    #[test]
1803    fn strict_interior_projection_preserves_a_curvature_carrying_seed() {
1804        let p = 5usize;
1805        let rows = p - 2;
1806        let mut a = Array2::<f64>::zeros((rows, p));
1807        for i in 0..rows {
1808            a[[i, i]] = -1.0;
1809            a[[i, i + 1]] = 2.0;
1810            a[[i, i + 2]] = -1.0;
1811        }
1812        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
1813            .expect("test constraint shape invariant");
1814        // A strictly concave coefficient profile (-(j-2)^2): every second
1815        // difference is -(-2) = +2 > 0 after the concave sign flip, well above
1816        // the interior margin.
1817        let seed = Array1::from_iter((0..p).map(|j| -((j as f64 - 2.0).powi(2))));
1818        let projected = project_point_strictly_into_feasible_cone(&seed, &constraints)
1819            .expect("already-interior seed must project");
1820        let max_move = seed
1821            .iter()
1822            .zip(projected.iter())
1823            .map(|(a, b)| (a - b).abs())
1824            .fold(0.0_f64, f64::max);
1825        assert!(
1826            max_move < 1e-3,
1827            "strictly-interior curvature-carrying seed should be preserved; max move = {max_move:.3e}"
1828        );
1829    }
1830
1831    #[test]
1832    fn maxiter_accepts_current_boundary_solution() {
1833        let hessian = array![[1.0]];
1834        let gradient = array![-1.0];
1835        let beta = array![0.0];
1836        let constraints = LinearInequalityConstraints {
1837            a: array![[-1.0]],
1838            b: array![-0.1],
1839        };
1840        let mut direction = Array1::zeros(1);
1841        let mut active_hint = Vec::new();
1842
1843        solve_newton_direction_with_linear_constraints_impl(
1844            &hessian,
1845            &gradient,
1846            &beta,
1847            &constraints,
1848            &mut direction,
1849            Some(&mut active_hint),
1850            1,
1851        )
1852        .expect("solver should accept the current boundary solution at the iteration limit");
1853
1854        assert_relative_eq!(direction[0], 0.1, epsilon = 1e-12);
1855        assert_eq!(active_hint, vec![0]);
1856    }
1857
1858    #[test]
1859    fn rank_reduce_zero_rows_returns_empty_working_set() {
1860        let a = array![[0.0, 0.0], [0.0, 0.0],];
1861        let b = array![0.0, 0.0];
1862        let groups = vec![vec![0], vec![1]];
1863
1864        let (a_out, b_out, groups_out, _) =
1865            rank_reduce_rows_pivoted_qr_with_dependence(a, b, groups);
1866
1867        assert_eq!(a_out.nrows(), 0);
1868        assert_eq!(a_out.ncols(), 2);
1869        assert_eq!(b_out.len(), 0);
1870        assert!(groups_out.is_empty());
1871    }
1872
1873    #[test]
1874    fn cone_projection_solves_nonnegative_least_squares_not_one_way_pruning() {
1875        let active_a = array![
1876            [0.85258593, -0.77270261],
1877            [-1.22152485, 2.05129351],
1878            [0.22794844, 1.56987265],
1879        ];
1880        let residual = array![-0.50524761, -1.10104911];
1881
1882        let (projected, multipliers) =
1883            project_stationarity_residual_on_constraint_cone(&residual, &active_a)
1884                .expect("cone projection should solve");
1885
1886        let row0 = active_a.row(0);
1887        let expected_mu0 = row0.dot(&residual) / row0.dot(&row0);
1888        assert_relative_eq!(multipliers[0], expected_mu0, epsilon = 1e-8);
1889        assert_relative_eq!(multipliers[1], 0.0, epsilon = 1e-10);
1890        assert_relative_eq!(multipliers[2], 0.0, epsilon = 1e-10);
1891
1892        let raw_norm2 = residual.dot(&residual);
1893        let projected_norm2 = projected.dot(&projected);
1894        assert!(
1895            projected_norm2 < raw_norm2 - 0.1,
1896            "NNLS projection should keep the improving active row: raw={raw_norm2:.6e}, projected={projected_norm2:.6e}"
1897        );
1898        let dual = active_a.dot(&projected);
1899        for (idx, (&mu, &w)) in multipliers.iter().zip(dual.iter()).enumerate() {
1900            if mu <= 1e-10 {
1901                assert!(
1902                    w <= 1e-8,
1903                    "inactive cone generator {idx} has positive reduced gradient {w:.3e}"
1904                );
1905            }
1906        }
1907    }
1908
1909    // #500: the KKT primal residual must be the *geometric* distance to the
1910    // constraint hyperplane — invariant to how the constraint row is scaled.
1911    // A B-spline endpoint-derivative clamp carries a large row norm, so the
1912    // raw slack `a·β − b` of a near-feasible iterate is inflated by ‖a‖ and a
1913    // downstream raw primal gate would spuriously refuse it. The same geometry
1914    // expressed with a unit-norm row must yield the same primal.
1915    #[test]
1916    fn kkt_primal_is_per_row_scale_invariant() {
1917        // β sits 2.071e-8 on the infeasible side of the hyperplane `row·β ≥ 0`
1918        // (the exact geometric residual reported in #500's startup abort).
1919        let geometric_violation = 2.071e-8_f64;
1920        let gradient = Array1::<f64>::zeros(2);
1921
1922        // Unit-norm row: raw slack == geometric distance.
1923        let beta_unit = array![-geometric_violation, 0.0];
1924        let unit = LinearInequalityConstraints {
1925            a: array![[1.0, 0.0]],
1926            b: array![0.0],
1927        };
1928        let diag_unit = compute_constraint_kkt_diagnostics(&beta_unit, &gradient, &unit);
1929
1930        // Same hyperplane, row scaled ×1000: raw slack would be 2.071e-5, but
1931        // the *scaled* primal must still equal the geometric distance.
1932        let beta_big = array![-geometric_violation, 0.0];
1933        let big = LinearInequalityConstraints {
1934            a: array![[1000.0, 0.0]],
1935            b: array![0.0],
1936        };
1937        let diag_big = compute_constraint_kkt_diagnostics(&beta_big, &gradient, &big);
1938
1939        assert_relative_eq!(
1940            diag_unit.primal_feasibility,
1941            geometric_violation,
1942            epsilon = 1e-14
1943        );
1944        assert_relative_eq!(
1945            diag_big.primal_feasibility,
1946            geometric_violation,
1947            epsilon = 1e-14
1948        );
1949        // The scaled diagnostic must NOT report the ‖a‖-inflated raw slack.
1950        assert!(
1951            diag_big.primal_feasibility < 1e-7,
1952            "scaled primal {:.3e} should pass a 1e-7 gate; raw slack would be {:.3e}",
1953            diag_big.primal_feasibility,
1954            1000.0 * geometric_violation
1955        );
1956    }
1957
1958    // A B-spline `bc=clamped`/`bc=anchored` constraint is an EQUALITY
1959    // `a·β = b` encoded as two opposing inequalities `a·β ≥ b` and
1960    // `−a·β ≥ −b`. The active-set solver must drive the unconstrained
1961    // optimum back onto the hyperplane `a·β = b`. This is the isolated
1962    // analogue of the `bc=clamped` startup-validation abort: the exact
1963    // validation solve left `a·β ≈ 7.76` instead of 0, so the KKT primal
1964    // residual blew past tolerance and every seed was refused.
1965    #[test]
1966    fn opposing_inequality_pair_pins_equality_to_target() {
1967        // Minimize ½‖β‖² − rhs·β  (H = I) ⇒ unconstrained optimum β* = rhs.
1968        // rhs = [5,5,0,0] ⇒ a·β* = 10 with a = [1,1,0,0].
1969        // The opposing pair must pull a·β back to the target 0.
1970        let hessian = array![
1971            [1.0, 0.0, 0.0, 0.0],
1972            [0.0, 1.0, 0.0, 0.0],
1973            [0.0, 0.0, 1.0, 0.0],
1974            [0.0, 0.0, 0.0, 1.0],
1975        ];
1976        let rhs = array![5.0, 5.0, 0.0, 0.0];
1977        let beta_start = Array1::<f64>::zeros(4);
1978        let constraints = LinearInequalityConstraints {
1979            a: array![[1.0, 1.0, 0.0, 0.0], [-1.0, -1.0, 0.0, 0.0]],
1980            b: array![0.0, 0.0],
1981        };
1982
1983        let (beta, _active) = solve_quadratic_with_linear_constraints(
1984            &hessian,
1985            &rhs,
1986            &beta_start,
1987            &constraints,
1988            None,
1989        )
1990        .expect("opposing-inequality equality QP must solve");
1991
1992        let a_dot_beta = beta[0] + beta[1];
1993        assert!(
1994            a_dot_beta.abs() < 1e-8,
1995            "opposing inequalities must pin a·β to 0, got {a_dot_beta:.6e} (β = {beta:?})"
1996        );
1997    }
1998
1999    // Same as above but with a non-zero target and a large row norm — the
2000    // exact shape of a B-spline endpoint-derivative clamp, whose rows carry
2001    // ‖a‖ ≫ 1. The equality must still be pinned in geometric coordinates.
2002    #[test]
2003    fn opposing_inequality_pair_pins_scaled_equality_to_nonzero_target() {
2004        let hessian = array![
2005            [1.0, 0.0, 0.0, 0.0],
2006            [0.0, 1.0, 0.0, 0.0],
2007            [0.0, 0.0, 1.0, 0.0],
2008            [0.0, 0.0, 0.0, 1.0],
2009        ];
2010        let rhs = array![5.0, 5.0, 0.0, 0.0];
2011        let beta_start = Array1::<f64>::zeros(4);
2012        // Row scaled ×1000 (mimics a derivative-clamp row norm) with target 3000
2013        // ⇒ geometric target a·β = 3.0 in unit coordinates.
2014        let constraints = LinearInequalityConstraints {
2015            a: array![[1000.0, 1000.0, 0.0, 0.0], [-1000.0, -1000.0, 0.0, 0.0]],
2016            b: array![3000.0, -3000.0],
2017        };
2018
2019        let (beta, _active) = solve_quadratic_with_linear_constraints(
2020            &hessian,
2021            &rhs,
2022            &beta_start,
2023            &constraints,
2024            None,
2025        )
2026        .expect("scaled opposing-inequality equality QP must solve");
2027
2028        let a_dot_beta = 1000.0 * (beta[0] + beta[1]);
2029        assert!(
2030            (a_dot_beta - 3000.0).abs() < 1e-5,
2031            "opposing inequalities must pin a·β to 3000, got {a_dot_beta:.6e} (β = {beta:?})"
2032        );
2033    }
2034
2035    // `bc=clamped` at BOTH ends produces TWO opposing-inequality equalities
2036    // (4 rows total). The real abort reports `active=2/4` — only ONE of the
2037    // two equalities is being pinned. Reproduce two independent equalities
2038    // and require BOTH to be driven to their targets.
2039    #[test]
2040    fn two_opposing_inequality_equalities_both_pinned() {
2041        let hessian = array![
2042            [1.0, 0.0, 0.0, 0.0],
2043            [0.0, 1.0, 0.0, 0.0],
2044            [0.0, 0.0, 1.0, 0.0],
2045            [0.0, 0.0, 0.0, 1.0],
2046        ];
2047        let rhs = array![5.0, 5.0, 5.0, 5.0];
2048        let beta_start = Array1::<f64>::zeros(4);
2049        // Equality A: β0 + β1 = 0 (rows 0,1). Equality B: β2 + β3 = 0 (rows 2,3).
2050        let constraints = LinearInequalityConstraints {
2051            a: array![
2052                [1.0, 1.0, 0.0, 0.0],
2053                [-1.0, -1.0, 0.0, 0.0],
2054                [0.0, 0.0, 1.0, 1.0],
2055                [0.0, 0.0, -1.0, -1.0],
2056            ],
2057            b: array![0.0, 0.0, 0.0, 0.0],
2058        };
2059
2060        let (beta, _active) = solve_quadratic_with_linear_constraints(
2061            &hessian,
2062            &rhs,
2063            &beta_start,
2064            &constraints,
2065            None,
2066        )
2067        .expect("two-equality QP must solve");
2068
2069        assert!(
2070            (beta[0] + beta[1]).abs() < 1e-8,
2071            "equality A not pinned: β0+β1 = {:.6e}",
2072            beta[0] + beta[1]
2073        );
2074        assert!(
2075            (beta[2] + beta[3]).abs() < 1e-8,
2076            "equality B not pinned: β2+β3 = {:.6e}",
2077            beta[2] + beta[3]
2078        );
2079    }
2080
2081    // Faithful to the failing fit: the penalized IRLS Hessian `X'WX + λS`
2082    // with λ at the over-smoothing ceiling is severely ill-conditioned — the
2083    // penalty `S` is rank-deficient (null space = the unpenalized polynomial
2084    // part), so directions in null(S) are governed by a tiny `X'WX` block
2085    // while penalized directions carry a huge λ. The opposing-inequality
2086    // equalities must STILL be pinned under this conditioning.
2087    #[test]
2088    fn opposing_inequality_equalities_pinned_under_ill_conditioned_penalty() {
2089        // H = diag(1, 1, λ, λ) with λ = 1e8 — penalized directions 2,3 are
2090        // ~1e8 stiffer than the data directions 0,1.
2091        let lam = 1.0e8_f64;
2092        let hessian = array![
2093            [1.0, 0.0, 0.0, 0.0],
2094            [0.0, 1.0, 0.0, 0.0],
2095            [0.0, 0.0, lam, 0.0],
2096            [0.0, 0.0, 0.0, lam],
2097        ];
2098        let rhs = array![5.0, 5.0, 5.0, 5.0];
2099        let beta_start = Array1::<f64>::zeros(4);
2100        // Two equalities that COUPLE a stiff and a soft coordinate, like a
2101        // B-spline derivative row spanning penalized and unpenalized parts:
2102        // A: β0 + β2 = 0, B: β1 + β3 = 0.
2103        let constraints = LinearInequalityConstraints {
2104            a: array![
2105                [1.0, 0.0, 1.0, 0.0],
2106                [-1.0, 0.0, -1.0, 0.0],
2107                [0.0, 1.0, 0.0, 1.0],
2108                [0.0, -1.0, 0.0, -1.0],
2109            ],
2110            b: array![0.0, 0.0, 0.0, 0.0],
2111        };
2112
2113        let (beta, _active) = solve_quadratic_with_linear_constraints(
2114            &hessian,
2115            &rhs,
2116            &beta_start,
2117            &constraints,
2118            None,
2119        )
2120        .expect("ill-conditioned two-equality QP must solve");
2121
2122        assert!(
2123            (beta[0] + beta[2]).abs() < 1e-6,
2124            "equality A not pinned under ill-conditioning: β0+β2 = {:.6e}",
2125            beta[0] + beta[2]
2126        );
2127        assert!(
2128            (beta[1] + beta[3]).abs() < 1e-6,
2129            "equality B not pinned under ill-conditioning: β1+β3 = {:.6e}",
2130            beta[1] + beta[3]
2131        );
2132    }
2133}