Skip to main content

gam_solve/
active_set.rs

1use crate::estimate::EstimationError;
2use faer::linalg::solvers::SolveLstsq;
3use faer::Side;
4use gam_linalg::faer_ndarray::{
5    FaerArrayView, FaerCholesky, FaerLinalgError, FaerSvd, array1_to_col_matmut,
6    default_rrqr_rank_alpha, rrqr_nullspace_basis,
7};
8use gam_linalg::utils::{KahanSum, StableSolver, array_is_finite};
9use gam_problem::{
10    ConstraintRowId, ConstraintSet, KhatriRaoConeConstraints, LinearInequalityConstraints,
11};
12use ndarray::{Array1, Array2, ArrayView1, s};
13use serde::{Deserialize, Serialize};
14use std::cell::Cell;
15use std::collections::HashSet;
16
17/// Primal-feasibility tolerance the inequality-constrained active-set Newton
18/// solver guarantees on its returned iterate, measured in the unit-normalized
19/// constraint-row metric `(b_i − a_i·β)/‖a_i‖` that
20/// [`ConstraintSet::max_scaled_violation`] computes.
21///
22/// The solver accepts a step when the worst scaled violation is at or below
23/// this threshold (see the acceptance gate in
24/// `solve_linear_constrained_newton_step` and the KKT diagnostics in
25/// `compute_constraint_kkt_diagnostics`).
26///
27/// The definition lives with the metric, in `gam_problem::constraint_set`, and
28/// is re-exported here under the solver-facing name so that the tolerance and
29/// the quantity it bounds cannot drift apart. Every gate, validator and step
30/// rule that decides "is this feasible" must reference this one value; four
31/// sites once re-spelled the literal `1e-8` beside it and one step rule
32/// demanded exact feasibility instead (gam#2719).
33pub use gam_problem::PRIMAL_FEASIBILITY_TOL as ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
34
35/// Scaled slack tolerance for membership in an active working face.
36///
37/// This is intentionally tighter than the public primal-feasibility contract:
38/// a row may be numerically feasible without being an equality at the current
39/// point. Warm-start and terminal face provenance both use this value so a QP
40/// endpoint row cannot remain active after globalization accepts an interior
41/// subsegment of the endpoint chord.
42pub const ACTIVE_SET_WORKING_FACE_TOL: f64 = 1e-10;
43
44/// Stationarity tolerance for the strong-KKT acceptance gate: the projected
45/// (working-set) gradient residual ‖∇L − Aᵀλ‖∞, either absolute or relative to
46/// `max(1, ‖∇L‖∞)`, must fall below this to certify a constrained stationary
47/// point. Matched against `ACTIVE_SET_KKT_COMPLEMENTARITY_TOL` so both KKT
48/// residual channels are certified at compatible scales.
49const ACTIVE_SET_KKT_STATIONARITY_TOL: f64 = 2e-6;
50
51/// Complementarity-slackness tolerance for the KKT acceptance gate:
52/// `max_i |λ_i · slack_i|` must fall below this for the
53/// active-inactive partition to be consistent.
54pub(crate) const ACTIVE_SET_KKT_COMPLEMENTARITY_TOL: f64 = 1e-6;
55
56/// Dual-feasibility tolerance for the KKT acceptance gate: every working-set
57/// multiplier must satisfy `λ_i ≥ −ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL` (a
58/// strictly-negative multiplier means the constraint should be released).
59const ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL: f64 = 1e-8;
60
61/// Relaxed stationarity tolerance accepted only on a *genuinely degenerate
62/// boundary face* — one whose active rows are linearly dependent
63/// (`rank(A_active) < n_active`), so the active-row multipliers are non-unique
64/// and the exact projected gradient cannot reach
65/// `ACTIVE_SET_KKT_STATIONARITY_TOL`. Still requires primal feasibility,
66/// complementarity, and a relative-stationarity backstop.
67///
68/// Public so the outer REML / PIRLS validation gate can apply the same
69/// relaxation when the diagnostic reports a rank-deficient active face — a
70/// strict 5e-6 check there would otherwise refuse iterates that the inner
71/// active-set solver legitimately certified via its own `degenerate_boundary_ok`
72/// clause.
73///
74/// NOTE: this is *not* the mechanism that fixes the `shape=concave` /
75/// `shape=convex` cold-vs-warm cache divergence (#873). The B-spline shape path
76/// reparameterizes curvature into independent *coordinate lower bounds*
77/// `γ_j ≥ 0` (see `shape_lower_bounds_local`); any subset of those active rows
78/// is full rank, so `working_set_rank_deficient` stays `false` and this
79/// relaxation never fires for them — and must not be widened to. That bug is a
80/// *seed* problem (a cold seed landing on the cone vertex with every curvature
81/// row tight); it is fixed at the source by
82/// `project_point_strictly_into_feasible_cone`, which starts the inner solve
83/// strictly inside the cone so the strict tolerance is reachable.
84pub(crate) const ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL: f64 = 1e-3;
85
86/// KKT diagnostics for inequality-constrained Newton subproblems.
87///
88/// Constraints are represented as `A * beta >= b` in the same coefficient
89/// coordinate system as the returned `beta`.
90///
91/// **Invariants** (held by all producers; not enforced at consumer boundary):
92/// - `n_active <= n_constraints` (a row cannot be active twice).
93/// - All four residual components (`primal_feasibility`, `dual_feasibility`,
94///   `complementarity`, `stationarity`) are `>= 0.0` and finite.
95/// - `active_tolerance >= 0.0` and finite.
96#[derive(Clone, Debug, Serialize, Deserialize)]
97pub struct ConstraintKktDiagnostics {
98    /// Number of inequality rows.
99    pub n_constraints: usize,
100    /// Number of rows considered active (`slack <= active_tolerance`).
101    pub n_active: usize,
102    /// Maximum primal feasibility violation: `max_i max(0, b_i - a_i^T beta)`.
103    pub primal_feasibility: f64,
104    /// Maximum dual feasibility violation: `max_i max(0, -lambda_i)`.
105    pub dual_feasibility: f64,
106    /// Maximum complementarity residual: `max_i |lambda_i * slack_i|`.
107    pub complementarity: f64,
108    /// Stationarity residual: `||grad - A^T lambda||_inf`.
109    pub stationarity: f64,
110    /// Tolerance used to classify active constraints from slacks.
111    pub active_tolerance: f64,
112    /// `true` when the active rows are linearly dependent (`rank(A_active) <
113    /// n_active`) — a *degenerate boundary face*. On such a face the active-row
114    /// multipliers are non-unique and the strict stationarity tolerance is
115    /// unreachable by construction. The inner active-set solver certifies these
116    /// iterates via its `ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL` relaxation;
117    /// the outer validation gate must consult this flag to apply the matching
118    /// relaxation, or it will refuse a legitimately-converged constrained
119    /// optimum and abort the REML startup loop.
120    ///
121    /// NOTE: B-spline `shape=concave`/`shape=convex` faces are *not* degenerate
122    /// — that path reparameterizes curvature into independent coordinate lower
123    /// bounds `γ_j ≥ 0` (full-rank active subsets), so this flag stays `false`
124    /// for them. Their cold-start fragility is a seed problem fixed by the
125    /// strictly-interior seed, not by this relaxation.
126    #[serde(default)]
127    pub working_set_rank_deficient: bool,
128    /// `true` when the cone projector REFUSED rather than returning
129    /// multipliers, so `lambda` was never computed and `stationarity` is the
130    /// raw gradient by default rather than by measurement.
131    ///
132    /// Without this, a refusal is indistinguishable from an answer of exactly
133    /// zero: both leave `lambda = 0`, both make `stationarity == ‖grad‖∞`, and
134    /// both print `stat_rel = 1.000e0`. #2601 reports precisely that on a
135    /// fully active convexity face, and the two readings call for opposite
136    /// responses — a genuine `λ = 0` says the point is not stationary, while a
137    /// refusal says nothing was measured at all.
138    #[serde(default)]
139    pub cone_projection_refused: bool,
140    /// Inf-norm of the (raw, unprojected) gradient at `beta`, `‖gradient‖∞` —
141    /// the natural scale of the stationarity residual. A converged constrained
142    /// optimum drives `stationarity = ‖grad − Aᵀλ‖∞` to zero *relative to* this
143    /// scale, not to a fixed absolute floor: the profiled REML latent objective
144    /// carries an O(n) gradient magnitude even at a genuine stationary point
145    /// (issue #879), so a bare absolute stationarity gate is unreachable there
146    /// by construction. The inner active-set solver already certifies
147    /// convergence on the scale-invariant ratio
148    /// `stationarity / max(gradient_scale, 1)` (its `stationarity_rel` path
149    /// against `ACTIVE_SET_KKT_STATIONARITY_TOL`); the outer validation gate
150    /// [`crate::estimate::reml::outer_eval`]`::enforce_constraint_kkt` consults this
151    /// field to apply the identical relative test, so the two stop on the same
152    /// contract instead of the gate spuriously aborting a constrained optimum
153    /// the solver legitimately reached (issue #989). Defaults to `0.0` when
154    /// deserialized from a model saved before this field existed, which makes
155    /// `max(gradient_scale, 1) = 1` and recovers the bare absolute test.
156    #[serde(default)]
157    pub gradient_scale: f64,
158}
159
160impl ConstraintKktDiagnostics {
161    /// The note a rendered KKT verdict must carry when the cone projector
162    /// REFUSED instead of returning multipliers.
163    ///
164    /// `stationarity` is `‖grad − Aᵀλ‖∞`. On a refusal `λ` was never computed,
165    /// so the number reported in that slot is the raw gradient BY DEFAULT rather
166    /// than by measurement — and two failures with opposite remedies then render
167    /// identically: a computed `λ = 0` says this point is not stationary, a
168    /// refusal says nothing was projected at all.
169    ///
170    /// [`Self::cone_projection_refused`] has recorded which since #2601, and
171    /// until this accessor existed every reader of the numbers dropped it — the
172    /// outer REML gate's `ParameterConstraintViolation` and the post-fit
173    /// feasibility audit's `KKT[...]` suffix both rendered `stat` with no way to
174    /// say whether it had been projected. A flag that cannot change what anyone
175    /// sees is not a diagnostic.
176    ///
177    /// Returns the separator too, so a non-refusal contributes nothing rather
178    /// than a dangling one.
179    pub fn cone_projection_note(&self) -> &'static str {
180        if self.cone_projection_refused {
181            "; cone_projection=REFUSED (no multipliers computed: stat is the UNPROJECTED gradient, \
182             not a residual)"
183        } else {
184            ""
185        }
186    }
187}
188
189/// Inf-norm `‖g‖∞` used as the scale of the stationarity residual in the
190/// relative KKT criterion shared by the inner active-set solver and the outer
191/// validation gate (see [`ConstraintKktDiagnostics::gradient_scale`]).
192fn gradient_inf_norm(gradient: &Array1<f64>) -> f64 {
193    gradient.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
194}
195
196fn solve_newton_direction_dense(
197    hessian: &Array2<f64>,
198    gradient: &Array1<f64>,
199    direction_out: &mut Array1<f64>,
200) -> Result<(), EstimationError> {
201    if direction_out.len() != gradient.len() {
202        *direction_out = Array1::zeros(gradient.len());
203    }
204
205    let factor = StableSolver::new()
206        .factorize(hessian)
207        .map_err(EstimationError::LinearSystemSolveFailed)?;
208    direction_out.assign(gradient);
209    let mut rhsview = array1_to_col_matmut(direction_out);
210    factor.solve_in_place(rhsview.as_mut());
211    direction_out.mapv_inplace(|v| -v);
212    if array_is_finite(direction_out) {
213        return Ok(());
214    }
215    Err(EstimationError::LinearSystemSolveFailed(
216        FaerLinalgError::FactorizationFailed {
217            context: "active-set newton direction non-finite solve",
218        },
219    ))
220}
221
222fn solve_dense_system_via_pseudoinverse(
223    matrix: &Array2<f64>,
224    rhs: &Array1<f64>,
225    out: &mut Array1<f64>,
226) -> Result<(), EstimationError> {
227    if matrix.nrows() != matrix.ncols() || rhs.len() != matrix.nrows() {
228        crate::bail_invalid_estim!("dense pseudoinverse solve dimension mismatch");
229    }
230
231    let (u_opt, singular, vt_opt) = matrix.svd(true, true).map_err(|_| {
232        EstimationError::InvalidInput("dense pseudoinverse solve SVD failed".to_string())
233    })?;
234    let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
235        crate::bail_invalid_estim!("dense pseudoinverse solve missing singular vectors");
236    };
237
238    let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
239    let tol = 100.0
240        * f64::EPSILON
241        * (matrix.nrows().max(matrix.ncols()).max(1) as f64)
242        * max_singular.max(1.0);
243    let mut coeff = u.t().dot(rhs);
244    for (idx, value) in coeff.iter_mut().enumerate() {
245        let sigma = singular[idx];
246        if sigma.abs() > tol {
247            *value /= sigma;
248        } else {
249            *value = 0.0;
250        }
251    }
252    let solution = vt.t().dot(&coeff);
253    if !array_is_finite(&solution) {
254        crate::bail_invalid_estim!("dense pseudoinverse solve produced non-finite values");
255    }
256    if out.len() != solution.len() {
257        *out = Array1::zeros(solution.len());
258    }
259    out.assign(&solution);
260    Ok(())
261}
262
263/// Least-squares `min_z ‖A z − b‖` for `A` of shape `(p, k)` and rhs `b`
264/// (length `p`), returning `z` (length `k`) or `None` on numerical failure.
265///
266/// - Tall or square (`p ≥ k`): the rank-revealing col-pivoted QR (faer
267///   `solve_lstsq`) — the exact prior behavior, byte-for-byte.
268/// - Wide (`k > p`): the system is underdetermined. This arises on a DEGENERATE
269///   active face where more constraint rows are active than the problem has
270///   dimensions — e.g. a monotone coefficient cone plus many binding per-row
271///   derivative guards. The minimum-norm solution `z = Aᵀ (A Aᵀ)⁺ b` is taken
272///   via the SVD pseudoinverse of the square (possibly rank-deficient) Gram
273///   `A Aᵀ`, matching what a wide-capable least-squares would return.
274///
275/// Faer's `solve_lstsq` asserts `nrows ≥ ncols`, so feeding it a wide matrix
276/// panics — and here that panic would cross the Rust/Python FFI boundary,
277/// violating the typed-error contract. Routing the wide case through this helper
278/// keeps the failure typed: callers receive `None` and treat it as
279/// "not certified" (conservative), never a process abort.
280fn least_squares_min_norm_any_shape(a: &Array2<f64>, b: &Array1<f64>) -> Option<Array1<f64>> {
281    let p = a.nrows();
282    let k = a.ncols();
283    if b.len() != p {
284        return None;
285    }
286    if k == 0 {
287        return Some(Array1::zeros(0));
288    }
289    if k <= p {
290        let mut rhs = Array2::<f64>::zeros((p, 1));
291        rhs.column_mut(0).assign(b);
292        let a_view = FaerArrayView::new(a);
293        let rhs_view = FaerArrayView::new(&rhs);
294        let solved = a_view.as_ref().col_piv_qr().solve_lstsq(rhs_view.as_ref());
295        let mut z = Array1::<f64>::zeros(k);
296        for c in 0..k {
297            let value = solved[(c, 0)];
298            if !value.is_finite() {
299                return None;
300            }
301            z[c] = value;
302        }
303        Some(z)
304    } else {
305        // Underdetermined: min-norm `z = Aᵀ (A Aᵀ)⁺ b`. `A Aᵀ` is `p × p`, so it
306        // satisfies the square precondition of the SVD pseudoinverse solve, and
307        // the pseudoinverse absorbs the rank deficiency of an over-complete face.
308        let gram = a.dot(&a.t());
309        let mut y = Array1::<f64>::zeros(p);
310        solve_dense_system_via_pseudoinverse(&gram, b, &mut y).ok()?;
311        let z = a.t().dot(&y);
312        if z.iter().any(|value| !value.is_finite()) {
313            return None;
314        }
315        Some(z)
316    }
317}
318
319/// How much of a refused stationarity residual is even *closable* by a
320/// multiplier, and how much is not.
321///
322/// The KKT stationarity residual `‖grad − Aᵀλ‖∞` is one number, and on its own
323/// it cannot distinguish two completely different failures:
324///
325/// * **Unreachable** — the residual has a component ORTHOGONAL to
326///   `span(A_activeᵀ)`. No multiplier vector, of any sign, can remove it: the
327///   iterate is non-stationary along a direction the constraints do not touch,
328///   i.e. the inner solve simply has not converged.
329/// * **Blocked** — the residual lies inside `span(A_activeᵀ)` but cannot be
330///   written with `λ ≥ 0`. The point is stationary in the free directions and
331///   is pressed against the WRONG side of the cone; the working face is wrong,
332///   not the convergence.
333///
334/// Reported only where a verdict is actually rendered (the refusal message), so
335/// the O(rank · n_active · p) orthogonalization never runs on the hot path.
336/// Returns `(unreachable_inf, blocked_inf)`; `None` when the geometry cannot be
337/// formed (dimension mismatch, no active rows).
338pub(crate) fn stationarity_residual_reachability(
339    beta: &Array1<f64>,
340    gradient: &Array1<f64>,
341    constraints: &LinearInequalityConstraints,
342) -> Option<(f64, f64)> {
343    let p = constraints.a.ncols();
344    if beta.len() != p || gradient.len() != p {
345        return None;
346    }
347    let face = active_face(beta, constraints)?;
348    if face.active_idx.is_empty() {
349        // Every direction is free: the whole residual is unreachable.
350        let inf = gradient_inf_norm(gradient);
351        return Some((inf, 0.0));
352    }
353    let (_, lambda_active) =
354        project_stationarity_residual_on_constraint_cone(gradient, &face.a_active)?;
355    let mut residual = gradient.to_owned();
356    for (r, &value) in lambda_active.iter().enumerate() {
357        if value != 0.0 {
358            residual.scaled_add(-value, &face.a_active.row(r));
359        }
360    }
361
362    // Orthonormal basis of the active ROW space by modified Gram–Schmidt. The
363    // basis is at most `p`-dimensional, so the scan stops as soon as it is
364    // complete no matter how many rows are active.
365    let mut basis: Vec<Array1<f64>> = Vec::new();
366    let drop_tol = 1e-12;
367    for r in 0..face.a_active.nrows() {
368        if basis.len() == p {
369            break;
370        }
371        let mut v = face.a_active.row(r).to_owned();
372        for q in &basis {
373            let projection = q.dot(&v);
374            v.scaled_add(-projection, q);
375        }
376        let norm = v.dot(&v).sqrt();
377        if norm > drop_tol {
378            v.mapv_inplace(|value| value / norm);
379            basis.push(v);
380        }
381    }
382
383    let mut orthogonal = residual.clone();
384    for q in &basis {
385        let projection = q.dot(&residual);
386        orthogonal.scaled_add(-projection, q);
387    }
388    let unreachable = gradient_inf_norm(&orthogonal);
389    let in_row_space = &residual - &orthogonal;
390    Some((unreachable, gradient_inf_norm(&in_row_space)))
391}
392
393/// The active face at `beta`: per-row-scaled inequalities, their slacks, the
394/// indices considered active, and the gathered active rows.
395///
396/// One derivation shared by [`compute_constraint_kkt_diagnostics`] and
397/// [`stationarity_residual_reachability`], so the reported reachability split
398/// can never describe a different face than the residual it explains.
399struct ActiveFace {
400    a_scaled: Array2<f64>,
401    slack: Array1<f64>,
402    primal_feasibility: f64,
403    active_idx: Vec<usize>,
404    a_active: Array2<f64>,
405}
406
407/// The unit-scaled rows of the constraints that BIND at `(beta, gradient)`:
408/// primal-active rows whose multiplier in the Moreau cone projection of the
409/// gradient is strictly positive. `k × p`, `k` possibly zero; `None` on a width
410/// mismatch or when the projection refuses.
411///
412/// This is the face an exact Newton refinement of a constrained KKT point has
413/// to stay on: with the binding multipliers carried by `∇L − Aᵀλ`, the residual
414/// that is left to zero lives in `null(A_binding)`, so the refinement step is
415/// the Newton step of the objective restricted to that null space.
416///
417/// A primal-active row with a zero multiplier is not part of the KKT face: the
418/// stationarity residual it leaves is the full gradient component along its
419/// normal, and a Newton step confined to that row's face cannot remove it.
420/// Treating the row as free lets the refinement move along its normal — inward
421/// when the objective wants to leave the boundary — while the caller's primal
422/// feasibility check still refuses any step that would cross it.
423pub(crate) fn binding_constraint_rows(
424    beta: &Array1<f64>,
425    gradient: &Array1<f64>,
426    constraints: &LinearInequalityConstraints,
427) -> Option<Array2<f64>> {
428    let face = active_face(beta, constraints)?;
429    let p = constraints.a.ncols();
430    if face.active_idx.is_empty() || gradient.len() != p {
431        return Some(Array2::<f64>::zeros((0, p)));
432    }
433    let (_, lambda_active) =
434        project_stationarity_residual_on_constraint_cone(gradient, &face.a_active)?;
435    let keep: Vec<usize> = (0..face.active_idx.len())
436        .filter(|&row| lambda_active.get(row).is_some_and(|value| *value > 0.0))
437        .collect();
438    let mut rows = Array2::<f64>::zeros((keep.len(), p));
439    for (position, &row) in keep.iter().enumerate() {
440        rows.row_mut(position).assign(&face.a_active.row(row));
441    }
442    Some(rows)
443}
444
445fn active_face(
446    beta: &Array1<f64>,
447    constraints: &LinearInequalityConstraints,
448) -> Option<ActiveFace> {
449    let m = constraints.a.nrows();
450    let p = constraints.a.ncols();
451    if beta.len() != p {
452        return None;
453    }
454    // Measure feasibility in the *scaled* (geometric) coordinate system the
455    // solver's tolerance is expressed in — see the note in
456    // `compute_constraint_kkt_diagnostics`.
457    let mut a_scaled = constraints.a.clone();
458    let mut b_scaled = constraints.b.clone();
459    for i in 0..m {
460        let n_i = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
461        if n_i > 0.0 {
462            let inv = 1.0 / n_i;
463            a_scaled.row_mut(i).mapv_inplace(|v| v * inv);
464            b_scaled[i] *= inv;
465        }
466    }
467    let mut slack = Array1::<f64>::zeros(m);
468    let mut primal_feasibility: f64 = 0.0;
469    for i in 0..m {
470        let s_i = a_scaled.row(i).dot(beta) - b_scaled[i];
471        slack[i] = s_i;
472        primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
473    }
474    let active_idx: Vec<usize> = (0..m)
475        .filter(|&i| slack[i] <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL)
476        .collect();
477    let mut a_active = Array2::<f64>::zeros((active_idx.len(), p));
478    for (r, &idx) in active_idx.iter().enumerate() {
479        a_active.row_mut(r).assign(&a_scaled.row(idx));
480    }
481    Some(ActiveFace {
482        a_scaled,
483        slack,
484        primal_feasibility,
485        active_idx,
486        a_active,
487    })
488}
489
490pub(crate) fn compute_constraint_kkt_diagnostics(
491    beta: &Array1<f64>,
492    gradient: &Array1<f64>,
493    constraints: &LinearInequalityConstraints,
494) -> ConstraintKktDiagnostics {
495    let m = constraints.a.nrows();
496    let active_tolerance = ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
497
498    // Feasibility is measured in the *scaled* (geometric) coordinate system the
499    // solver's tolerance is expressed in — see `active_face`, which owns that
500    // derivation for this function and for
501    // [`stationarity_residual_reachability`].
502    let p = constraints.a.ncols();
503    let Some(ActiveFace {
504        a_scaled,
505        slack,
506        primal_feasibility,
507        active_idx,
508        a_active,
509    }) = active_face(beta, constraints)
510    else {
511        // `beta` does not match the constraint system's coefficient width. No
512        // face can be formed, so no KKT claim can be made: report the raw
513        // gradient scale and an empty active set rather than inventing one.
514        return ConstraintKktDiagnostics {
515            n_constraints: m,
516            n_active: 0,
517            primal_feasibility: f64::INFINITY,
518            dual_feasibility: 0.0,
519            complementarity: 0.0,
520            stationarity: gradient_inf_norm(gradient),
521            active_tolerance,
522            working_set_rank_deficient: false,
523            cone_projection_refused: false,
524            gradient_scale: gradient_inf_norm(gradient),
525        };
526    };
527
528    let mut lambda = Array1::<f64>::zeros(m);
529    let mut working_set_rank_deficient = false;
530    // A refusal and an answer of zero are not the same fact.
531    //
532    // `project_stationarity_residual_on_constraint_cone` returns `None` on a
533    // non-finite target, a width mismatch, or the Lawson-Hanson `3m + 30`
534    // guard being reached -- and this `if let` used to leave `lambda` at its
535    // zeros in every one of those cases, with no trace. That is
536    // indistinguishable from NNLS genuinely reporting `λ = 0`: both give
537    // `stationarity == ‖grad‖∞` and print `stat_rel = 1.000e0`.
538    //
539    // #2601 reports exactly that on a fully active convexity face. A measured
540    // check (`the_kkt_cone_convention_is_grad_equals_a_transpose_lambda_2601`)
541    // rules out the obvious explanation: a gradient in the POLAR cone still
542    // recruits rows and leaves `stat_rel = 0.5` on that face, not 1.0. So a
543    // flipped sign cannot produce the observed number and a silent refusal can.
544    let mut cone_projection_refused = false;
545    if !active_idx.is_empty() {
546        let n_active = active_idx.len();
547        match project_stationarity_residual_on_constraint_cone(gradient, &a_active) {
548            Some((_, lambda_active)) => {
549                for (r, &idx) in active_idx.iter().enumerate() {
550                    lambda[idx] = lambda_active[r];
551                }
552            }
553            None => cone_projection_refused = true,
554        }
555        // Rank-deficiency detection on the (scaled) active rows. Per-row
556        // positive scaling is rank-preserving, so this answers the same
557        // question the finite dual solver's reduced face does —
558        // `rank(A_active) < n_active`. For curvature
559        // constraints the second-difference operator forces dependence
560        // whenever more than `p` rows bind, and for monotonicity the
561        // first-difference operator does so beyond a similar count. The
562        // diagnostic exposes the flag so the outer validation gate can apply
563        // the same `ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL` relaxation
564        // the inner solver does, instead of refusing the iterate at strict
565        // `ACTIVE_SET_KKT_STATIONARITY_TOL`.
566        working_set_rank_deficient = if n_active > p {
567            true
568        } else if n_active > 1 {
569            let groups: Vec<Vec<usize>> = (0..n_active).map(|i| vec![i]).collect();
570            let b_dummy = Array1::<f64>::zeros(n_active);
571            let (reduced_a, _, _, _) =
572                rank_reduce_rows_pivoted_qr_with_dependence(a_active, b_dummy, groups);
573            reduced_a.nrows() < n_active
574        } else {
575            false
576        };
577    }
578
579    let mut dual_feasibility: f64 = 0.0;
580    let mut complementarity: f64 = 0.0;
581    for i in 0..m {
582        dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
583        complementarity = complementarity.max((lambda[i] * slack[i]).abs());
584    }
585    let stationarity = {
586        let mut resid = gradient.to_owned();
587        resid -= &a_scaled.t().dot(&lambda);
588        resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
589    };
590
591    ConstraintKktDiagnostics {
592        n_constraints: m,
593        n_active: active_idx.len(),
594        primal_feasibility,
595        dual_feasibility,
596        complementarity,
597        stationarity,
598        active_tolerance,
599        working_set_rank_deficient,
600        cone_projection_refused,
601        gradient_scale: gradient_inf_norm(gradient),
602    }
603}
604
605/// Operator-native Lawson–Hanson projection onto a finitely generated cone.
606///
607/// `row_values(r)` returns every raw row product `A r`; `gather_rows(ids)`
608/// materializes only the named rows. The passive set of an NNLS solution has
609/// at most coefficient-space rank, so a factored cone can scan millions of
610/// generators while gathering only `O(p²)` storage. Entering rows use ascending
611/// original row id as the exact-tie break, so the unique projection is
612/// independent of warm-start history.
613fn nonnegative_cone_projection_by_rows<RowValues, GatherRows>(
614    row_norms: &[f64],
615    target: &Array1<f64>,
616    row_values: RowValues,
617    gather_rows: GatherRows,
618) -> Option<(Vec<(usize, f64)>, Array1<f64>)>
619where
620    RowValues: Fn(&Array1<f64>) -> Option<Array1<f64>>,
621    GatherRows: Fn(&[usize]) -> Option<Array2<f64>>,
622{
623    let p = target.len();
624    let m = row_norms.len();
625    if m == 0 {
626        return Some((Vec::new(), target.clone()));
627    }
628    if target.iter().any(|v| !v.is_finite())
629        || row_norms
630            .iter()
631            .any(|norm| !norm.is_finite() || *norm < 0.0)
632    {
633        return None;
634    }
635    let target_inf = target.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
636    if target_inf == 0.0 {
637        return Some((Vec::new(), target.clone()));
638    }
639    // Gradient tolerance in λ-space: with unit rows, `w_i = a_î·r` is bounded
640    // by ‖r‖, so a relative band on the target scale is dimensionless.
641    let tol_w = 1e-10 * target_inf;
642    let lambda_floor = 1e-14 * target_inf;
643
644    let mut lambda_unit = Array1::<f64>::zeros(m);
645    let mut passive: Vec<usize> = Vec::new();
646    let mut in_passive = vec![false; m];
647    let mut residual = target.clone();
648    // Rows whose trial coefficient collapsed to zero at the current residual;
649    // re-eligible as soon as the residual moves. Prevents an add/drop loop on
650    // exactly degenerate geometry.
651    let mut banned = vec![false; m];
652
653    let solve_passive = |passive: &[usize]| -> Option<Array1<f64>> {
654        let k = passive.len();
655        // `design` is `p × k` (each column is a unit active row). On a degenerate
656        // over-complete face `k` can exceed `p` (more active rows than
657        // dimensions); the min-norm least-squares helper handles that wide case
658        // instead of panicking inside faer's tall-only `solve_lstsq`.
659        let mut design = Array2::<f64>::zeros((p, k));
660        let rows = gather_rows(passive)?;
661        if rows.nrows() != k || rows.ncols() != p || rows.iter().any(|value| !value.is_finite()) {
662            return None;
663        }
664        for (col, &row) in passive.iter().enumerate() {
665            let norm = row_norms[row];
666            if !(norm > 0.0) {
667                return None;
668            }
669            design
670                .column_mut(col)
671                .assign(&(&rows.row(col) / norm));
672        }
673        least_squares_min_norm_any_shape(&design, target)
674    };
675
676    let max_outer = m.saturating_mul(3).saturating_add(30);
677    for _ in 0..max_outer {
678        // Most-ascent candidate among non-passive, non-banned rows.
679        let values = row_values(&residual)?;
680        if values.len() != m || values.iter().any(|value| !value.is_finite()) {
681            return None;
682        }
683        let mut best: Option<(usize, f64)> = None;
684        for i in 0..m {
685            if in_passive[i] || banned[i] || row_norms[i] <= 0.0 {
686                continue;
687            }
688            let w = values[i] / row_norms[i];
689            if w > tol_w && best.map(|(_, best_w)| w > best_w).unwrap_or(true) {
690                best = Some((i, w));
691            }
692        }
693        let Some((entering, _)) = best else {
694            break;
695        };
696        passive.push(entering);
697        in_passive[entering] = true;
698
699        let mut inner_ok = false;
700        for _ in 0..(m + 2) {
701            let Some(z) = solve_passive(&passive) else {
702                return None;
703            };
704            let min_z = z.iter().copied().fold(f64::INFINITY, f64::min);
705            if min_z > lambda_floor {
706                for (pos, &row) in passive.iter().enumerate() {
707                    lambda_unit[row] = z[pos];
708                }
709                inner_ok = true;
710                break;
711            }
712            // Interpolate toward z until the first coefficient hits zero,
713            // then drop every zeroed row from the passive set.
714            let mut alpha = 1.0_f64;
715            for (pos, &row) in passive.iter().enumerate() {
716                if z[pos] <= lambda_floor {
717                    let current = lambda_unit[row];
718                    let denom = current - z[pos];
719                    if denom > 0.0 {
720                        alpha = alpha.min((current / denom).clamp(0.0, 1.0));
721                    } else {
722                        alpha = 0.0;
723                    }
724                }
725            }
726            for (pos, &row) in passive.iter().enumerate() {
727                lambda_unit[row] += alpha * (z[pos] - lambda_unit[row]);
728            }
729            let mut retained = Vec::with_capacity(passive.len());
730            for &row in &passive {
731                if lambda_unit[row] > lambda_floor {
732                    retained.push(row);
733                } else {
734                    lambda_unit[row] = 0.0;
735                    in_passive[row] = false;
736                    // The row failed at THIS residual; ban it until the
737                    // residual moves so a degenerate add/drop pair cannot
738                    // cycle within one outer round.
739                    banned[row] = true;
740                }
741            }
742            if retained.len() == passive.len() {
743                // Nothing dropped despite a non-positive trial coefficient:
744                // numerically stuck; stop refining this passive set.
745                inner_ok = true;
746                for (pos, &row) in passive.iter().enumerate() {
747                    lambda_unit[row] = z[pos].max(0.0);
748                }
749                break;
750            }
751            passive = retained;
752            if passive.is_empty() {
753                break;
754            }
755        }
756        // Refresh the residual; any movement re-enables banned rows.
757        let mut fitted = Array1::<f64>::zeros(p);
758        let passive_rows = gather_rows(&passive)?;
759        if passive_rows.nrows() != passive.len()
760            || passive_rows.ncols() != p
761            || passive_rows.iter().any(|value| !value.is_finite())
762        {
763            return None;
764        }
765        for (position, &row) in passive.iter().enumerate() {
766            fitted.scaled_add(
767                lambda_unit[row] / row_norms[row],
768                &passive_rows.row(position),
769            );
770        }
771        let new_residual = target - &fitted;
772        let moved = new_residual
773            .iter()
774            .zip(residual.iter())
775            .any(|(a, b)| (a - b).abs() > 1e-15 * target_inf);
776        residual = new_residual;
777        if moved {
778            banned.iter_mut().for_each(|b| *b = false);
779        } else if !inner_ok {
780            break;
781        }
782    }
783
784    // Exact Moreau/KKT exit: the residual must lie in the polar cone, i.e.
785    // every unit generator has non-positive correlation (within the same
786    // scale-relative tolerance used for entering). This distinguishes normal
787    // Lawson–Hanson termination from exhausting the floating-point pivot cap;
788    // a capped non-polar iterate is not a projection and must never reach a
789    // stationarity certificate or projected-gradient direction.
790    let final_values = row_values(&residual)?;
791    if final_values.len() != m
792        || final_values.iter().any(|value| !value.is_finite())
793        || (0..m).any(|row| {
794            row_norms[row] > 0.0 && final_values[row] / row_norms[row] > tol_w
795        })
796    {
797        return None;
798    }
799
800    let multipliers: Vec<(usize, f64)> = passive
801        .into_iter()
802        .filter_map(|row| {
803            let lambda = lambda_unit[row] / row_norms[row];
804            (lambda > 0.0).then_some((row, lambda))
805        })
806        .collect();
807    if multipliers.iter().any(|(_, value)| !value.is_finite())
808        || !array_is_finite(&residual)
809    {
810        return None;
811    }
812    Some((multipliers, residual))
813}
814
815/// Lawson–Hanson nonnegative least squares onto a dense finitely generated
816/// cone.
817///
818/// Solves `min_{λ ≥ 0} ‖rowsᵀ λ − target‖₂` for a row block `rows` (`m × p`,
819/// original row units) and returns `(λ, projected)` with
820/// `projected = target − rowsᵀ λ`. By the Moreau decomposition `rowsᵀ λ` is
821/// the Euclidean projection of `target` onto the cone generated by the rows,
822/// so `projected` is the projection onto that cone's polar.
823///
824/// This is the existence-form dual-feasibility certificate for degenerate
825/// working faces: multipliers on a rank-deficient face are non-unique, and
826/// any single reconstruction (KKT least-squares, per-group attribution) can
827/// carry huge canceling ± components — reporting `dual ≫ 0` at a point where
828/// a different `λ ≥ 0` closes stationarity exactly (#2298 survival
829/// monotonicity faces, #979 CTN Khatri–Rao faces). NNLS answers the right
830/// question: does ANY nonnegative multiplier close stationarity?
831///
832/// Rows are unit-normalized internally so pivot ordering and tolerances are
833/// scale-invariant; the returned `λ` is in original row units. Zero rows
834/// carry `λ = 0`. Classic LH terminates after finitely many passive-set
835/// changes; a `3m + 30` outer guard bounds float pathologies, and the terminal
836/// full-row polarity check refuses rather than returning a non-KKT iterate if
837/// that guard is ever reached.
838pub(crate) fn nonnegative_cone_multipliers(
839    rows: &Array2<f64>,
840    target: &Array1<f64>,
841) -> Option<(Array1<f64>, Array1<f64>)> {
842    let p = target.len();
843    let m = rows.nrows();
844    if rows.ncols() != p {
845        return None;
846    }
847    let norms: Vec<f64> = (0..m)
848        .map(|row| rows.row(row).dot(&rows.row(row)).sqrt())
849        .collect();
850    let (sparse, projected) = nonnegative_cone_projection_by_rows(
851        &norms,
852        target,
853        |residual| Some(rows.dot(residual)),
854        |ids| {
855            let mut gathered = Array2::<f64>::zeros((ids.len(), p));
856            for (position, &row) in ids.iter().enumerate() {
857                gathered.row_mut(position).assign(&rows.row(row));
858            }
859            Some(gathered)
860        },
861    )?;
862    let mut lambda = Array1::<f64>::zeros(m);
863    for (row, value) in sparse {
864        lambda[row] = value;
865    }
866    Some((lambda, projected))
867}
868
869pub fn project_stationarity_residual_on_constraint_cone(
870    residual: &Array1<f64>,
871    active_a: &Array2<f64>,
872) -> Option<(Array1<f64>, Array1<f64>)> {
873    let p = residual.len();
874    if active_a.ncols() != p {
875        return None;
876    }
877    if active_a.nrows() == 0 {
878        return Some((residual.clone(), Array1::zeros(0)));
879    }
880    // Projection onto a finitely generated cone IS nonnegative least squares
881    // (Moreau): `projected = residual − Aᵀλ*` with
882    // `λ* = argmin_{λ≥0} ‖residual − Aᵀλ‖`. Use that definition directly.
883    // The former two-algorithm cascade first ran a primal working-set QP and
884    // silently substituted Lawson–Hanson when it cycled. A stationarity
885    // projector must be one deterministic map, not a success-dependent choice
886    // between algorithms (#2432).
887    nonnegative_cone_multipliers(active_a, residual).map(|(lambda, projected)| (projected, lambda))
888}
889
890pub(crate) fn feasible_point_for_linear_constraints(
891    constraints: &LinearInequalityConstraints,
892    p: usize,
893) -> Option<Array1<f64>> {
894    if constraints.a.ncols() != p
895        || constraints.a.nrows() == 0
896        || constraints.b.len() != constraints.a.nrows()
897    {
898        return None;
899    }
900    // The zero-vector shortcut must compare `b` in GEOMETRIC (per-row-scaled)
901    // units: on raw `b` alone, `1e-20·β ≥ 1e-20` — the same half-space as
902    // `β ≥ 1` — would accept `β = 0`. A numerically-zero row is vacuous when
903    // `b_i ≤ 0` and infeasible (no seed exists) when `b_i > 0`.
904    let mut all_scaled_b_tiny = true;
905    for i in 0..constraints.a.nrows() {
906        let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
907        if norm > 0.0 {
908            if constraints.b[i].abs() > 1e-14 * norm {
909                all_scaled_b_tiny = false;
910            }
911        } else if constraints.b[i] > 0.0 {
912            return None;
913        }
914    }
915    if all_scaled_b_tiny {
916        return Some(Array1::zeros(p));
917    }
918
919    let gram = constraints.a.dot(&constraints.a.t());
920    let (u_opt, singular, vt_opt) = gram.svd(true, true).ok()?;
921    let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
922        return None;
923    };
924    let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
925    // Rank tolerance relative to the LARGEST singular value only — an absolute
926    // `max(σ_max, 1)` floor declares a uniformly small (but perfectly
927    // well-conditioned) system rank-deficient purely because of its units.
928    let tol = 100.0 * f64::EPSILON * constraints.a.nrows().max(1) as f64 * max_singular;
929    let mut coeff = u.t().dot(&constraints.b);
930    for (idx, value) in coeff.iter_mut().enumerate() {
931        let sigma = singular[idx];
932        if sigma.abs() > tol {
933            *value /= sigma;
934        } else {
935            *value = 0.0;
936        }
937    }
938    let dual = vt.t().dot(&coeff);
939    let beta = constraints.a.t().dot(&dual);
940    if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
941        return None;
942    }
943    // Accept on per-row GEOMETRIC slack (raw slack over ‖a_i‖), the same
944    // scale-invariant metric the active-set gates use.
945    let feasible = (0..constraints.a.nrows()).all(|i| {
946        let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
947        if norm > 0.0 {
948            (constraints.a.row(i).dot(&beta) - constraints.b[i]) / norm >= -1e-8
949        } else {
950            constraints.b[i] <= 0.0
951        }
952    });
953    if feasible { Some(beta) } else { None }
954}
955
956/// Strictly-interior margin (in per-row geometric / scaled-slack units) required
957/// of the projected cold-start seed produced by
958/// [`project_point_strictly_into_feasible_cone`]. Each constraint row is shifted
959/// to `a_iᵀβ ≥ b_i + ACTIVE_SET_INTERIOR_SEED_MARGIN·‖a_i‖` so that, scaled by
960/// `‖a_i‖`, every row of the returned seed has slack `≥` this margin. The value
961/// is far above the active-set activation threshold (`tol_active = 1e-10`) so the
962/// initial working set the QP step solver builds from the seed is **empty** — no
963/// row is mistaken for "on the boundary" — yet small enough that the seed stays a
964/// negligible distance from the data-driven projection it is derived from.
965const ACTIVE_SET_INTERIOR_SEED_MARGIN: f64 = 1e-6;
966
967/// The strictly-interior cold-start margin (scaled-slack units) that
968/// [`project_point_strictly_into_feasible_cone`] guarantees on its returned
969/// seed. Exposed so the P-IRLS seed builder can decide, on the same scale,
970/// whether the current seed is already strictly interior (and may be used as-is)
971/// or sits on / outside the cone boundary (and must be projected).
972#[inline]
973pub(crate) fn interior_seed_margin() -> f64 {
974    ACTIVE_SET_INTERIOR_SEED_MARGIN
975}
976
977/// Maximum nesting depth of the strictly-interior feasibility repair before the
978/// solver stops re-projecting and surfaces an honest constraint-violation error.
979///
980/// The operator strict-interior projection and its feasibility repair are
981/// mutually recursive: a failed projected-gradient repair can request another
982/// inward-shifted identity-metric solve. On a well-conditioned cone the repair
983/// converges at depth 0–1. But on near-anti-parallel rows (the clamped / anchored
984/// monotone time-warp constraints an interval-censored survival fit emits, which
985/// are only *near* — not exactly — anti-parallel and so slip past the zero-width
986/// equality lift below), successive inward shifts may never certify. A cone that
987/// cannot be certified within this many levels is degenerate; the projection
988/// surfaces [`EstimationError::ParameterConstraintViolation`] instead of
989/// exhausting the worker stack. Dense strict QPs no longer participate in this
990/// cycle: they use the finite dual metric projection directly (#2432).
991const MAX_FEASIBILITY_REPAIR_DEPTH: u32 = 16;
992
993thread_local! {
994    /// Current nesting depth of the `solve ↔ project` feasibility-repair cycle on
995    /// this thread. Every recursive projected-gradient repair re-enters one of
996    /// the strict-interior projection entry points, so the shared counter bounds
997    /// the whole cycle. Per-thread because each solve runs to completion on a
998    /// single call stack; independent solves on other worker threads carry their
999    /// own counter.
1000    static FEASIBILITY_REPAIR_DEPTH: Cell<u32> = const { Cell::new(0) };
1001}
1002
1003/// RAII depth counter for the feasibility-repair recursion. [`enter`] increments
1004/// the per-thread depth and returns a guard whose `Drop` restores it on every
1005/// exit path — including the projection's many `return None` branches — so the
1006/// counter can never leak. It yields `None` once
1007/// [`MAX_FEASIBILITY_REPAIR_DEPTH`] is reached, so the caller bails out of the
1008/// recursion instead of descending another level.
1009///
1010/// [`enter`]: FeasibilityRepairGuard::enter
1011struct FeasibilityRepairGuard;
1012
1013impl FeasibilityRepairGuard {
1014    fn enter() -> Option<Self> {
1015        FEASIBILITY_REPAIR_DEPTH.with(|depth| {
1016            let current = depth.get();
1017            if current >= MAX_FEASIBILITY_REPAIR_DEPTH {
1018                None
1019            } else {
1020                depth.set(current + 1);
1021                Some(Self)
1022            }
1023        })
1024    }
1025}
1026
1027impl Drop for FeasibilityRepairGuard {
1028    fn drop(&mut self) {
1029        FEASIBILITY_REPAIR_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
1030    }
1031}
1032
1033/// Project `point` to a *strictly interior* feasible point of the polyhedron
1034/// `{β : A·β ≥ b}`: the solution of `min_β ½‖β − point‖²` subject to the
1035/// margin-shifted system `A·β ≥ b + δ·‖a_i‖`, with `δ =
1036/// ACTIVE_SET_INTERIOR_SEED_MARGIN`.
1037///
1038/// This is the principled feasible cold-start seed for a shape-constrained
1039/// (convex / concave / monotone) smooth. It is qualitatively different from
1040/// `feasible_point_for_linear_constraints`, which returns the *minimum-norm*
1041/// feasible point — for a homogeneous cone (`b = 0`, as the second-difference
1042/// convexity / concavity constraints are) that minimum-norm point is the cone
1043/// **vertex** `β = 0` (a flat line) where every constraint row is tight. A
1044/// shape-constrained P-IRLS launched from that vertex hands the inner active-set
1045/// QP an all-rows-active working set (every row's slack is `0`), and the QP then
1046/// stalls on a degenerate, non-stationary face of the cone. The fit's success
1047/// then depends on whether a warm-start seed happens to drop it into the right
1048/// basin, so the same fit silently diverges (or aborts) between a cold and a
1049/// warm cache (#873).
1050///
1051/// Requiring a strictly-positive margin on every row makes the returned seed an
1052/// interior point: the QP step solver starts from an **empty** active set and
1053/// adds only the genuinely binding rows, converging to the certified constrained
1054/// stationary point regardless of cache state. The projection is the
1055/// identity-Hessian instance of [`solve_quadratic_with_linear_constraints`]
1056/// (`H = I`, `rhs = point` ⇒ minimizing `½‖β − point‖²`), so the interior seed is
1057/// also the *nearest* strictly-interior point to the supplied data-driven
1058/// `point` — it inherits whatever curvature `point` already carries. Returns
1059/// `None` if the constraints are malformed or the QP cannot certify a feasible
1060/// solution.
1061pub fn project_point_strictly_into_feasible_cone(
1062    point: &Array1<f64>,
1063    constraints: &LinearInequalityConstraints,
1064) -> Option<Array1<f64>> {
1065    // Bound the mutually-recursive `solve ↔ project` feasibility repair. Every
1066    // recursion path re-enters here, so a too-deep call returns `None` (a
1067    // degenerate cone the strictly-interior QP cannot certify) instead of
1068    // recursing until the worker stack overflows. The guard restores the
1069    // per-thread depth on every early return via its `Drop`.
1070    let repair_guard = FeasibilityRepairGuard::enter()?;
1071    let p = point.len();
1072    let m = constraints.a.nrows();
1073    if constraints.a.ncols() != p || m == 0 || constraints.b.len() != m {
1074        return None;
1075    }
1076    let norms: Vec<f64> = (0..m)
1077        .map(|i| constraints.a.row(i).dot(&constraints.a.row(i)).sqrt())
1078        .collect();
1079
1080    // Classify rows. An *anti-parallel pair* with ~zero scaled feasible-slab
1081    // width is an EQUALITY `rᵀβ = t` encoded as `{rᵀβ ≥ t, −rᵀβ ≥ −t}` (the
1082    // canonical encoding emitted by a clamped / anchored boundary condition).
1083    // Representing an equality as two opposing inequalities makes the inequality
1084    // active-set QP CYCLE: it adds one side, the equality-split multiplier turns
1085    // the other negative, it removes it, and the working set repeats until cycle
1086    // detection aborts the solve — so the projection would fail and the caller
1087    // would fall back to the cone vertex, silently reintroducing the #873 seed
1088    // for the *combined* case (`shape=concave`/`convex` with `bc=clamped`). So we
1089    // lift such pairs out as genuine equalities, eliminate them through the null
1090    // space, and run the strictly-interior QP only on the one-sided rows. A pure
1091    // shape cone has no anti-parallel rows, so `equality_rows` is empty and this
1092    // reduces to the original single-QP path verbatim.
1093    const ANTIPARALLEL_COS_TOL: f64 = -1.0 + 1e-9;
1094    const EQUALITY_WIDTH_TOL: f64 = 1e-9;
1095    let mut is_equality_member = vec![false; m];
1096    let mut equality_rows: Vec<usize> = Vec::new();
1097    let mut margin = vec![ACTIVE_SET_INTERIOR_SEED_MARGIN; m];
1098    for i in 0..m {
1099        if norms[i] == 0.0 {
1100            margin[i] = 0.0;
1101            continue;
1102        }
1103        for j in (i + 1)..m {
1104            if norms[j] == 0.0 {
1105                continue;
1106            }
1107            let cos = constraints.a.row(i).dot(&constraints.a.row(j)) / (norms[i] * norms[j]);
1108            if cos > ANTIPARALLEL_COS_TOL {
1109                continue;
1110            }
1111            // Anti-parallel rows â and −â: row i is `âᵀβ ≥ b_i/‖a_i‖`, row j is
1112            // `âᵀβ ≤ −b_j/‖a_j‖`. Scaled feasible-slab width:
1113            let width = -constraints.b[j] / norms[j] - constraints.b[i] / norms[i];
1114            if width.abs() <= EQUALITY_WIDTH_TOL {
1115                // Zero width ⇒ equality. Record it once (row i's orientation) and
1116                // exclude both rows from the one-sided interior shift.
1117                if !is_equality_member[i] && !is_equality_member[j] {
1118                    equality_rows.push(i);
1119                }
1120                is_equality_member[i] = true;
1121                is_equality_member[j] = true;
1122            } else {
1123                // Genuine (wide) two-sided bound: cap each side's inward shift at
1124                // `w/3` so the shifted slab `s_i + s_j ≤ w` stays non-empty.
1125                let cap = (width / 3.0).max(0.0);
1126                margin[i] = margin[i].min(cap);
1127                margin[j] = margin[j].min(cap);
1128            }
1129        }
1130    }
1131
1132    // One-sided rows (everything not lifted into an equality), shifted strictly
1133    // inward by `margin·‖a‖`.
1134    let ineq_rows: Vec<usize> = (0..m).filter(|&i| !is_equality_member[i]).collect();
1135    let mut a_ineq = Array2::<f64>::zeros((ineq_rows.len(), p));
1136    let mut b_ineq = Array1::<f64>::zeros(ineq_rows.len());
1137    for (r, &i) in ineq_rows.iter().enumerate() {
1138        a_ineq.row_mut(r).assign(&constraints.a.row(i));
1139        b_ineq[r] = constraints.b[i] + margin[i] * norms[i];
1140    }
1141
1142    let beta = if equality_rows.is_empty() {
1143        // No equalities: the original single strictly-interior QP
1144        // (`min ½‖β − point‖²` s.t. the margin-shifted one-sided rows).
1145        let interior = LinearInequalityConstraints::new(a_ineq, b_ineq)
1146            .expect("shifted interior constraint shape invariant");
1147        let identity = Array2::<f64>::eye(p);
1148        solve_quadratic_with_linear_constraints(&identity, point, point, &interior, None)
1149            .ok()?
1150            .0
1151    } else {
1152        // Eliminate `E β = e` through its null space. From the thin SVD
1153        // `E = U Σ Vᵀ` (rank `r`): the row space is `span(v_0..v_{r-1})`, the
1154        // minimum-norm particular solution is `β_p = Σ_{i<r} (uᵢᵀe / σᵢ) vᵢ`, and
1155        // an orthonormal null basis `Z` (p × (p−r)) is the complement of the row
1156        // space (built by Gram-Schmidt of the standard axes — `p` is a single
1157        // smooth-term width, so this is cheap and exact). Writing `β = β_p + Z u`
1158        // and using `ZᵀZ = I`, the projection becomes the reduced strictly-
1159        // interior QP `min ½‖u − Zᵀ(point − β_p)‖²` s.t. `(A_ineq Z) u ≥ b_ineq −
1160        // A_ineq β_p`, whose rows carry no anti-parallel pair, so it can't cycle.
1161        let k = equality_rows.len();
1162        let mut e_mat = Array2::<f64>::zeros((k, p));
1163        let mut e_rhs = Array1::<f64>::zeros(k);
1164        for (r, &i) in equality_rows.iter().enumerate() {
1165            e_mat.row_mut(r).assign(&constraints.a.row(i));
1166            e_rhs[r] = constraints.b[i];
1167        }
1168        let (u_opt, sing, vt_opt) = e_mat.svd(true, true).ok()?;
1169        let (u_mat, vt) = (u_opt?, vt_opt?);
1170        let smax = sing.iter().fold(0.0_f64, |acc, &v| acc.max(v));
1171        let rank_tol = smax.max(1.0) * (k.max(p) as f64) * f64::EPSILON * 100.0;
1172        let rank = sing.iter().filter(|&&s| s > rank_tol).count();
1173        if rank == 0 || rank >= p {
1174            return None;
1175        }
1176        let mut beta_p = Array1::<f64>::zeros(p);
1177        for idx in 0..rank {
1178            let coeff = u_mat.column(idx).dot(&e_rhs) / sing[idx];
1179            beta_p.scaled_add(coeff, &vt.row(idx));
1180        }
1181        // Orthonormal null basis: Gram-Schmidt the standard axes against the row
1182        // space `vt[0..rank]` and the null vectors collected so far.
1183        let mut basis: Vec<Array1<f64>> = (0..rank).map(|i| vt.row(i).to_owned()).collect();
1184        let mut z = Array2::<f64>::zeros((p, p - rank));
1185        let mut collected = 0usize;
1186        for axis in 0..p {
1187            if collected == p - rank {
1188                break;
1189            }
1190            let mut v = Array1::<f64>::zeros(p);
1191            v[axis] = 1.0;
1192            for q in basis.iter() {
1193                let c = q.dot(&v);
1194                v.scaled_add(-c, q);
1195            }
1196            let nrm = v.dot(&v).sqrt();
1197            if nrm > 1e-8 {
1198                v /= nrm;
1199                z.column_mut(collected).assign(&v);
1200                basis.push(v);
1201                collected += 1;
1202            }
1203        }
1204        if collected != p - rank {
1205            return None;
1206        }
1207        let a_red = a_ineq.dot(&z);
1208        let b_red = &b_ineq - &a_ineq.dot(&beta_p);
1209        let u0 = z.t().dot(&(point - &beta_p));
1210        let reduced = LinearInequalityConstraints::new(a_red, b_red)
1211            .expect("reduced constraint shape invariant");
1212        let identity = Array2::<f64>::eye(z.ncols());
1213        let (u_sol, _active) =
1214            solve_quadratic_with_linear_constraints(&identity, &u0, &u0, &reduced, None).ok()?;
1215        &beta_p + &z.dot(&u_sol)
1216    };
1217
1218    if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
1219        return None;
1220    }
1221    // Certify against the ORIGINAL constraints: every genuine one-sided row must
1222    // clear (most of) its requested margin so the QP step solver sees no spurious
1223    // active rows; equality-pair rows need only be feasible — they are
1224    // legitimately tight.
1225    const SEED_FEASIBILITY_TOL: f64 = 1e-9;
1226    for i in 0..m {
1227        let s = scaled_constraint_slack(&beta, constraints, i);
1228        let lower = if is_equality_member[i] {
1229            -SEED_FEASIBILITY_TOL
1230        } else {
1231            0.5 * margin[i] - SEED_FEASIBILITY_TOL
1232        };
1233        if s < lower {
1234            return None;
1235        }
1236    }
1237    // All mutually-recursive `solve ↔ project` calls are complete; release the
1238    // per-thread recursion-depth guard explicitly on the success path (early
1239    // returns above release it via `Drop`). Named + dropped (not `let _guard`)
1240    // to satisfy the underscore-binding ban without changing its lifetime.
1241    drop(repair_guard);
1242    Some(beta)
1243}
1244
1245/// Per-row signed scaled slack: `(a_i·beta - b_i) / ‖a_i‖`. A degenerate row
1246/// with `‖a_i‖ = 0` carries no direction, but it is NOT free of content: for
1247/// `b_i > 0` the row `0ᵀβ ≥ b_i` is unconditionally violated (−∞ slack), and
1248/// only for `b_i ≤ 0` is it vacuously satisfied (+∞ slack). Returning zero for
1249/// both let an impossible row report zero violation and pass every gate.
1250#[inline]
1251fn scaled_constraint_slack(
1252    beta: &Array1<f64>,
1253    constraints: &LinearInequalityConstraints,
1254    i: usize,
1255) -> f64 {
1256    let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1257    if norm > 0.0 {
1258        (constraints.a.row(i).dot(beta) - constraints.b[i]) / norm
1259    } else if constraints.b[i] > 0.0 {
1260        f64::NEG_INFINITY
1261    } else {
1262        f64::INFINITY
1263    }
1264}
1265
1266struct ActiveEqualityResidualCertificate {
1267    worst_row: usize,
1268    residual: f64,
1269    allowed: f64,
1270}
1271
1272impl ActiveEqualityResidualCertificate {
1273    fn is_certified(&self) -> bool {
1274        self.residual.is_finite() && self.allowed.is_finite() && self.residual <= self.allowed
1275    }
1276}
1277
1278/// Certify an active affine face in the normalized geometry in which it was
1279/// solved.
1280///
1281/// The bottom block is `a_i' d = r_i`. Its representable residual is governed
1282/// by the standard length-`p` dot-product roundoff bound
1283///
1284/// `gamma_(p+1) * (sum_j |a_ij d_j| + |r_i|)`,
1285///
1286/// where the extra operation is the final subtraction. This is a forward
1287/// equality certificate, not only the normwise backward-error certificate for
1288/// the whole (potentially very stiff) saddle system: an O(1e-8) equality drift
1289/// can be backward-stable against a huge Hessian block while still moving the
1290/// constrained quadratic by O(1e-3).
1291///
1292/// The bound also carries the scale at which `direction` was COMPUTED, not only
1293/// the scale of the products this row happens to sum. `direction` comes out of a
1294/// linear solve, so each component carries an absolute error of order
1295/// `eps·‖direction‖`, never `eps·|d_j|`: cancellation inside one row does not buy
1296/// that row a smaller input error. Bounding by `sum_j |a_ij d_j|` alone makes the
1297/// tolerance shrink with exactly the cancellation it exists to tolerate, and on a
1298/// degenerate face it shrinks below anything f64 can deliver. A factored cone
1299/// reaches that face routinely — when one coefficient block goes to zero, every
1300/// observation row over that block becomes tight while its products underflow, so
1301/// the row-local scale is ~1e-65 and the certificate demands an equality residual
1302/// no arithmetic can produce. The `‖a_i‖₁·‖direction‖_∞` term is that floor.
1303fn certify_active_equalities(
1304    active_a: &Array2<f64>,
1305    rhs: &Array1<f64>,
1306    direction: &Array1<f64>,
1307) -> ActiveEqualityResidualCertificate {
1308    let p = active_a.ncols();
1309    let m = active_a.nrows();
1310    let operations = p.saturating_add(1).max(1);
1311    let roundoff = operations as f64 * f64::EPSILON;
1312    let gamma = roundoff / (1.0 - roundoff);
1313    let direction_scale = direction
1314        .iter()
1315        .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1316    let mut worst = ActiveEqualityResidualCertificate {
1317        worst_row: 0,
1318        residual: 0.0,
1319        allowed: f64::MIN_POSITIVE,
1320    };
1321    let mut worst_ratio = 0.0_f64;
1322    for active_row in 0..m {
1323        let mut dot = KahanSum::default();
1324        let mut magnitude = KahanSum::default();
1325        let mut row_magnitude = KahanSum::default();
1326        for column in 0..p {
1327            let entry = active_a[[active_row, column]];
1328            let product = entry * direction[column];
1329            dot.add(product);
1330            magnitude.add(product.abs());
1331            row_magnitude.add(entry.abs());
1332        }
1333        let residual = (rhs[active_row] - dot.sum()).abs();
1334        let solve_scale = row_magnitude.sum() * direction_scale;
1335        let allowed = (gamma * (magnitude.sum() + rhs[active_row].abs() + solve_scale))
1336            .max(f64::MIN_POSITIVE);
1337        if !residual.is_finite() || !allowed.is_finite() {
1338            return ActiveEqualityResidualCertificate {
1339                worst_row: active_row,
1340                residual,
1341                allowed,
1342            };
1343        }
1344        let ratio = residual / allowed;
1345        if ratio > worst_ratio {
1346            worst_ratio = ratio;
1347            worst = ActiveEqualityResidualCertificate {
1348                worst_row: active_row,
1349                residual,
1350                allowed,
1351            };
1352        }
1353    }
1354    worst
1355}
1356
1357/// Compute `rhs - A * direction` with compensated row reductions.
1358fn compensated_active_residual(
1359    active_a: &Array2<f64>,
1360    rhs: &Array1<f64>,
1361    direction: &Array1<f64>,
1362) -> Array1<f64> {
1363    Array1::from_shape_fn(active_a.nrows(), |row| {
1364        let mut dot = KahanSum::default();
1365        for column in 0..active_a.ncols() {
1366            dot.add(active_a[[row, column]] * direction[column]);
1367        }
1368        rhs[row] - dot.sum()
1369    })
1370}
1371
1372fn minimum_norm_from_svd(
1373    u: &Array2<f64>,
1374    singular: &Array1<f64>,
1375    vt: &Array2<f64>,
1376    rank: usize,
1377    rhs: &Array1<f64>,
1378) -> Array1<f64> {
1379    let mut solution = Array1::<f64>::zeros(vt.ncols());
1380    for index in 0..rank {
1381        let coefficient = u.column(index).dot(rhs) / singular[index];
1382        solution.scaled_add(coefficient, &vt.row(index));
1383    }
1384    solution
1385}
1386
1387fn transposed_minimum_norm_from_svd(
1388    u: &Array2<f64>,
1389    singular: &Array1<f64>,
1390    vt: &Array2<f64>,
1391    rank: usize,
1392    rhs: &Array1<f64>,
1393) -> Array1<f64> {
1394    let mut solution = Array1::<f64>::zeros(u.nrows());
1395    for index in 0..rank {
1396        let coefficient = vt.row(index).dot(rhs) / singular[index];
1397        solution.scaled_add(coefficient, &u.column(index));
1398    }
1399    solution
1400}
1401
1402/// Solve the equality-constrained strictly-convex quadratic
1403///
1404/// `min_d 1/2 d' H d + g' d  subject to A d = r`
1405///
1406/// in an orthonormal null-space coordinate system.
1407///
1408/// The bordered KKT representation `[H A'; A 0]` mixes the scale of a stiff
1409/// positive-definite metric with unit-normalized active equations in one
1410/// indefinite factor. On the #979 CTN face that finite LBLT answer missed an
1411/// active equation by `6.384e-4`, and even its residual-correction solve became
1412/// non-finite. The null-space representation never forms that saddle matrix:
1413///
1414/// * normalize each active equation;
1415/// * use a thin SVD `A = U S V'` for a minimum-norm affine point `d_p` and a
1416///   rank-revealing Householder QR of `A'` for its full orthonormal null basis
1417///   `Z`;
1418/// * solve the positive-definite reduced problem
1419///   `(Z' H Z) z = -Z' (g + H d_p)`; and
1420/// * recover active multipliers from the stationarity equation.
1421///
1422/// This is algebraically the same constrained minimizer. Rank-deficient active
1423/// equations use the RRQR rank consistently in both decompositions; an
1424/// inconsistent affine right-hand side is rejected by the forward equality
1425/// certificate rather than hidden by a pseudoinverse rank drop.
1426pub(crate) fn solve_kkt_direction(
1427    hessian: &Array2<f64>,
1428    gradient: &Array1<f64>,
1429    active_a: &Array2<f64>,
1430    active_residual: Option<&Array1<f64>>,
1431) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
1432    let p = hessian.nrows();
1433    let m = active_a.nrows();
1434    if hessian.ncols() != p || gradient.len() != p || active_a.ncols() != p {
1435        crate::bail_invalid_estim!("null-space constrained solve dimension mismatch");
1436    }
1437    if let Some(residual) = active_residual
1438        && residual.len() != m
1439    {
1440        crate::bail_invalid_estim!(
1441            "active-equality residual length mismatch: got {}, expected {}",
1442            residual.len(),
1443            m
1444        );
1445    }
1446    if m == 0 {
1447        let mut d = Array1::<f64>::zeros(p);
1448        solve_newton_direction_dense(hessian, gradient, &mut d)?;
1449        return Ok((d, Array1::zeros(0)));
1450    }
1451
1452    let mut scaled_a = active_a.clone();
1453    let mut scaled_rhs = active_residual
1454        .cloned()
1455        .unwrap_or_else(|| Array1::<f64>::zeros(m));
1456    let mut row_norms = Array1::<f64>::zeros(m);
1457    for row in 0..m {
1458        let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
1459        if !(norm.is_finite() && norm > 0.0) {
1460            crate::bail_invalid_estim!(
1461                "active equality row {row} has invalid norm {norm}"
1462            );
1463        }
1464        row_norms[row] = norm;
1465        let inverse = 1.0 / norm;
1466        scaled_a.row_mut(row).mapv_inplace(|value| value * inverse);
1467        scaled_rhs[row] *= inverse;
1468    }
1469
1470    let (u_opt, singular, vt_opt) = scaled_a.svd(true, true).map_err(|_| {
1471        EstimationError::InvalidInput(
1472            "null-space constrained quadratic active-equation SVD failed".to_string(),
1473        )
1474    })?;
1475    let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
1476        crate::bail_invalid_estim!(
1477            "null-space constrained quadratic SVD omitted singular vectors"
1478        );
1479    };
1480    let (mut null_basis, rank) =
1481        rrqr_nullspace_basis(&scaled_a.t(), default_rrqr_rank_alpha()).map_err(|_| {
1482            EstimationError::InvalidInput(
1483                "null-space constrained quadratic active-equation RRQR failed".to_string(),
1484            )
1485        })?;
1486    if rank == 0 {
1487        crate::bail_invalid_estim!(
1488            "null-space constrained quadratic active equations have numerical rank zero"
1489        );
1490    }
1491    if rank > singular.len()
1492        || !singular[rank - 1].is_finite()
1493        || singular[rank - 1] <= 0.0
1494    {
1495        crate::bail_invalid_estim!(
1496            "null-space constrained quadratic RRQR rank {rank} has no positive SVD pivot"
1497        );
1498    }
1499    let nullity = p.saturating_sub(rank);
1500    if null_basis.dim() != (p, nullity) {
1501        crate::bail_invalid_estim!(
1502            "null-space constrained quadratic RRQR basis has shape {}x{}, expected {}x{}",
1503            null_basis.nrows(),
1504            null_basis.ncols(),
1505            p,
1506            nullity,
1507        );
1508    }
1509    // Make the null basis orthogonal to the row space WITHOUT dividing by a
1510    // singular value.
1511    //
1512    // `vt.row(0..rank)` is already an ORTHONORMAL basis of `row(scaled_a)`, so
1513    // the row-space component of a column `z` is `sum_i <v_i, z> v_i` and
1514    // removing it is Gram-Schmidt against an orthonormal set. In exact
1515    // arithmetic that is the same map as the previous correction
1516    // (`V Σ⁻¹ Uᵀ (A z)` equals `V Vᵀ z`), but in floating point the two are not
1517    // remotely equal: the old form first FORMS `A z` -- catastrophic
1518    // cancellation exactly when `z` is nearly in the null space, which is the
1519    // only case that matters here -- and then multiplies by `1/σ`, amplifying
1520    // whatever survived by `1/σ_min`. On a near-rank-deficient face that is
1521    // where the basis loses its accuracy.
1522    //
1523    // This matters because the reduced Newton solve below zeroes the gradient on
1524    // `span(Z)`, while the KKT certificate measures stationarity against the
1525    // TRUE tangent, the complement of `row(A)`. If those two subspaces differ,
1526    // the solve faithfully zeroes the wrong one and the endpoint is accepted
1527    // carrying real tangent mass -- which is what #2592 measures.
1528    //
1529    // Two passes: one sweep of classical Gram-Schmidt loses orthogonality when
1530    // the input is already nearly dependent, and repeating it once is the
1531    // standard remedy ("twice is enough").
1532    for column in 0..nullity {
1533        for _ in 0..2 {
1534            let mut basis_column = null_basis.column(column).to_owned();
1535            for index in 0..rank {
1536                let projection = vt.row(index).dot(&basis_column);
1537                basis_column.scaled_add(-projection, &vt.row(index));
1538            }
1539            null_basis.column_mut(column).assign(&basis_column);
1540        }
1541        // Keep the columns unit-scaled so `ZᵀHZ` inherits `H`'s conditioning
1542        // rather than the basis's. A column that collapses under the projection
1543        // was not independent of the row space to begin with; leave it as the
1544        // rank checks above left it rather than amplifying noise.
1545        let norm = null_basis.column(column).dot(&null_basis.column(column)).sqrt();
1546        if norm.is_finite() && norm > 0.0 {
1547            null_basis.column_mut(column).mapv_inplace(|value| value / norm);
1548        }
1549    }
1550    if !array_is_finite(&null_basis) {
1551        crate::bail_invalid_estim!(
1552            "null-space constrained quadratic refined RRQR basis is non-finite"
1553        );
1554    }
1555
1556    let mut particular = minimum_norm_from_svd(&u, &singular, &vt, rank, &scaled_rhs);
1557    if !array_is_finite(&particular) {
1558        crate::bail_invalid_estim!(
1559            "null-space constrained quadratic affine solution is non-finite"
1560        );
1561    }
1562
1563    let initial_affine_residual =
1564        compensated_active_residual(&scaled_a, &scaled_rhs, &particular);
1565    let affine_correction =
1566        minimum_norm_from_svd(&u, &singular, &vt, rank, &initial_affine_residual);
1567    particular += &affine_correction;
1568
1569    let mut direction = particular.clone();
1570    if nullity > 0 {
1571        let mut reduced_hessian = null_basis.t().dot(hessian).dot(&null_basis);
1572        for row in 0..nullity {
1573            for column in (row + 1)..nullity {
1574                let average =
1575                    0.5 * (reduced_hessian[[row, column]] + reduced_hessian[[column, row]]);
1576                reduced_hessian[[row, column]] = average;
1577                reduced_hessian[[column, row]] = average;
1578            }
1579        }
1580        let affine_gradient = gradient + &hessian.dot(&particular);
1581        let reduced_rhs = -null_basis.t().dot(&affine_gradient);
1582        let factor = reduced_hessian
1583            .cholesky(Side::Lower)
1584            .map_err(EstimationError::LinearSystemSolveFailed)?;
1585        let mut reduced_solution = factor.solvevec(&reduced_rhs);
1586        if !array_is_finite(&reduced_solution) {
1587            crate::bail_invalid_estim!(
1588                "null-space constrained quadratic reduced solve is non-finite"
1589            );
1590        }
1591        // THIS solve is what makes the endpoint stationary on its face: it sets
1592        // `Zᵀ(H beta + g) = 0`, and every downstream KKT certificate depends on
1593        // it holding. Its residual was never looked at.
1594        //
1595        // `Z` comes from an RRQR null-space basis that is itself corrected in a
1596        // loop above, so `ZᵀHZ` is a product of three inexact factors; when the
1597        // face is close to rank-deficient it is ill-conditioned, and a single
1598        // Cholesky substitution returns a `w` whose reduced residual is nowhere
1599        // near roundoff. The endpoint then satisfies its active equalities
1600        // exactly (that IS certified, two blocks below) and is all-row feasible,
1601        // so the walk accepts it -- and the failure only surfaces three frames
1602        // later as "failed stationarity certification", where it reads as a
1603        // solver mystery rather than an unchecked linear solve.
1604        //
1605        // Measured (#2592): transformation-normal seed 1 reached that refusal
1606        // with `residual=1.600e1` against `gradient_scale=9.908e2`, and the
1607        // smallest residual ANY multipliers could achieve was `1.600e1` too --
1608        // i.e. the whole 1.6% lived in the face TANGENT, which is precisely the
1609        // quantity this solve is responsible for zeroing.
1610        //
1611        // One step of iterative refinement in the same factorization. It costs a
1612        // matvec and a substitution, is the standard remedy for exactly this,
1613        // and cannot move a solve that was already exact (its correction is then
1614        // zero to roundoff).
1615        let reduced_residual = &reduced_rhs - &reduced_hessian.dot(&reduced_solution);
1616        let reduced_correction = factor.solvevec(&reduced_residual);
1617        if array_is_finite(&reduced_correction) {
1618            reduced_solution += &reduced_correction;
1619        }
1620        direction += &null_basis.dot(&reduced_solution);
1621    }
1622
1623    let initial_certificate =
1624        certify_active_equalities(&scaled_a, &scaled_rhs, &direction);
1625    if !initial_certificate.is_certified() {
1626        let affine_residual =
1627            compensated_active_residual(&scaled_a, &scaled_rhs, &direction);
1628        let correction =
1629            minimum_norm_from_svd(&u, &singular, &vt, rank, &affine_residual);
1630        if !correction.iter().all(|value| value.is_finite()) {
1631            return Err(EstimationError::ParameterConstraintViolation(format!(
1632                "null-space active-equality correction produced a non-finite value \
1633                 (active_row={}, residual={:.3e}, roundoff_bound={:.3e})",
1634                initial_certificate.worst_row,
1635                initial_certificate.residual,
1636                initial_certificate.allowed,
1637            )));
1638        }
1639        direction += &correction;
1640        let refined_certificate =
1641            certify_active_equalities(&scaled_a, &scaled_rhs, &direction);
1642        if !refined_certificate.is_certified() {
1643            return Err(EstimationError::ParameterConstraintViolation(format!(
1644                "null-space active equality is unresolved after affine correction \
1645                 (active_row={}, residual={:.3e}, roundoff_bound={:.3e}; \
1646                 initial_active_row={}, initial_residual={:.3e}, \
1647                 initial_roundoff_bound={:.3e})",
1648                refined_certificate.worst_row,
1649                refined_certificate.residual,
1650                refined_certificate.allowed,
1651                initial_certificate.worst_row,
1652                initial_certificate.residual,
1653                initial_certificate.allowed,
1654            )));
1655        }
1656    }
1657
1658    let stationarity_rhs = -(gradient + &hessian.dot(&direction));
1659    let scaled_multiplier =
1660        transposed_minimum_norm_from_svd(&u, &singular, &vt, rank, &stationarity_rhs);
1661    let multiplier = &scaled_multiplier / &row_norms;
1662    if !array_is_finite(&multiplier) {
1663        crate::bail_invalid_estim!(
1664            "null-space constrained quadratic multiplier recovery is non-finite"
1665        );
1666    }
1667    Ok((direction, multiplier))
1668}
1669
1670/// One dependent row of the WORKING SET expressed against its representative:
1671/// `a_dep ≈ coeff · a_rep`.
1672///
1673/// Recorded ONLY for exactly-parallel (positively-aligned scalar-multiple)
1674/// dependents; a general-position dependent is dropped from the working set with
1675/// NO entry and re-enters via the next feasibility scan (it never receives a
1676/// distributed/phantom multiplier).
1677///
1678/// `active_pos` is an ACTIVE-SET POSITION: an index into the caller's `active`
1679/// slice, so the original constraint id is `active[active_pos]`. Working-face
1680/// rank reduction seeds each group with these positions before collapsing
1681/// dependent rows. It is NOT a constraint-row id and NOT a coefficient index.
1682/// The reduced-face op reports its dependents in constraint-row space instead
1683/// and therefore uses its own [`ConstraintRowDependence`] — the two must not be
1684/// interchanged.
1685#[derive(Clone, Copy, Debug)]
1686pub struct ActiveRowDependence {
1687    pub active_pos: usize,
1688    pub coeff: f64,
1689}
1690
1691/// One tight row of a REDUCED FACE expressed against its representative:
1692/// `a_dep ≈ coeff · a_rep`, with the dependent named in constraint-row space.
1693///
1694/// Same `(A)`-strict recording rule as [`ActiveRowDependence`], different index
1695/// space: `row` is the dependent's [`ConstraintRowId`] in the reduced set's own
1696/// row space. The representative is identified by the index of the owning
1697/// [`ReducedFace::dependence`] slot, which is aligned with
1698/// [`ReducedFace::representatives`].
1699#[derive(Clone, Copy, Debug)]
1700pub struct ConstraintRowDependence {
1701    pub row: ConstraintRowId,
1702    pub coeff: f64,
1703}
1704
1705/// The result of reducing a tight active face to a minimal independent set — the
1706/// shared output of the `ConstraintSet` reduced-face op (Dense arm =
1707/// [`dense_reduced_face`]; KhatriRaoCone / BlockDiagonal arms produce the same
1708/// shape). Determinism: representatives are the lowest-flat-index row per
1709/// independent direction, ascending, with no float tie-break.
1710///
1711/// INDEX SPACE: every id here is a [`ConstraintRowId`] in the reduced set's own
1712/// constraint-row space (`0..nrows()`), addressing `values()` / `bound()` /
1713/// `row_norm()`. It is NOT a coefficient index; to reach β coordinates go
1714/// through `gam_problem::ConstraintSet::row_column_support`.
1715#[derive(Clone, Debug)]
1716pub struct ReducedFace {
1717    /// Kept independent rows — the lowest-flat-index representative per direction,
1718    /// ascending. Flat id space is `0..nrows` (Dense) / `slot*n + obs` (cone) /
1719    /// the concatenation of the member row spaces (block-diagonal).
1720    pub representatives: Vec<ConstraintRowId>,
1721    /// Per-representative parallel-dependent map, index-aligned with
1722    /// `representatives`. `dependence[i]` lists the exactly-parallel dependents of
1723    /// `representatives[i]` (empty when it has none); general-position dependents
1724    /// are absent (dropped, re-enter on the next feasibility scan).
1725    pub dependence: Vec<Vec<ConstraintRowDependence>>,
1726    /// The full tight set that was reduced, ascending flat ids.
1727    pub tight_rows: Vec<ConstraintRowId>,
1728}
1729
1730/// Reduce the tight active face of a Khatri–Rao monotonicity cone to its minimal
1731/// independent set — the `KhatriRaoCone` arm of the `ConstraintSet` reduced-face
1732/// op (gam#2306; the Dense arm is [`dense_reduced_face`]).
1733///
1734/// A cone row `(slot, i)` has normal `e_{k} ⊗ ψ_i` (`k = coupled_rows[slot]`),
1735/// so two normals' inner product is `δ_{slot,slot'}·(ψ_iᵀ ψ_{i'})`: cross-block
1736/// normals are ALWAYS orthogonal and never redundant, and redundancy occurs only
1737/// WITHIN a shape block among linearly dependent covariate rows `ψ_i`. The
1738/// reduction therefore decomposes into independent per-block Gram–Schmidt scans
1739/// over the block's tight `ψ_i` rows — never forming the `n·|coupled|` system.
1740///
1741/// Contract (matches the Dense arm): FULL rank cut (every dependent row is
1742/// dropped from `representatives`, parallel OR general-position); the dependence
1743/// map records `(A)`-strict — ONLY exactly-parallel dependents
1744/// (`|cos(ψ_dep, ψ_rep)| ≥ 1 − 1e-9`) get a [`ConstraintRowDependence`] against their
1745/// single representative (`coeff = ψ_depᵀψ_rep / ‖ψ_rep‖²`, so `a_dep ≈ coeff·a_rep`);
1746/// general-position drops get no entry and re-enter via the next feasibility
1747/// scan. Representatives are the lowest-flat-index row per direction (ascending
1748/// obs within a block), host-deterministic with no float tie-break. The rank
1749/// tolerance mirrors the Dense scan (`100·ε·max(n_tight, p_cov)·max‖ψ‖`), so the
1750/// two arms cut to the same numerical rank. Flat id is `slot*n + obs`, matching
1751/// [`KhatriRaoConeConstraints::values`].
1752pub fn khatri_rao_cone_reduced_face(
1753    cone: &KhatriRaoConeConstraints,
1754    beta: ndarray::ArrayView1<'_, f64>,
1755    membership_tol: f64,
1756) -> Result<ReducedFace, EstimationError> {
1757    let psi = cone.factor();
1758    let n = psi.nrows();
1759    let p_cov = psi.ncols();
1760    let coupled = cone.coupled_rows();
1761    let values = cone.values(beta).map_err(|error| {
1762        EstimationError::ParameterConstraintViolation(format!(
1763            "Khatri-Rao cone reduced-face values: {error}"
1764        ))
1765    })?;
1766
1767    // ‖ψ_i‖ is shared across coupled slots (the same covariate factor).
1768    let row_norms: Vec<f64> = (0..n)
1769        .map(|i| {
1770            let row = psi.row(i);
1771            row.dot(&row).sqrt()
1772        })
1773        .collect();
1774
1775    const RANK_ALPHA: f64 = 100.0;
1776    // Exactly-parallel threshold, matching the Dense scan's ±1e-9 cosine band.
1777    const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1778
1779    let mut representatives: Vec<ConstraintRowId> = Vec::new();
1780    let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1781    let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
1782
1783    for slot in 0..coupled.len() {
1784        // Tight obs in this block, ascending. A zero-norm ψ_i is a vacuous row
1785        // (0ᵀβ ≥ 0 always holds) — never a constraint direction, never a rep.
1786        let mut tight_obs: Vec<usize> = Vec::new();
1787        for i in 0..n {
1788            let norm_i = row_norms[i];
1789            if norm_i <= 0.0 {
1790                continue;
1791            }
1792            let scaled_slack = values[slot * n + i] / norm_i;
1793            if scaled_slack <= membership_tol {
1794                tight_rows.push(ConstraintRowId(slot * n + i));
1795                tight_obs.push(i);
1796            }
1797        }
1798        if tight_obs.is_empty() {
1799            continue;
1800        }
1801
1802        let max_norm = tight_obs
1803            .iter()
1804            .map(|&i| row_norms[i])
1805            .fold(0.0_f64, f64::max);
1806        let rank_tol =
1807            RANK_ALPHA * f64::EPSILON * (tight_obs.len().max(p_cov).max(1) as f64) * max_norm;
1808
1809        let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1810        // Kept representatives in THIS block: (obs, ψ_obs, index into representatives).
1811        let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1812        for &i in &tight_obs {
1813            let psi_i = psi.row(i).to_owned();
1814            let mut resid = psi_i.clone();
1815            for q in &ortho_basis {
1816                let proj = resid.dot(q);
1817                resid.scaled_add(-proj, q);
1818            }
1819            let resid_norm = resid.dot(&resid).sqrt();
1820            let flat = ConstraintRowId(slot * n + i);
1821            if resid_norm > rank_tol {
1822                ortho_basis.push(&resid / resid_norm);
1823                let out_idx = representatives.len();
1824                representatives.push(flat);
1825                dependence.push(Vec::new());
1826                kept.push((i, psi_i, out_idx));
1827            } else {
1828                // (A)-strict: record ONLY an exactly-parallel single-representative
1829                // dependence; general-position drops carry no multiplier.
1830                let mut best_abs_cos = 0.0_f64;
1831                let mut best: Option<(usize, f64)> = None;
1832                for (rep_obs, rep_psi, rep_out_idx) in &kept {
1833                    let rep_norm = row_norms[*rep_obs];
1834                    let dot = psi_i.dot(rep_psi);
1835                    let cos = if rep_norm > 0.0 {
1836                        dot / (row_norms[i] * rep_norm)
1837                    } else {
1838                        0.0
1839                    };
1840                    if cos.abs() > best_abs_cos {
1841                        best_abs_cos = cos.abs();
1842                        best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1843                    }
1844                }
1845                if best_abs_cos >= PARALLEL_COS_TOL {
1846                    if let Some((out_idx, coeff)) = best {
1847                        dependence[out_idx].push(ConstraintRowDependence {
1848                            row: flat,
1849                            coeff,
1850                        });
1851                    }
1852                }
1853            }
1854        }
1855    }
1856
1857    Ok(ReducedFace {
1858        representatives,
1859        dependence,
1860        tight_rows,
1861    })
1862}
1863
1864/// Dense arm of the reduced-face op: reduce the tight rows of an explicit
1865/// `A x ≥ b` set at `beta` to a minimal independent set. Mirrors
1866/// [`khatri_rao_cone_reduced_face`] exactly — ascending-index greedy MGS,
1867/// `RANK_ALPHA·ε·max(n_tight,p)·max‖a‖` tolerance, (A)-strict parallel-only
1868/// dependence (|cos| ≥ 1−1e-9, `coeff = a_depᵀa_rep/‖a_rep‖²`, `row` = the
1869/// dependent row's flat id) — so both carriers produce the same `ReducedFace`
1870/// contract. Flat id = the constraint row index. A zero-norm row is vacuous
1871/// (never a direction, never a representative).
1872pub fn dense_reduced_face(
1873    lin: &LinearInequalityConstraints,
1874    beta: ndarray::ArrayView1<'_, f64>,
1875    membership_tol: f64,
1876) -> Result<ReducedFace, EstimationError> {
1877    let a = &lin.a;
1878    let b = &lin.b;
1879    let n = a.nrows();
1880    let p = a.ncols();
1881
1882    let row_norms: Vec<f64> = (0..n)
1883        .map(|i| {
1884            let row = a.row(i);
1885            row.dot(&row).sqrt()
1886        })
1887        .collect();
1888
1889    const RANK_ALPHA: f64 = 100.0;
1890    const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1891
1892    // The scan runs in raw row indices (they address `a` / `row_norms`); the
1893    // ids are wrapped into constraint-row space once, at the return boundary.
1894    let mut tight: Vec<usize> = Vec::new();
1895    for i in 0..n {
1896        let norm_i = row_norms[i];
1897        if norm_i <= 0.0 {
1898            continue;
1899        }
1900        let scaled_slack = (a.row(i).dot(&beta) - b[i]) / norm_i;
1901        if scaled_slack <= membership_tol {
1902            tight.push(i);
1903        }
1904    }
1905
1906    let mut representatives: Vec<ConstraintRowId> = Vec::new();
1907    let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1908    if tight.is_empty() {
1909        return Ok(ReducedFace {
1910            representatives,
1911            dependence,
1912            tight_rows: Vec::new(),
1913        });
1914    }
1915
1916    let max_norm = tight
1917        .iter()
1918        .map(|&i| row_norms[i])
1919        .fold(0.0_f64, f64::max);
1920    let rank_tol = RANK_ALPHA * f64::EPSILON * (tight.len().max(p).max(1) as f64) * max_norm;
1921
1922    let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1923    // Kept representatives: (row, a_row, index into `representatives`).
1924    let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1925    for &i in &tight {
1926        let a_i = a.row(i).to_owned();
1927        let mut resid = a_i.clone();
1928        for q in &ortho_basis {
1929            let proj = resid.dot(q);
1930            resid.scaled_add(-proj, q);
1931        }
1932        let resid_norm = resid.dot(&resid).sqrt();
1933        if resid_norm > rank_tol {
1934            ortho_basis.push(&resid / resid_norm);
1935            let out_idx = representatives.len();
1936            representatives.push(ConstraintRowId(i));
1937            dependence.push(Vec::new());
1938            kept.push((i, a_i, out_idx));
1939        } else {
1940            // (A)-strict: record ONLY an exactly-parallel single-representative
1941            // dependence; a general-position drop carries no multiplier and
1942            // re-enters via the next feasibility scan.
1943            let mut best_abs_cos = 0.0_f64;
1944            let mut best: Option<(usize, f64)> = None;
1945            for (rep_row, rep_a, rep_out_idx) in &kept {
1946                let rep_norm = row_norms[*rep_row];
1947                let dot = a_i.dot(rep_a);
1948                let cos = if rep_norm > 0.0 {
1949                    dot / (row_norms[i] * rep_norm)
1950                } else {
1951                    0.0
1952                };
1953                if cos.abs() > best_abs_cos {
1954                    best_abs_cos = cos.abs();
1955                    best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1956                }
1957            }
1958            if best_abs_cos >= PARALLEL_COS_TOL {
1959                if let Some((out_idx, coeff)) = best {
1960                    dependence[out_idx].push(ConstraintRowDependence {
1961                        row: ConstraintRowId(i),
1962                        coeff,
1963                    });
1964                }
1965            }
1966        }
1967    }
1968
1969    Ok(ReducedFace {
1970        representatives,
1971        dependence,
1972        tight_rows: tight.into_iter().map(ConstraintRowId).collect(),
1973    })
1974}
1975
1976/// Lift a MEMBER's constraint-row id into the JOINT block-diagonal row space.
1977///
1978/// Derivation: `ConstraintSet::BlockDiagonal` stacks its members' constraint
1979/// ROWS in block order — `ConstraintSet::values` writes member `m`'s values into
1980/// `out[off .. off + m.nrows()]`, and `bound` / `row_norm` decode a joint row by
1981/// walking the same running `nrows()` sum (`block_for_row`). So the joint id of
1982/// member row `local` is `local + Σ_{earlier m} m.nrows()`, which is what
1983/// `row_offset` accumulates.
1984///
1985/// The offset is deliberately NOT `col_start`. That is the COEFFICIENT offset,
1986/// and it advances by `ncols()`. Using one for the other is only invisible while
1987/// every member is square (`nrows() == ncols()`); the moment a member constrains
1988/// fewer rows than it has coefficients, the two sequences diverge and the ids
1989/// silently name the wrong block. To go from these ids to β coordinates, use
1990/// `ConstraintSet::row_column_support` — never arithmetic on the id.
1991#[inline]
1992fn lift_member_row(local: ConstraintRowId, row_offset: usize) -> ConstraintRowId {
1993    ConstraintRowId(local.index() + row_offset)
1994}
1995
1996/// The shared tight-face reduction op over the `ConstraintSet` carrier union.
1997/// An extension trait (not an inherent method) so the numeric reduction
1998/// stays in gam-solve where the solvers consume it, keeping `gam-problem` a pure
1999/// data crate. All three arms produce the same `ReducedFace` contract.
2000pub trait ConstraintSetReducedFace {
2001    fn reduced_face(
2002        &self,
2003        beta: ndarray::ArrayView1<'_, f64>,
2004        membership_tol: f64,
2005    ) -> Result<ReducedFace, EstimationError>;
2006}
2007
2008impl ConstraintSetReducedFace for ConstraintSet {
2009    fn reduced_face(
2010        &self,
2011        beta: ndarray::ArrayView1<'_, f64>,
2012        membership_tol: f64,
2013    ) -> Result<ReducedFace, EstimationError> {
2014        match self {
2015            ConstraintSet::Dense(lin) => dense_reduced_face(lin, beta, membership_tol),
2016            ConstraintSet::KhatriRaoCone(cone) => {
2017                khatri_rao_cone_reduced_face(cone, beta, membership_tol)
2018            }
2019            ConstraintSet::BlockDiagonal { blocks, .. } => {
2020                // Compose per inner block. TWO independent offsets are in play and
2021                // they are NOT interchangeable:
2022                //   * `block.col_start` slices β — COEFFICIENT space, advancing by
2023                //     each member's `ncols()`;
2024                //   * `row_offset` lifts the returned ids — CONSTRAINT-ROW space,
2025                //     advancing by each member's `nrows()`.
2026                // They coincide only when every member is a square carrier, which
2027                // is why a mixed block (a constrained sub-basis alongside
2028                // unconstrained intercept/covariate columns, `nrows() < ncols()`)
2029                // is the case that separates them. See `lift_member_row`.
2030                let mut representatives: Vec<ConstraintRowId> = Vec::new();
2031                let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
2032                let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
2033                let mut row_offset = 0usize;
2034                for block in blocks {
2035                    let start = block.col_start;
2036                    let end = start + block.set.ncols();
2037                    let beta_block = beta.slice(ndarray::s![start..end]);
2038                    let sub = block.set.reduced_face(beta_block, membership_tol)?;
2039                    for r in sub.representatives {
2040                        representatives.push(lift_member_row(r, row_offset));
2041                    }
2042                    for deps in sub.dependence {
2043                        dependence.push(
2044                            deps.into_iter()
2045                                .map(|d| ConstraintRowDependence {
2046                                    row: lift_member_row(d.row, row_offset),
2047                                    coeff: d.coeff,
2048                                })
2049                                .collect(),
2050                        );
2051                    }
2052                    for t in sub.tight_rows {
2053                        tight_rows.push(lift_member_row(t, row_offset));
2054                    }
2055                    row_offset += block.set.nrows();
2056                }
2057                Ok(ReducedFace {
2058                    representatives,
2059                    dependence,
2060                    tight_rows,
2061                })
2062            }
2063        }
2064    }
2065}
2066
2067fn identity_multiplier_dependence(groups: &[Vec<usize>]) -> Vec<Vec<ActiveRowDependence>> {
2068    groups
2069        .iter()
2070        .map(|group| {
2071            group
2072                .iter()
2073                .copied()
2074                .map(|active_pos| ActiveRowDependence {
2075                    active_pos,
2076                    coeff: 1.0,
2077                })
2078                .collect()
2079        })
2080        .collect()
2081}
2082
2083pub fn rank_reduce_rows_pivoted_qr_with_dependence(
2084    a: Array2<f64>,
2085    b: Array1<f64>,
2086    groups: Vec<Vec<usize>>,
2087) -> (
2088    Array2<f64>,
2089    Array1<f64>,
2090    Vec<Vec<usize>>,
2091    Vec<Vec<ActiveRowDependence>>,
2092) {
2093    let k = a.nrows();
2094    let p = a.ncols();
2095    if k <= 1 {
2096        let multiplier_dependence = identity_multiplier_dependence(&groups);
2097        return (a, b, groups, multiplier_dependence);
2098    }
2099
2100    // DETERMINISTIC, host-independent representative selection. The former faer
2101    // `col_piv_qr` pivots by largest column norm; on an equal-norm tie
2102    // (near-parallel / identical active rows — the degenerate-face case) faer's
2103    // internal tie-break can differ across CPU arch / SIMD width / library
2104    // version, which would record a host-dependent active face and re-introduce
2105    // the nondeterministic cross-host certification the face feeds. Instead do a
2106    // greedy ASCENDING-original-index independence scan: iterate rows in index
2107    // order and keep row r iff its residual after projecting onto the orthonormal
2108    // span of the already-kept rows exceeds the rank tolerance; otherwise record
2109    // it dependent. This yields the lowest-index representative per independent
2110    // direction with no float-comparison tie-break.
2111    //
2112    // Rank tolerance is relative to the largest row norm — the same |R00| scale
2113    // (= largest column norm of Aᵀ = largest row norm of A) the pivoted QR used —
2114    // so the accepted COUNT matches the prior numerical rank; only WHICH
2115    // representative is chosen among tied near-parallel rows changes, and it
2116    // changes deterministically. The scale carries NO absolute floor, preserving
2117    // the unit-robustness of the prior tolerance (a perfectly independent system
2118    // in tiny units, e.g. A = 1e-20·I, keeps full rank rather than being dropped).
2119    const RANK_ALPHA: f64 = 100.0;
2120    let max_row_norm = (0..k)
2121        .map(|r| {
2122            let row = a.row(r);
2123            row.dot(&row).sqrt()
2124        })
2125        .fold(0.0_f64, f64::max);
2126    let tol = RANK_ALPHA * f64::EPSILON * (k.max(p).max(1) as f64) * max_row_norm;
2127
2128    let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
2129    let mut kept_orig: Vec<usize> = Vec::new();
2130    let mut dropped_orig: Vec<usize> = Vec::new();
2131    for r in 0..k {
2132        let mut resid = a.row(r).to_owned();
2133        // TWO passes, and the second one is the rank decision (gam#2600).
2134        //
2135        // A single modified-Gram-Schmidt sweep leaves the computed residual
2136        // contaminated by the accumulated loss of orthogonality in
2137        // `ortho_basis`, which is `O(eps * cond(A_kept))` — NOT `O(eps)`. On an
2138        // ill-conditioned face that contamination is larger than `tol`, so a
2139        // row that is genuinely in the span of the kept rows reports a residual
2140        // above the floor and is kept as "independent". The face then carries a
2141        // numerically redundant row, and every consumer that factors it
2142        // afterwards disagrees with this scan about the rank.
2143        //
2144        // Measured on #2600's `transformation_normal_held_out_pit_is_uniform`
2145        // fixture: 90 reduced-face solves where the block this scan returned had
2146        // `nrows = svd_rank + 1`, EVERY time — `face_rows=39, rank=38` with the
2147        // retained singular values down to `3.86e-5` and the 39th at `~1e-15`,
2148        // against this scan's `tol = 1.07e-12`. `cond(A_kept) ~ 6e4` puts the
2149        // single-pass contamination at `~1.3e-11`, an order above `tol`, which
2150        // is exactly the observed miss. The physical reduced face then handed
2151        // that block to an SVD that refused it as a singular affine system and
2152        // ended the whole fit.
2153        //
2154        // Reorthogonalizing once ("twice is enough", Kahan) drops the residual
2155        // error back to `O(eps)*||a_r||`, so this scan's rank and a
2156        // rank-revealing factorization of the same block agree. It is the same
2157        // deliberate second pass `active_constraint_tangent_geometry` already
2158        // runs when it completes a null basis, for the same reason.
2159        for _ in 0..2 {
2160            for q in &ortho_basis {
2161                let proj = resid.dot(q);
2162                resid.scaled_add(-proj, q);
2163            }
2164        }
2165        let resid_norm = resid.dot(&resid).sqrt();
2166        if resid_norm > tol {
2167            kept_orig.push(r);
2168            ortho_basis.push(&resid / resid_norm);
2169        } else {
2170            dropped_orig.push(r);
2171        }
2172    }
2173    let rank = kept_orig.len();
2174    if rank >= k {
2175        let multiplier_dependence = identity_multiplier_dependence(&groups);
2176        return (a, b, groups, multiplier_dependence);
2177    }
2178    if rank == 0 {
2179        log::debug!(
2180            "rank-reduced active constraints from {} to 0 rows (all active rows numerically zero)",
2181            k
2182        );
2183        return (
2184            Array2::<f64>::zeros((0, p)),
2185            Array1::<f64>::zeros(0),
2186            Vec::new(),
2187            Vec::new(),
2188        );
2189    }
2190
2191    let mut orig_to_out = std::collections::HashMap::with_capacity(rank);
2192    let mut a_out = Array2::<f64>::zeros((rank, p));
2193    let mut b_out = Array1::<f64>::zeros(rank);
2194    let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(rank);
2195    let mut multiplier_dependence: Vec<Vec<ActiveRowDependence>> = Vec::with_capacity(rank);
2196    for (out_idx, &orig_idx) in kept_orig.iter().enumerate() {
2197        a_out.row_mut(out_idx).assign(&a.row(orig_idx));
2198        b_out[out_idx] = b[orig_idx];
2199        groups_out.push(groups[orig_idx].clone());
2200        multiplier_dependence.push(
2201            groups[orig_idx]
2202                .iter()
2203                .copied()
2204                .map(|active_pos| ActiveRowDependence {
2205                    active_pos,
2206                    coeff: 1.0,
2207                })
2208                .collect(),
2209        );
2210        orig_to_out.insert(orig_idx, out_idx);
2211    }
2212
2213    // (A)-strict merge, matching the shared `dense_reduced_face` /
2214    // `khatri_rao_cone_reduced_face` `ReducedFace` contract. A dropped row joins
2215    // a representative's group — and receives a distributed multiplier — ONLY
2216    // when it is exactly PARALLEL to that representative (the same half-space up
2217    // to positive scale). A GENERAL-POSITION dependent — dependent only because
2218    // more normals bind than the face dimension (e.g. three normals inside a 2-D
2219    // coupled block) — is dropped outright with NO group entry and NO
2220    // multiplier: it re-enters the working set via the next feasibility scan and
2221    // is never conflated with a different half-space's dual (#979). The former
2222    // `best_positive_align` merge folded such a row into whichever kept row it
2223    // was most positively aligned with, silently truncating a general-position
2224    // active row out of the enforced face and pinning the wrong vertex (#2378).
2225    const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
2226    for &dropped_idx in &dropped_orig {
2227        let dropped_row = a.row(dropped_idx);
2228        let dropped_norm = dropped_row.dot(&dropped_row).sqrt();
2229        let mut best_abs_cos = 0.0_f64;
2230        let mut best_target: Option<(usize, f64)> = None;
2231        for &kept_idx in &kept_orig {
2232            let kept_row = a.row(kept_idx);
2233            let kept_norm = kept_row.dot(&kept_row).sqrt();
2234            let dot = kept_row.dot(&dropped_row);
2235            let cos = if kept_norm > 0.0 && dropped_norm > 0.0 {
2236                dot / (kept_norm * dropped_norm)
2237            } else {
2238                0.0
2239            };
2240            let coeff = if kept_norm > 0.0 {
2241                dot / (kept_norm * kept_norm)
2242            } else {
2243                0.0
2244            };
2245            if cos.abs() > best_abs_cos {
2246                best_abs_cos = cos.abs();
2247                best_target = Some((kept_idx, coeff));
2248            }
2249        }
2250        // Only an exactly-parallel dependent is recorded; a general-position
2251        // drop carries no phantom distributed dual. The group (whose whole-set
2252        // release the working-set loop drives) additionally requires POSITIVE
2253        // parallelism — same constraint up to positive scale — so an opposing
2254        // (anti-parallel) tight row is never released together with it.
2255        if best_abs_cos >= PARALLEL_COS_TOL {
2256            if let Some((target, coeff)) = best_target {
2257                let &out_idx = orig_to_out
2258                    .get(&target)
2259                    .expect("merge target must be a kept row");
2260                for &active_pos in &groups[dropped_idx] {
2261                    multiplier_dependence[out_idx].push(ActiveRowDependence { active_pos, coeff });
2262                }
2263                if coeff > 0.0 {
2264                    groups_out[out_idx].extend_from_slice(&groups[dropped_idx]);
2265                }
2266            }
2267        }
2268    }
2269
2270    for group in &mut groups_out {
2271        group.sort_unstable();
2272        group.dedup();
2273    }
2274    for dependencies in &mut multiplier_dependence {
2275        dependencies.sort_unstable_by_key(|dependency| dependency.active_pos);
2276        dependencies.dedup_by_key(|dependency| dependency.active_pos);
2277    }
2278
2279    let mut row_order: Vec<usize> = (0..groups_out.len()).collect();
2280    row_order.sort_by_key(|&idx| groups_out[idx].first().copied().unwrap_or(usize::MAX));
2281    if row_order.iter().enumerate().any(|(idx, &orig)| idx != orig) {
2282        let mut a_sorted = Array2::<f64>::zeros((rank, p));
2283        let mut b_sorted = Array1::<f64>::zeros(rank);
2284        let mut groups_sorted = Vec::with_capacity(rank);
2285        let mut dependence_sorted = Vec::with_capacity(rank);
2286        for (out_idx, orig_idx) in row_order.into_iter().enumerate() {
2287            a_sorted.row_mut(out_idx).assign(&a_out.row(orig_idx));
2288            b_sorted[out_idx] = b_out[orig_idx];
2289            groups_sorted.push(groups_out[orig_idx].clone());
2290            dependence_sorted.push(multiplier_dependence[orig_idx].clone());
2291        }
2292        a_out = a_sorted;
2293        b_out = b_sorted;
2294        groups_out = groups_sorted;
2295        multiplier_dependence = dependence_sorted;
2296    }
2297
2298    if rank < k {
2299        log::debug!(
2300            "rank-reduced active constraints from {} to {} rows (rank deficiency {})",
2301            k,
2302            rank,
2303            k - rank
2304        );
2305    }
2306
2307    (a_out, b_out, groups_out, multiplier_dependence)
2308}
2309
2310// ============================================================================
2311// Operator constraint geometry and finite dual projection
2312//
2313// The factored Khatri-Rao monotonicity cone has `n · p_shape` rows over
2314// `p_resp · p_cov` coefficients; its dense materialization is gigabytes while
2315// every operation the primal active-set method performs factors through the
2316// `n × p_cov` covariate design. Every full-row-set sweep (activation scan,
2317// ratio test, violation gate) runs on batched constraint values, never on
2318// explicit rows. Strict quadratic entry points for both Dense and factored
2319// carriers use the finite dual metric projection below; the primal loop remains
2320// only for the operator strict-interior construction that permits a feasible
2321// tangent chord.
2322// ============================================================================
2323
2324/// Batched full-row-set geometry for a [`ConstraintSet`].
2325///
2326/// `scaled_margin` shifts every non-vacuous row inward by that amount in
2327/// scaled (geometric) units — `a_iᵀβ ≥ b_i + scaled_margin·‖a_i‖` — which is
2328/// exactly the uniform interior-seed shift the dense strict projection
2329/// applies. The main QP solve uses `scaled_margin = 0`.
2330struct ConstraintSetOps<'a> {
2331    set: &'a ConstraintSet,
2332    norms: Vec<f64>,
2333    bounds: Vec<f64>,
2334    scaled_margin: f64,
2335}
2336
2337impl<'a> ConstraintSetOps<'a> {
2338    fn new(set: &'a ConstraintSet, scaled_margin: f64) -> Result<Self, EstimationError> {
2339        let m = set.nrows();
2340        let mut norms = Vec::with_capacity(m);
2341        let mut bounds = Vec::with_capacity(m);
2342        for row in 0..m {
2343            norms.push(set.row_norm(row).map_err(|e| {
2344                EstimationError::ParameterConstraintViolation(format!(
2345                    "constraint-set row norm: {e}"
2346                ))
2347            })?);
2348            bounds.push(set.bound(row).map_err(|e| {
2349                EstimationError::ParameterConstraintViolation(format!(
2350                    "constraint-set row bound: {e}"
2351                ))
2352            })?);
2353        }
2354        Ok(Self {
2355            set,
2356            norms,
2357            bounds,
2358            scaled_margin,
2359        })
2360    }
2361
2362/// Operator view of only the rows that are tight at `beta`. Inactive rows
2363    /// do not constrain the tangent cone, so make them vacuous by zeroing both
2364    /// their cached norm and bound while retaining the original row indexing.
2365    /// This avoids materializing the potentially enormous tight submatrix and
2366    /// keeps returned active ids in the parent [`ConstraintSet`] coordinates.
2367    fn tangent_face(set: &'a ConstraintSet, beta: &Array1<f64>) -> Result<Self, EstimationError> {
2368        let mut ops = Self::new(set, 0.0)?;
2369        let values = ops.values(beta)?;
2370        for row in 0..ops.nrows() {
2371            if ops.norms[row] <= 0.0 {
2372                if ops.bounds[row] > 0.0 {
2373                    crate::bail_invalid_estim!(
2374                        "infeasible zero-norm constraint row {} entered tangent-face projection",
2375                        row
2376                    );
2377                }
2378                ops.bounds[row] = 0.0;
2379                continue;
2380            }
2381            let is_tight = ops.scaled_slack(&values, row) <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
2382            // Tangent directions are homogeneous even when the original
2383            // feasible set is affine: a_i^T d >= 0 on a tight row.
2384            ops.bounds[row] = 0.0;
2385            if !is_tight {
2386                ops.norms[row] = 0.0;
2387            }
2388        }
2389        Ok(ops)
2390    }
2391
2392    fn nrows(&self) -> usize {
2393        self.norms.len()
2394    }
2395
2396    fn values(&self, x: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
2397        self.set.values(x.view()).map_err(|e| {
2398            EstimationError::ParameterConstraintViolation(format!("constraint-set values: {e}"))
2399        })
2400    }
2401
2402    /// Signed scaled slack of one row given the batched raw values, with the
2403    /// same ±∞ zero-norm semantics as [`scaled_constraint_slack`].
2404    #[inline]
2405    fn scaled_slack(&self, values: &Array1<f64>, row: usize) -> f64 {
2406        let norm = self.norms[row];
2407        if norm > 0.0 {
2408            (values[row] - self.bounds[row]) / norm - self.scaled_margin
2409        } else if self.bounds[row] > 0.0 {
2410            f64::NEG_INFINITY
2411        } else {
2412            f64::INFINITY
2413        }
2414    }
2415
2416    /// Gather the working rows as an explicit UNIT-normalized system (the
2417    /// per-row scale the dense path reaches via up-front canonicalization),
2418    /// with the margin shift folded into `b`. Zero-norm rows are rejected —
2419    /// they are vacuous and must never enter a working set.
2420    fn gather_unit_rows(
2421        &self,
2422        rows: &[usize],
2423    ) -> Result<LinearInequalityConstraints, EstimationError> {
2424        let mut gathered = self.set.gather_rows(rows).map_err(|e| {
2425            EstimationError::ParameterConstraintViolation(format!(
2426                "constraint-set working-row gather: {e}"
2427            ))
2428        })?;
2429        for (out_row, &row) in rows.iter().enumerate() {
2430            let norm = self.norms[row];
2431            if norm <= 0.0 {
2432                crate::bail_invalid_estim!(
2433                    "vacuous zero-norm constraint row {} entered the working set",
2434                    row
2435                );
2436            }
2437            let inv = 1.0 / norm;
2438            gathered.a.row_mut(out_row).mapv_inplace(|v| v * inv);
2439            gathered.b[out_row] = self.bounds[row] * inv + self.scaled_margin;
2440        }
2441        Ok(gathered)
2442    }
2443
2444}
2445
2446/// Add every geometrically independent violated separator available at one
2447/// operator iterate, in descending scaled-violation order.
2448///
2449/// A factored cone can expose `m ≫ p` violated observation rows after a
2450/// globalized Newton step leaves the previous endpoint face. Adding one row
2451/// and re-solving the conditioned `p`-dimensional KKT system after every
2452/// separator makes face discovery cost `O(p)` dense factorizations. The #979
2453/// CTN witness had `m=24_000`, `p=144`, and only 24 point-tight warm rows; that
2454/// serial path spent the remainder of a 300-second command inside one metric
2455/// projection.
2456///
2457/// This routine performs one full value scan (already required by the primal
2458/// gate), orders candidates by their geometric violation, and streams their
2459/// unit normals in coefficient-sized chunks. Modified Gram--Schmidt extends
2460/// the current active normal basis until no coefficient-space direction
2461/// remains or the rank reaches `p`. At most `p` dense rows are retained and no
2462/// `m × p` matrix is materialized.
2463fn independent_violated_operator_rows(
2464    ops: &ConstraintSetOps<'_>,
2465    values: &Array1<f64>,
2466    active: &[usize],
2467    is_active: &[bool],
2468    banned: &[bool],
2469    max_new: usize,
2470) -> Result<Vec<usize>, EstimationError> {
2471    let p = ops.set.ncols();
2472    if max_new == 0 {
2473        return Ok(Vec::new());
2474    }
2475    if values.len() != ops.nrows()
2476        || is_active.len() != ops.nrows()
2477        || banned.len() != ops.nrows()
2478    {
2479        crate::bail_invalid_estim!(
2480            "operator batch-separation dimension mismatch: values={}, active_mask={}, \
2481             banned_mask={}, constraints={}",
2482            values.len(),
2483            is_active.len(),
2484            banned.len(),
2485            ops.nrows(),
2486        );
2487    }
2488
2489    let mut candidates = Vec::<(usize, f64)>::new();
2490    for row in 0..ops.nrows() {
2491        if is_active[row] || banned[row] || ops.norms[row] <= 0.0 {
2492            continue;
2493        }
2494        let violation = (-ops.scaled_slack(values, row)).max(0.0);
2495        if violation > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2496            candidates.push((row, violation));
2497        }
2498    }
2499    candidates.sort_unstable_by(|(left_row, left_violation), (right_row, right_violation)| {
2500        right_violation
2501            .total_cmp(left_violation)
2502            .then_with(|| left_row.cmp(right_row))
2503    });
2504    if candidates.is_empty() {
2505        return Ok(Vec::new());
2506    }
2507
2508    // Every gathered row is unit-normalized. Use the same relative rank scale
2509    // as the working-face reducer, with `p` (the maximum attainable rank) in
2510    // place of a row-count-dependent tolerance.
2511    let rank_tolerance = 100.0 * f64::EPSILON * p.max(1) as f64;
2512    let mut basis = Vec::<Array1<f64>>::with_capacity(p);
2513    if !active.is_empty() {
2514        let active_rows = ops.gather_unit_rows(active)?;
2515        for row in active_rows.a.rows() {
2516            extend_operator_normal_basis(&mut basis, row, rank_tolerance);
2517        }
2518    }
2519
2520    let chunk_size = p.max(32);
2521    let mut selected = Vec::with_capacity(max_new.min(p.saturating_sub(basis.len())));
2522    for chunk in candidates.chunks(chunk_size) {
2523        let chunk_ids = chunk.iter().map(|(row, _)| *row).collect::<Vec<_>>();
2524        let gathered = ops.gather_unit_rows(&chunk_ids)?;
2525        for (position, &row) in chunk_ids.iter().enumerate() {
2526            if extend_operator_normal_basis(
2527                &mut basis,
2528                gathered.a.row(position),
2529                rank_tolerance,
2530            ) {
2531                selected.push(row);
2532                if selected.len() == max_new || basis.len() == p {
2533                    return Ok(selected);
2534                }
2535            }
2536        }
2537    }
2538    Ok(selected)
2539}
2540
2541/// Reorthogonalized modified Gram--Schmidt append for one unit constraint
2542/// normal. Returns true exactly when the row adds a resolved normal-space
2543/// direction.
2544fn extend_operator_normal_basis(
2545    basis: &mut Vec<Array1<f64>>,
2546    row: ArrayView1<'_, f64>,
2547    rank_tolerance: f64,
2548) -> bool {
2549    let mut residual = row.to_owned();
2550    // The second pass prevents a long, nearly dependent active basis from
2551    // manufacturing a false new direction through first-pass roundoff.
2552    for _ in 0..2 {
2553        for direction in basis.iter() {
2554            let projection = residual.dot(direction);
2555            residual.scaled_add(-projection, direction);
2556        }
2557    }
2558    let residual_norm = residual.dot(&residual).sqrt();
2559    if !(residual_norm.is_finite() && residual_norm > rank_tolerance) {
2560        return false;
2561    }
2562    residual /= residual_norm;
2563    basis.push(residual);
2564    true
2565}
2566
2567/// Retain only candidate row ids that are genuinely tight at `beta`.
2568///
2569/// Active-face provenance is point-local. A constrained QP reports its full
2570/// endpoint face, while trust-region globalization may accept a strict
2571/// subsegment whose endpoint-only rows are still slack. This helper is the
2572/// shared handoff for warm starts and terminal tangent-space evidence: it uses
2573/// the carrier's exact row scaling, preserves canonical input order, and never
2574/// scans rows outside the sparse candidate face.
2575pub fn constraint_set_rows_tight_at_point(
2576    set: &ConstraintSet,
2577    beta: &Array1<f64>,
2578    candidate_rows: &[usize],
2579) -> Result<Vec<usize>, EstimationError> {
2580    if set.ncols() != beta.len() {
2581        crate::bail_invalid_estim!(
2582            "active-face point dimension mismatch: set has {} columns, beta has {}",
2583            set.ncols(),
2584            beta.len()
2585        );
2586    }
2587    let mut seen = HashSet::with_capacity(candidate_rows.len());
2588    let mut unique = Vec::with_capacity(candidate_rows.len());
2589    for &row in candidate_rows {
2590        if row < set.nrows() && seen.insert(row) {
2591            unique.push(row);
2592        }
2593    }
2594    if unique.is_empty() {
2595        return Ok(Vec::new());
2596    }
2597    let gathered = set.gather_rows(&unique).map_err(|error| {
2598        EstimationError::ParameterConstraintViolation(format!(
2599            "active-face candidate-row gather failed: {error}"
2600        ))
2601    })?;
2602    let mut tight = Vec::with_capacity(unique.len());
2603    for (position, &row) in unique.iter().enumerate() {
2604        let constraint_row = gathered.a.row(position);
2605        let norm = constraint_row.dot(&constraint_row).sqrt();
2606        if norm > 0.0 {
2607            let scaled_slack = (constraint_row.dot(beta) - gathered.b[position]) / norm;
2608            if scaled_slack <= ACTIVE_SET_WORKING_FACE_TOL {
2609                tight.push(row);
2610            }
2611        }
2612    }
2613    Ok(tight)
2614}
2615
2616/// Project a stationarity residual onto the normal cone of an operator-carried
2617/// constraint set without materializing its complete tight face.
2618///
2619/// Lawson–Hanson discovers the required generators through batched operator
2620/// products and gathers only its `O(p)` passive rows. Selection depends only
2621/// on the current face geometry, never on a warm active-set history.
2622/// `seed_active` is output provenance only: tight seed rows are retained in the
2623/// returned sparse face even when their KKT multiplier is zero, but they never
2624/// enter the Lawson–Hanson pivot order or alter the projected vector.
2625pub fn project_stationarity_residual_on_constraint_set(
2626    residual: &Array1<f64>,
2627    beta: &Array1<f64>,
2628    set: &ConstraintSet,
2629    seed_active: &[usize],
2630) -> Option<(Array1<f64>, Vec<usize>)> {
2631    let p = residual.len();
2632    if beta.len() != p || set.ncols() != p {
2633        return None;
2634    }
2635    match set {
2636        ConstraintSet::KhatriRaoCone(cone) if cone.p_left() != 1 || cone.coupled_rows() != &[0] => {
2637            // Each coupled response row occupies a disjoint `p_cov` slice,
2638            // and the projection Hessian is identity. The global projection is
2639            // therefore the exact direct sum of small row projections. Solving
2640            // all response rows in one `p_left*p_cov` KKT system needlessly
2641            // pays cubic global algebra at the all-tight CTN vertex.
2642            let p_cov = cone.factor().ncols();
2643            let n = cone.factor().nrows();
2644            let mut projected = residual.clone();
2645            let mut active = Vec::new();
2646            for (slot, &coefficient_row) in cone.coupled_rows().iter().enumerate() {
2647                let start = coefficient_row * p_cov;
2648                let end = start + p_cov;
2649                let local_residual = residual.slice(s![start..end]).to_owned();
2650                let local_beta = beta.slice(s![start..end]).to_owned();
2651                let local_set = ConstraintSet::KhatriRaoCone(cone.single_coupled_slot(slot).ok()?);
2652                let row_start = slot * n;
2653                let row_end = row_start + n;
2654                let local_seed: Vec<usize> = seed_active
2655                    .iter()
2656                    .copied()
2657                    .filter(|&row| row >= row_start && row < row_end)
2658                    .map(|row| row - row_start)
2659                    .collect();
2660                let (local_projected, local_active) =
2661                    project_stationarity_residual_on_constraint_set(
2662                        &local_residual,
2663                        &local_beta,
2664                        &local_set,
2665                        &local_seed,
2666                    )?;
2667                projected.slice_mut(s![start..end]).assign(&local_projected);
2668                active.extend(local_active.into_iter().map(|row| row_start + row));
2669            }
2670            Some((projected, active))
2671        }
2672        ConstraintSet::BlockDiagonal { blocks, .. } => {
2673            // The same direct-sum identity applies to explicitly placed blocks;
2674            // columns outside all blocks are unconstrained and retain their
2675            // original residual components.
2676            let mut projected = residual.clone();
2677            let mut active = Vec::new();
2678            let mut row_offset = 0usize;
2679            for block in blocks {
2680                let width = block.set.ncols();
2681                let start = block.col_start;
2682                let end = start + width;
2683                let local_residual = residual.slice(s![start..end]).to_owned();
2684                let local_beta = beta.slice(s![start..end]).to_owned();
2685                let row_end = row_offset + block.set.nrows();
2686                let local_seed: Vec<usize> = seed_active
2687                    .iter()
2688                    .copied()
2689                    .filter(|&row| row >= row_offset && row < row_end)
2690                    .map(|row| row - row_offset)
2691                    .collect();
2692                let (local_projected, local_active) =
2693                    project_stationarity_residual_on_constraint_set(
2694                        &local_residual,
2695                        &local_beta,
2696                        &block.set,
2697                        &local_seed,
2698                    )?;
2699                projected.slice_mut(s![start..end]).assign(&local_projected);
2700                active.extend(local_active.into_iter().map(|row| row_offset + row));
2701                row_offset = row_end;
2702            }
2703            Some((projected, active))
2704        }
2705        _ => project_stationarity_residual_on_constraint_set_undivided(
2706            residual,
2707            beta,
2708            set,
2709            seed_active,
2710        ),
2711    }
2712}
2713
2714fn project_stationarity_residual_on_constraint_set_undivided(
2715    residual: &Array1<f64>,
2716    beta: &Array1<f64>,
2717    set: &ConstraintSet,
2718    seed_active: &[usize],
2719) -> Option<(Array1<f64>, Vec<usize>)> {
2720    let ops = ConstraintSetOps::tangent_face(set, beta).ok()?;
2721    let (multipliers, projected) = nonnegative_cone_projection_by_rows(
2722        &ops.norms,
2723        residual,
2724        |candidate| ops.values(candidate).ok(),
2725        |rows| ops.set.gather_rows(rows).ok().map(|gathered| gathered.a),
2726    )?;
2727    let mut active: Vec<usize> = multipliers.into_iter().map(|(row, _)| row).collect();
2728    for &row in seed_active {
2729        if row < ops.nrows() && ops.norms[row] > 0.0 && !active.contains(&row) {
2730            active.push(row);
2731        }
2732    }
2733    Some((projected, active))
2734}
2735
2736/// Strictly-interior projection onto a [`ConstraintSet`]: the operator
2737/// analogue of [`project_point_strictly_into_feasible_cone`]. Dense sets
2738/// delegate to the dense projection (including its anti-parallel equality
2739/// lift); the factored cone is homogeneous and one-sided, so the projection
2740/// is a single identity-Hessian QP against the margin-shifted rows. The QP uses
2741/// the same finite dual active-set solver as production metric projection:
2742/// coefficient dimension bounds its dense work, while carrier row count enters
2743/// only through operator scans.
2744///
2745/// A refusal is a typed [`EstimationError::ParameterConstraintViolation`]
2746/// naming the failing condition (dimension mismatch, non-finite iterate, or
2747/// the specific row whose half-margin the projection could not clear), never a
2748/// bare `None`: the caller decides whether that refusal is fatal or a soft
2749/// fallback, but it is never a silent one.
2750pub fn project_point_strictly_into_feasible_constraint_set(
2751    point: &Array1<f64>,
2752    set: &ConstraintSet,
2753) -> Result<Array1<f64>, EstimationError> {
2754    match set {
2755        ConstraintSet::Dense(dense) => {
2756            // The dense arm keeps its `Option` contract (it has other callers);
2757            // its refusal is retyped here so this seam carries a diagnostic
2758            // rather than a bare `None`.
2759            project_point_strictly_into_feasible_cone(point, dense).ok_or_else(|| {
2760                EstimationError::ParameterConstraintViolation(
2761                    "dense strict-interior projection could not certify a feasible point"
2762                        .to_string(),
2763                )
2764            })
2765        }
2766        _ => {
2767            let p = point.len();
2768            if set.ncols() != p {
2769                return Err(EstimationError::ParameterConstraintViolation(format!(
2770                    "strict-interior projection dimension mismatch: point length {p} != constraint columns {}",
2771                    set.ncols()
2772                )));
2773            }
2774            let ops = ConstraintSetOps::new(set, ACTIVE_SET_INTERIOR_SEED_MARGIN)?;
2775            let identity = Array2::<f64>::eye(p);
2776            // min ½‖β − point‖² ⇒ Hessian = I and rhs = point. This is the
2777            // same strictly convex operator projection solved by the finite
2778            // Goldfarb--Idnani dual path below, not the retired primal add/drop
2779            // direction loop. The latter bounded work by the carrier's row
2780            // count; a tiny roundoff repair on the 320k-row CTN cone could
2781            // therefore spend the entire command budget enumerating row ids.
2782            // Here the face rank and transition bounds depend only on p, and m
2783            // contributes only full operator scans.
2784            let factor = identity.cholesky(Side::Lower).map_err(|error| {
2785                EstimationError::InvalidInput(format!(
2786                    "strict-interior identity metric could not be factored: {error}"
2787                ))
2788            })?;
2789            let (beta, _) = solve_operator_metric_projection_dual_active_set(
2790                &identity,
2791                point,
2792                point,
2793                &factor,
2794                &ops,
2795                &[],
2796            )?;
2797            if beta.iter().any(|v| !v.is_finite()) {
2798                return Err(EstimationError::ParameterConstraintViolation(
2799                    "strict-interior projection produced a non-finite iterate".to_string(),
2800                ));
2801            }
2802            // Certify against the ORIGINAL (unshifted) rows with half-margin
2803            // clearance, mirroring the dense projection's exit contract.
2804            const SEED_FEASIBILITY_TOL: f64 = 1e-9;
2805            let unshifted = ConstraintSetOps::new(set, 0.0)?;
2806            let values = unshifted.values(&beta)?;
2807            let half_margin = 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - SEED_FEASIBILITY_TOL;
2808            for row in 0..unshifted.nrows() {
2809                if unshifted.norms[row] <= 0.0 {
2810                    continue;
2811                }
2812                let slack = unshifted.scaled_slack(&values, row);
2813                if slack < half_margin {
2814                    return Err(EstimationError::ParameterConstraintViolation(format!(
2815                        "strict-interior projection could not clear the half-margin at row {row}: \
2816                         scaled slack {slack:.3e} < {half_margin:.3e}"
2817                    )));
2818                }
2819            }
2820            Ok(beta)
2821        }
2822    }
2823}
2824
2825/// Re-solve one discovered passive face in the original metric.
2826///
2827/// The terminal KKT contract admits multipliers down to
2828/// `-ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL`; the transition rule must use that
2829/// same numerical cone. Treating every roundoff-sized negative multiplier as a
2830/// leaving pivot makes a degenerate face alternate between equivalent bases
2831/// that the eventual certificate would accept. Materially negative rows leave
2832/// one at a time in Bland order (lowest constraint id), so the conditioned
2833/// phase has a deterministic anti-cycling pivot rule.
2834/// Project `point` onto the feasible polyhedron of `set` in the diagonal metric
2835/// `metric_diag` (the Euclidean metric when `None`), landing ON the face that
2836/// binds, and return the rows that bind there.
2837///
2838/// This is the projection an active-set step loop needs, and it is not
2839/// [`project_point_strictly_into_feasible_constraint_set`]. That one retreats
2840/// `ACTIVE_SET_INTERIOR_SEED_MARGIN` into the interior and exists to SEED a
2841/// solve away from every face. Applied to a trial step it moves the iterate
2842/// off the very row that clipped it, so the row is never tight at the accepted
2843/// point, the accepted face stays empty, the reduced-face Newton never runs, and
2844/// the next ambient trust step is clipped on the same row again. Measured on
2845/// the survival location-scale 1569 witness (gam#2695, gam#2714): one time-block
2846/// row held the walk for sixty cycles, the iterate shuttling between scaled
2847/// slack `1e-8` and `1e-6` with accepted steps of `1e-22` while the QP already
2848/// listed that row as active.
2849///
2850/// The metric projection is the same strictly convex QP the constrained Newton
2851/// step itself solves (`½‖β − point‖²_D` subject to the rows), so its answer
2852/// carries the binding rows exactly and at the solver's own face tolerance —
2853/// there is no margin to choose. `warm_active_set` seeds the dual active set;
2854/// rows in it that are not tight at `point` are ignored.
2855pub fn project_point_onto_constraint_set_in_metric(
2856    point: &Array1<f64>,
2857    metric_diag: Option<&Array1<f64>>,
2858    set: &ConstraintSet,
2859    warm_active_set: Option<&[usize]>,
2860) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
2861    let p = point.len();
2862    if set.ncols() != p {
2863        crate::bail_invalid_estim!(
2864            "metric projection dimension mismatch: point length {p} != constraint columns {}",
2865            set.ncols()
2866        );
2867    }
2868    if !array_is_finite(point) {
2869        crate::bail_invalid_estim!("metric projection received a non-finite point");
2870    }
2871    if let Some(diag) = metric_diag
2872        && (diag.len() != p || diag.iter().any(|value| !value.is_finite() || *value <= 0.0))
2873    {
2874        crate::bail_invalid_estim!(
2875            "metric projection needs a finite positive diagonal metric of length {p}"
2876        );
2877    }
2878    let mut hessian = Array2::<f64>::zeros((p, p));
2879    let mut rhs = Array1::<f64>::zeros(p);
2880    for index in 0..p {
2881        let weight = metric_diag.map_or(1.0, |diag| diag[index]);
2882        hessian[[index, index]] = weight;
2883        rhs[index] = weight * point[index];
2884    }
2885    solve_quadratic_with_constraint_set(&hessian, &rhs, point, set, warm_active_set)
2886}
2887
2888fn refine_operator_metric_face(
2889    hessian: &Array2<f64>,
2890    rhs: &Array1<f64>,
2891    unconstrained: &Array1<f64>,
2892    ops: &ConstraintSetOps<'_>,
2893    active: &mut Vec<usize>,
2894    is_active: &mut [bool],
2895    transitions: &mut usize,
2896) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
2897    loop {
2898        if active.is_empty() {
2899            return Ok((unconstrained.clone(), Array1::zeros(0)));
2900        }
2901        let rows = ops.gather_unit_rows(active)?;
2902        // Solve for the absolute endpoint, not a correction to the free point.
2903        // The correction is routinely the negation of an O(1) free point while
2904        // the constrained endpoint is O(eps) or smaller. Forming
2905        // `unconstrained + correction` then loses absolute digits to
2906        // cancellation that no subsequent face solve can recover, and an
2907        // active row can drift by O(eps) even though its endpoint-scale
2908        // representable residual is O(eps²). The original quadratic is
2909        //
2910        //     1/2 beta' H beta - rhs' beta,
2911        //
2912        // so solving it directly with `A beta = b` is algebraically identical
2913        // and preserves the endpoint's own scale.
2914        let objective_gradient = -rhs;
2915        let (candidate, system_multipliers) = solve_kkt_direction(
2916            hessian,
2917            &objective_gradient,
2918            &rows.a,
2919            Some(&rows.b),
2920        )?;
2921        let refined_multipliers = -system_multipliers;
2922        let leaving_position = refined_multipliers
2923            .iter()
2924            .enumerate()
2925            .filter(|(_, value)| {
2926                !value.is_finite() || **value < -ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
2927            })
2928            .min_by_key(|(position, _)| active[*position])
2929            .map(|(position, _)| position);
2930        let Some(leaving_position) = leaving_position else {
2931            return Ok((candidate, refined_multipliers));
2932        };
2933        let leaving_row = active.remove(leaving_position);
2934        is_active[leaving_row] = false;
2935        *transitions += 1;
2936    }
2937}
2938
2939/// Numerical dependence floor, in the whitened metric, for "the entering
2940/// constraint normal is already spanned by the active face".
2941///
2942/// The dual step direction of the entering row `p` is `z = H⁻¹(n_p − Nᵀr)`,
2943/// whose whitened form is the component of `L⁻¹n_p` orthogonal to the whitened
2944/// active normals. `n_pᵀz = ‖z_w‖²` is therefore the *rate* at which a step
2945/// closes row `p`'s violation, and it vanishes exactly when `n_p ∈ span(N)`. The
2946/// test is relative to `‖L⁻¹n_p‖`, so it is invariant to the metric's scale, and
2947/// it sits far above `eps`: a row that differs from the face only by roundoff
2948/// must be classified DEPENDENT (and handled by a dual drop), never handed a
2949/// spurious enormous step length.
2950const ACTIVE_SET_DUAL_DEPENDENCE_TOL: f64 = 1e-11;
2951
2952/// Thin QR by reorthogonalized modified Gram--Schmidt: `columns = Q R` with `Q`
2953/// orthonormal (returned as its column list) and `R` upper triangular.
2954///
2955/// Returns `None` when a column is numerically dependent on its predecessors.
2956/// The dual active-set solve maintains an independent face by construction, so
2957/// `None` is a genuine numerical breakdown rather than an expected branch, and
2958/// the caller converts it into a typed refusal.
2959fn thin_qr_reorthogonalized(
2960    columns: &[Array1<f64>],
2961    rank_tolerance: f64,
2962) -> Option<(Vec<Array1<f64>>, Array2<f64>)> {
2963    let k = columns.len();
2964    let mut q: Vec<Array1<f64>> = Vec::with_capacity(k);
2965    let mut r = Array2::<f64>::zeros((k, k));
2966    for (column_index, column) in columns.iter().enumerate() {
2967        let scale = column.dot(column).sqrt();
2968        let mut residual = column.clone();
2969        // Two passes: one sweep leaves a long, nearly dependent basis able to
2970        // manufacture a false orthogonal direction out of first-pass roundoff.
2971        for _ in 0..2 {
2972            for (basis_index, basis) in q.iter().enumerate() {
2973                let projection = residual.dot(basis);
2974                r[[basis_index, column_index]] += projection;
2975                residual.scaled_add(-projection, basis);
2976            }
2977        }
2978        let norm = residual.dot(&residual).sqrt();
2979        if !(norm.is_finite() && scale.is_finite() && norm > rank_tolerance * scale.max(1.0)) {
2980            return None;
2981        }
2982        r[[column_index, column_index]] = norm;
2983        residual /= norm;
2984        q.push(residual);
2985    }
2986    Some((q, r))
2987}
2988
2989/// Back substitution against an upper-triangular `R` (`R x = y`).
2990fn upper_triangular_back_substitution(r: &Array2<f64>, y: &Array1<f64>) -> Option<Array1<f64>> {
2991    let k = y.len();
2992    if r.nrows() != k || r.ncols() != k {
2993        return None;
2994    }
2995    let mut x = Array1::<f64>::zeros(k);
2996    for row in (0..k).rev() {
2997        let mut sum = y[row];
2998        for column in (row + 1)..k {
2999            sum -= r[[row, column]] * x[column];
3000        }
3001        let pivot = r[[row, row]];
3002        if !(pivot.is_finite() && pivot != 0.0) {
3003            return None;
3004        }
3005        x[row] = sum / pivot;
3006    }
3007    if array_is_finite(&x) { Some(x) } else { None }
3008}
3009
3010/// One violated row plus its scaled violation, as produced by a full scan.
3011struct ViolatedConstraintRow {
3012    row: usize,
3013    violation: f64,
3014}
3015
3016/// Full-set primal scan.
3017///
3018/// `worst` owns the one-sided public feasibility decision over EVERY row, while
3019/// `inactive` contains only rows the dual iteration can admit. Active rows obey
3020/// a stronger, two-sided equality contract that is certified separately after
3021/// the original-metric face solve. Keeping primal feasibility and separator
3022/// admission in distinct fields prevents an empty inactive queue from being
3023/// mistaken for an all-row certificate (#979).
3024struct OperatorViolationScan {
3025    worst: ViolatedConstraintRow,
3026    inactive: Vec<ViolatedConstraintRow>,
3027}
3028
3029impl OperatorViolationScan {
3030    fn is_primal_feasible(&self) -> bool {
3031        self.worst.violation <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3032    }
3033}
3034
3035fn scan_operator_violations(
3036    ops: &ConstraintSetOps<'_>,
3037    values: &Array1<f64>,
3038    is_active: &[bool],
3039) -> Result<OperatorViolationScan, EstimationError> {
3040    if values.len() != ops.nrows() || is_active.len() != ops.nrows() {
3041        crate::bail_invalid_estim!(
3042            "operator violation scan dimension mismatch: values={}, active_mask={}, rows={}",
3043            values.len(),
3044            is_active.len(),
3045            ops.nrows(),
3046        );
3047    }
3048    let mut worst = 0.0_f64;
3049    let mut worst_row = 0usize;
3050    let mut inactive = Vec::<ViolatedConstraintRow>::new();
3051    for row in 0..ops.nrows() {
3052        if ops.norms[row] <= 0.0 {
3053            // A vacuous row constrains nothing unless its bound is positive, in
3054            // which case the feasible set is empty and no projection exists.
3055            if ops.bounds[row] > 0.0 {
3056                return Err(EstimationError::ParameterConstraintViolation(format!(
3057                    "operator metric projection has an infeasible zero-norm constraint row {row} \
3058                     with bound {:.3e}",
3059                    ops.bounds[row]
3060                )));
3061            }
3062            continue;
3063        }
3064        let violation = (-ops.scaled_slack(values, row)).max(0.0);
3065        if violation > worst {
3066            worst = violation;
3067            worst_row = row;
3068        }
3069        if violation > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[row] {
3070            inactive.push(ViolatedConstraintRow { row, violation });
3071        }
3072    }
3073    Ok(OperatorViolationScan {
3074        worst: ViolatedConstraintRow {
3075            row: worst_row,
3076            violation: worst,
3077        },
3078        inactive,
3079    })
3080}
3081
3082/// Goldfarb--Idnani dual active-set solve of the strictly convex operator
3083/// metric projection
3084///
3085/// ```text
3086/// minimize  ½ βᵀHβ − rhsᵀβ    subject to   Aβ ≥ b,   H ≻ 0.
3087/// ```
3088///
3089/// # Why this algorithm and not an add/drop primal face iteration
3090///
3091/// The predecessor solved the equality-constrained subproblem on a working
3092/// face, added violated rows, dropped negative-multiplier rows, and repeated.
3093/// That iteration has **no monotone merit function**: nothing forbids it from
3094/// returning to a face it has already left, so its only termination argument was
3095/// an exact-state memo plus a temporary ban set — and on a degenerate face
3096/// (`m ≫ p` with many parallel rows, exactly the shape-constrained
3097/// transformation carriers this solver exists for) it duly cycled and refused
3098/// (#2432: 565 / 390 / 210 / 70 dual transitions across three shapes).
3099///
3100/// The dual method removes that failure mode *structurally*:
3101///
3102/// * between separators, `(β, μ)` is the exact minimizer subject to the active
3103///   rows held as EQUALITIES, with `μ ≥ 0`; during a separator pivot, its
3104///   cumulative pending multiplier is part of that same primal/dual
3105///   representation until the row is fully admitted;
3106/// * an entering row is closed by a step `t = min(t₁, t₂)` along
3107///   `z = H⁻¹(n_p − Nᵀr)`, where `t₂` reaches that row's boundary and `t₁` is the
3108///   largest step keeping every multiplier nonnegative;
3109/// * a **full** step (`t = t₂ > 0`) strictly increases the dual objective, so no
3110///   active set can ever be revisited;
3111/// * a **partial** step (`t = t₁ < t₂`) drops exactly one row and keeps the same
3112///   entering row, so at most `|A| ≤ p` of them can occur consecutively without
3113///   an intervening strict increase.
3114///
3115/// Together those are a finite-termination proof that does not assume
3116/// non-degeneracy: no anti-cycling rule, no ban set and no state memo is needed,
3117/// because a cycle is not representable. Linear independence of the active
3118/// normals is maintained rather than assumed — a row dependent on the face
3119/// (`‖z_w‖ ≈ 0`) can only be admitted after a dual drop makes room for it, and
3120/// if no drop is available the feasible set is empty and the solve refuses with
3121/// that diagnosis instead of grinding.
3122///
3123/// # Operator-native cost
3124///
3125/// Full constraint scans (`ops.values`) happen only when the candidate queue
3126/// empties; an individual entering row costs one single-row gather plus an
3127/// `O(p·k²)` face factorization with `k ≤ p`. Observation-row cardinality
3128/// therefore affects linear scans only, never the size or number of dense
3129/// systems — the #979 property, preserved. The caller's warm active set and the
3130/// batched independent-separator scan feed the queue as *ordering hints only*:
3131/// the returned minimizer is a function of `(H, rhs, A, b)` alone, so warm-start
3132/// history can change how fast this solve runs but never what it returns.
3133/// The two dual KKT channels of the metric projection, judged in the same
3134/// units as its stationarity channel: a channel is violated only when it
3135/// exceeds its tolerance both absolutely AND relative to `gradient_scale`
3136/// (`max(1, ‖g‖∞)`). The multipliers solve `g − A_Aᵀμ = 0` on unit rows, so
3137/// they carry the gradient's scale and so does `|μ·slack|`; an absolute bar
3138/// alone refused every high-λ seed of the survival location-scale fits at
3139/// `|μ·slack| ≈ 1e-6` from a `1e-15` roundoff slack under `‖g‖∞ ≈ 2e9`
3140/// (gam#2695, gam#2714).
3141pub(crate) fn kkt_dual_channel_violations(
3142    dual_violation: f64,
3143    complementarity: f64,
3144    gradient_scale: f64,
3145) -> (bool, bool) {
3146    let dual_infeasible = dual_violation > ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
3147        && dual_violation / gradient_scale > ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
3148    let complementarity_violated = complementarity > ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
3149        && complementarity / gradient_scale > ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
3150    (dual_infeasible, complementarity_violated)
3151}
3152
3153fn solve_operator_metric_projection_dual_active_set(
3154    hessian: &Array2<f64>,
3155    rhs: &Array1<f64>,
3156    unconstrained: &Array1<f64>,
3157    factor: &gam_linalg::faer_ndarray::FaerCholeskyFactor,
3158    ops: &ConstraintSetOps<'_>,
3159    warm_rows: &[usize],
3160) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3161    use gam_linalg::triangular::{
3162        back_substitution_lower_transpose, forward_substitution_lower_vector,
3163    };
3164
3165    let p = unconstrained.len();
3166    let m = ops.nrows();
3167    let lower = factor.lower_triangular();
3168    let face_rank_tolerance = 100.0 * f64::EPSILON * (p.max(1) as f64);
3169
3170    let mut beta = unconstrained.clone();
3171    let mut active = Vec::<usize>::new();
3172    let mut is_active = vec![false; m];
3173    // Whitened active normals `L⁻¹n_i`, index-parallel to `active`.
3174    let mut whitened_active = Vec::<Array1<f64>>::new();
3175    let mut multipliers = Vec::<f64>::new();
3176    let mut queue = std::collections::VecDeque::<usize>::new();
3177    for &row in warm_rows {
3178        if row < m && ops.norms[row] > 0.0 && !queue.contains(&row) {
3179            queue.push_back(row);
3180        }
3181    }
3182
3183    // Operational bounded-work limits, not a finite-termination theorem. Exact
3184    // arithmetic cannot revisit a face after a strict dual improvement, but the
3185    // number of distinct faces is not bounded by a polynomial in `p`. Reaching
3186    // either limit therefore surfaces an explicit refusal without diagnosing
3187    // its cause as floating-point breakdown.
3188    let max_transitions = 8usize
3189        .saturating_mul(p.saturating_add(2))
3190        .saturating_mul(p.saturating_add(2))
3191        .saturating_add(64);
3192    let max_refills = 4usize.saturating_mul(p).saturating_add(32);
3193    let mut transitions = 0usize;
3194    let mut refills = 0usize;
3195
3196    let (candidate, refined_multipliers) = loop {
3197        'dual: loop {
3198            let Some(entering) = queue.pop_front() else {
3199                let values = ops.values(&beta)?;
3200                let scan = scan_operator_violations(ops, &values, &is_active)?;
3201                if scan.inactive.is_empty() {
3202                    // No row remains that this phase can admit. An active row
3203                    // may have accumulated forward-error drift, so this is a
3204                    // transition to original-metric face conditioning, not a
3205                    // claim that the all-row primal certificate passed.
3206                    break 'dual;
3207                }
3208                refills += 1;
3209                if refills > max_refills {
3210                    return Err(EstimationError::ParameterConstraintViolation(format!(
3211                        "operator metric projection reached its bounded-work limit of \
3212                         {max_refills} separator scans with {} rows still violated \
3213                         (worst {:.3e})",
3214                        scan.inactive.len(),
3215                        scan.inactive
3216                            .iter()
3217                            .map(|entry| entry.violation)
3218                            .fold(0.0_f64, f64::max),
3219                    )));
3220                }
3221                // Prefer a batch of mutually independent separators: each is
3222                // admissible without an intervening drop, so one scan can rebuild
3223                // a whole face. When the face already spans every violated normal
3224                // the batch is empty and plain most-violated order is queued —
3225                // those rows are dependent, and the dual drop rule makes room.
3226                let no_bans = vec![false; m];
3227                let batch = independent_violated_operator_rows(
3228                    ops,
3229                    &values,
3230                    &active,
3231                    &is_active,
3232                    &no_bans,
3233                    p.saturating_sub(active.len()),
3234                )?;
3235                if batch.is_empty() {
3236                    let mut ordered = scan.inactive;
3237                    ordered.sort_unstable_by(|left, right| {
3238                        right
3239                            .violation
3240                            .total_cmp(&left.violation)
3241                            .then_with(|| left.row.cmp(&right.row))
3242                    });
3243                    queue.extend(
3244                        ordered
3245                            .iter()
3246                            .take(p.saturating_add(8))
3247                            .map(|entry| entry.row),
3248                    );
3249                } else {
3250                    queue.extend(batch);
3251                }
3252                continue 'dual;
3253            };
3254            if is_active[entering] || ops.norms[entering] <= 0.0 {
3255                continue 'dual;
3256            }
3257            let entering_rows = ops.gather_unit_rows(&[entering])?;
3258            let normal = entering_rows.a.row(0).to_owned();
3259            let bound = entering_rows.b[0];
3260            let whitened_normal = forward_substitution_lower_vector(lower.view(), normal.view());
3261            let whitened_scale = whitened_normal.dot(&whitened_normal).sqrt();
3262            if !(array_is_finite(&whitened_normal) && whitened_scale > 0.0) {
3263                crate::bail_invalid_estim!(
3264                    "operator metric projection whitened entering row {entering} to a degenerate \
3265                     normal (scale {whitened_scale:.3e})"
3266                );
3267            }
3268
3269            // Inner dual loop: hold `entering` fixed and take dual steps until it
3270            // is admitted (full step) or proven inadmissible (no drop available).
3271            //
3272            // A partial step already adds a positive multiplier for `entering`
3273            // to the primal representation while releasing one old row. The
3274            // separator must therefore remain pending across every partial
3275            // drop, even if its remaining violation falls inside the public
3276            // primal tolerance. Abandoning it there leaves `beta` carrying an
3277            // unrecorded normal component; terminal face conditioning erases
3278            // that component and recreates the same violation (#979).
3279            let mut remaining_violation = bound - normal.dot(&beta);
3280            if remaining_violation <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3281                // The iterate moved since this row was queued; no dual step
3282                // for this separator has begun, so it is safe to discard.
3283                continue 'dual;
3284            }
3285            let mut entering_multiplier = 0.0_f64;
3286            loop {
3287                let (dual_direction, tangent) = if active.is_empty() {
3288                    (Array1::<f64>::zeros(0), whitened_normal.clone())
3289                } else {
3290                    let Some((q, r)) =
3291                        thin_qr_reorthogonalized(&whitened_active, face_rank_tolerance)
3292                    else {
3293                        crate::bail_invalid_estim!(
3294                            "operator metric projection lost independence of its {} active normals",
3295                            active.len()
3296                        );
3297                    };
3298                    let projections =
3299                        Array1::from_iter(q.iter().map(|basis| basis.dot(&whitened_normal)));
3300                    let Some(dual_direction) =
3301                        upper_triangular_back_substitution(&r, &projections)
3302                    else {
3303                        crate::bail_invalid_estim!(
3304                            "operator metric projection could not solve its {}-row dual direction",
3305                            active.len()
3306                        );
3307                    };
3308                    let mut tangent = whitened_normal.clone();
3309                    for (basis, projection) in q.iter().zip(projections.iter()) {
3310                        tangent.scaled_add(-projection, basis);
3311                    }
3312                    (dual_direction, tangent)
3313                };
3314
3315                // `n_pᵀ z = ‖tangent‖²`: the rate at which a unit dual step
3316                // closes this row's violation, zero exactly on a dependent normal.
3317                let rate = tangent.dot(&tangent);
3318                let dependence_floor = ACTIVE_SET_DUAL_DEPENDENCE_TOL * whitened_scale;
3319                let full_step = if rate > dependence_floor * dependence_floor {
3320                    remaining_violation / rate
3321                } else {
3322                    f64::INFINITY
3323                };
3324
3325                // Largest step preserving `μ ≥ 0`, with the lowest constraint id
3326                // breaking exact ties so the drop rule is deterministic.
3327                let mut partial_step = f64::INFINITY;
3328                let mut blocking: Option<usize> = None;
3329                for (position, &direction) in dual_direction.iter().enumerate() {
3330                    if !(direction > 0.0) {
3331                        continue;
3332                    }
3333                    let ratio = (multipliers[position] / direction).max(0.0);
3334                    let replaces = match blocking {
3335                        None => true,
3336                        Some(current) => {
3337                            ratio < partial_step
3338                                || (ratio == partial_step && active[position] < active[current])
3339                        }
3340                    };
3341                    if replaces {
3342                        partial_step = ratio;
3343                        blocking = Some(position);
3344                    }
3345                }
3346
3347                if !full_step.is_finite() && blocking.is_none() {
3348                    return Err(EstimationError::ParameterConstraintViolation(format!(
3349                        "operator metric projection proved its constraint set infeasible: row \
3350                         {entering} is violated by {remaining_violation:.3e} and lies in the span \
3351                         of the {} active normals with no releasable multiplier",
3352                        active.len(),
3353                    )));
3354                }
3355                let step = full_step.min(partial_step);
3356                if !step.is_finite() {
3357                    crate::bail_invalid_estim!(
3358                        "operator metric projection produced a non-finite dual step for row \
3359                         {entering}"
3360                    );
3361                }
3362                if step > 0.0 {
3363                    let primal_direction =
3364                        back_substitution_lower_transpose(lower.view(), tangent.view());
3365                    beta.scaled_add(step, &primal_direction);
3366                    if !array_is_finite(&beta) {
3367                        crate::bail_invalid_estim!(
3368                            "operator metric projection iterate left the finite range"
3369                        );
3370                    }
3371                    for (multiplier, direction) in
3372                        multipliers.iter_mut().zip(dual_direction.iter())
3373                    {
3374                        *multiplier = (*multiplier - step * direction).max(0.0);
3375                    }
3376                }
3377                entering_multiplier += step;
3378                if !entering_multiplier.is_finite() {
3379                    crate::bail_invalid_estim!(
3380                        "operator metric projection accumulated a non-finite multiplier for \
3381                         entering row {entering}"
3382                    );
3383                }
3384
3385                transitions += 1;
3386                if transitions > max_transitions {
3387                    return Err(EstimationError::ParameterConstraintViolation(format!(
3388                        "operator metric projection reached its bounded-work limit of \
3389                         {max_transitions} active-set transitions with {} active rows",
3390                        active.len(),
3391                    )));
3392                }
3393
3394                if full_step <= partial_step {
3395                    active.push(entering);
3396                    is_active[entering] = true;
3397                    whitened_active.push(whitened_normal);
3398                    multipliers.push(entering_multiplier);
3399                    continue 'dual;
3400                }
3401                // For a partial step, `step < remaining_violation / rate`,
3402                // hence this quantity is strictly positive in exact
3403                // arithmetic. Track that algebraic residual instead of
3404                // re-reading a cancellation-prone row dot-product; terminal
3405                // original-metric conditioning owns the forward-error repair.
3406                remaining_violation =
3407                    (-step).mul_add(rate, remaining_violation).max(0.0);
3408                let leaving = blocking.expect("a finite partial step names a blocking row");
3409                let leaving_row = active.remove(leaving);
3410                whitened_active.remove(leaving);
3411                multipliers.remove(leaving);
3412                is_active[leaving_row] = false;
3413            }
3414        }
3415
3416        // Terminal conditioning. Every dual step preserved `Nβ = b_N` in exact
3417        // arithmetic, so re-deriving the endpoint from the face's KKT system in
3418        // the ORIGINAL metric changes nothing mathematically — it removes only
3419        // the drift accumulated along the step path, which is exactly what an
3420        // absolute feasibility certificate stated in un-whitened row units
3421        // measures. The refinement also releases any multiplier that
3422        // conditioning pushed materially negative; a release re-enters the dual
3423        // iteration from the conditioned face rather than being certified.
3424        let refined = refine_operator_metric_face(
3425            hessian,
3426            rhs,
3427            unconstrained,
3428            ops,
3429            &mut active,
3430            &mut is_active,
3431            &mut transitions,
3432        )?;
3433        if transitions > max_transitions {
3434            return Err(EstimationError::ParameterConstraintViolation(format!(
3435                "operator metric projection reached its bounded-work limit of \
3436                 {max_transitions} active-set transitions during original-metric face \
3437                 conditioning with {} active rows",
3438                active.len(),
3439            )));
3440        }
3441        if !active.is_empty() {
3442            let active_rows = ops.gather_unit_rows(&active)?;
3443            let equality =
3444                certify_active_equalities(&active_rows.a, &active_rows.b, &refined.0);
3445            if !equality.is_certified() {
3446                return Err(EstimationError::ParameterConstraintViolation(format!(
3447                    "operator metric projection conditioned face failed its active-equality \
3448                     certificate at constraint row {} (active position {}): absolute residual \
3449                     {:.3e} exceeds roundoff bound {:.3e} over {} active rows",
3450                    active[equality.worst_row],
3451                    equality.worst_row,
3452                    equality.residual,
3453                    equality.allowed,
3454                    active.len(),
3455                )));
3456            }
3457        }
3458        let values = ops.values(&refined.0)?;
3459        let scan = scan_operator_violations(ops, &values, &is_active)?;
3460        if scan.is_primal_feasible() {
3461            break refined;
3462        }
3463        if scan.inactive.is_empty() {
3464            return Err(EstimationError::ParameterConstraintViolation(format!(
3465                "operator metric projection found all-row scaled violation {:.3e} at row {} \
3466                 after certifying {} active equalities, but found no inactive separator",
3467                scan.worst.violation,
3468                scan.worst.row,
3469                active.len(),
3470            )));
3471        }
3472        // The conditioned face solve is itself a valid dual state — `β` is the
3473        // exact equality-constrained minimizer and every multiplier is
3474        // nonnegative — so the dual iteration resumes from it directly. There
3475        // is no separate conditioning-round budget: every nonterminal scan now
3476        // contains an inactive separator, and the pending-separator invariant
3477        // guarantees that processing it records a transition or returns a typed
3478        // refusal. The counters above bound operational work; they are not the
3479        // exact-arithmetic finite-termination proof.
3480        beta = refined.0;
3481        multipliers = refined.1.to_vec();
3482        whitened_active.clear();
3483        if !active.is_empty() {
3484            let face_rows = ops.gather_unit_rows(&active)?;
3485            for position in 0..active.len() {
3486                whitened_active.push(forward_substitution_lower_vector(
3487                    lower.view(),
3488                    face_rows.a.row(position),
3489                ));
3490            }
3491        }
3492        queue.clear();
3493        queue.extend(scan.inactive.iter().map(|entry| entry.row));
3494    };
3495
3496    let active_ids = active.clone();
3497    let gradient = hessian.dot(&candidate) - rhs;
3498    let (stationarity, complementarity, dual_violation) = if active_ids.is_empty() {
3499        (gradient_inf_norm(&gradient), 0.0, 0.0)
3500    } else {
3501        let rows = ops.gather_unit_rows(&active_ids)?;
3502        let residual = &gradient - &rows.a.t().dot(&refined_multipliers);
3503        let complementarity = refined_multipliers
3504            .iter()
3505            .enumerate()
3506            .map(|(position, multiplier)| {
3507                let slack = rows.a.row(position).dot(&candidate) - rows.b[position];
3508                (multiplier * slack).abs()
3509            })
3510            .fold(0.0_f64, f64::max);
3511        let dual_violation = refined_multipliers
3512            .iter()
3513            .map(|multiplier| (-multiplier).max(0.0))
3514            .fold(0.0_f64, f64::max);
3515        (
3516            gradient_inf_norm(&residual),
3517            complementarity,
3518            dual_violation,
3519        )
3520    };
3521    let gradient_scale = gradient_inf_norm(&gradient).max(1.0);
3522    if stationarity > ACTIVE_SET_KKT_STATIONARITY_TOL
3523        && stationarity / gradient_scale > ACTIVE_SET_KKT_STATIONARITY_TOL
3524    {
3525        // WHICH of the two things failed is not recoverable from `residual`
3526        // alone, and they need opposite repairs (#2592).
3527        //
3528        // Stationarity here is `|| g - A_Aᵀ μ ||` for the multipliers this walk
3529        // recovered. That number is large either because
3530        //
3531        //   (a) the point is not stationary on its own face -- `g` has real
3532        //       mass in the face TANGENT, which no multipliers can absorb, so
3533        //       the solve stopped somewhere that is not the constrained
3534        //       minimizer; or
3535        //   (b) the point IS stationary and the MULTIPLIERS are wrong -- `g`
3536        //       lies (nearly) in the row space of `A_A`, and the walk simply
3537        //       failed to recover the `μ` that represents it, e.g. on a
3538        //       rank-deficient face.
3539        //
3540        // The discriminator is the smallest residual ANY multipliers could
3541        // achieve, `min_μ || g - A_Aᵀ μ ||`, which is exactly the norm of `g`
3542        // projected onto the face tangent. Report it beside the achieved one:
3543        // near zero means (b), near `residual` means (a). A ridge keeps the
3544        // normal-equation solve defined on a rank-deficient face, which is the
3545        // very case this diagnostic exists to name; it only makes the reported
3546        // achievable residual an UPPER bound, so it can never turn (a) into (b).
3547        let achievable = if active_ids.is_empty() {
3548            Some(gradient_inf_norm(&gradient))
3549        } else {
3550            ops.gather_unit_rows(&active_ids).ok().and_then(|rows| {
3551                let gram = rows.a.dot(&rows.a.t());
3552                let ridge = 1.0e-12 * gram.diag().iter().fold(0.0_f64, |m, v| m.max(v.abs()));
3553                let mut regularized = gram;
3554                for i in 0..regularized.nrows() {
3555                    regularized[[i, i]] += ridge.max(f64::MIN_POSITIVE);
3556                }
3557                regularized.cholesky(Side::Lower).ok().map(|factor| {
3558                    let least_squares = factor.solvevec(&rows.a.dot(&gradient));
3559                    gradient_inf_norm(&(&gradient - &rows.a.t().dot(&least_squares)))
3560                })
3561            })
3562        };
3563        let achievable_report = achievable.map_or_else(
3564            || "unmeasured".to_string(),
3565            |value| format!("{value:.3e}"),
3566        );
3567        let verdict = match achievable {
3568            Some(value) if value > 0.5 * stationarity => {
3569                "the face TANGENT carries the residual, so this point is not the \
3570                 constrained minimizer of its own face"
3571            }
3572            Some(_) => {
3573                "the residual lies in the face ROW SPACE, so the point is stationary \
3574                 and the recovered multipliers do not represent its gradient"
3575            }
3576            None => "achievable residual unmeasured (face gram not factorizable)",
3577        };
3578        return Err(EstimationError::ParameterConstraintViolation(format!(
3579            "operator metric projection failed stationarity certification: \
3580             residual={stationarity:.3e}, relative={:.3e}, active={}, transitions={transitions}, \
3581             achievable={achievable_report} (best over all multipliers), gradient_scale={gradient_scale:.3e}; \
3582             {verdict}",
3583            stationarity / gradient_scale,
3584            active_ids.len(),
3585        )));
3586    }
3587    // The multipliers carry the gradient's units: `μ` solves `g − A_Aᵀ μ = 0`
3588    // on unit rows, so a penalty-dominated system (`‖g‖∞ ≈ 1e9` at a high-λ
3589    // seed, `β ≈ 1`) has `μ ≈ 1e9` and a roundoff slack of `1e-15` on a
3590    // certified-active row reads as `|μ·slack| ≈ 1e-6` — the absolute bar,
3591    // met by exact arithmetic on that very face. Stationarity above is
3592    // already judged absolute-OR-relative to `gradient_scale`; the two dual
3593    // channels are certified in the same units so the three KKT residuals
3594    // share one scale (gam#2695, gam#2714: every seed of the survival
3595    // location-scale fits was refused here with `dual = 0`, `complementarity
3596    // ≈ 1e-6`, `rhs_inf ≈ 1.9e9`, and the outer search never started).
3597    let (dual_infeasible, complementarity_violated) =
3598        kkt_dual_channel_violations(dual_violation, complementarity, gradient_scale);
3599    if dual_infeasible || complementarity_violated {
3600        return Err(EstimationError::ParameterConstraintViolation(format!(
3601            "operator metric projection failed dual/complementarity certification: \
3602             dual={dual_violation:.3e}, complementarity={complementarity:.3e} \
3603             (relative to gradient_scale={gradient_scale:.3e}: dual={:.3e}, \
3604             complementarity={:.3e}), active={}",
3605            dual_violation / gradient_scale,
3606            complementarity / gradient_scale,
3607            active_ids.len(),
3608        )));
3609    }
3610    Ok((candidate, active_ids))
3611}
3612
3613/// Constrained quadratic solve: minimize `½ βᵀHβ − rhsᵀβ` subject to the
3614/// [`ConstraintSet`]. Dense and factored carriers use the same dual active-set
3615/// metric projection; only row products and row gathering differ.
3616///
3617/// Same public feasibility contract as
3618/// [`solve_quadratic_with_linear_constraints`]: the returned point is feasible
3619/// to [`ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`] or the solve errors. For an operator
3620/// carrier the Hessian must be strictly positive definite; the unique minimizer
3621/// then satisfies
3622///
3623/// ```text
3624/// β = u + H⁻¹ Cᵀ μ,   Cβ − d ≥ 0,   μ ≥ 0,   μ ⊙ (Cβ − d) = 0
3625/// ```
3626///
3627/// for `u = H⁻¹rhs` and unit-scaled rows `C` with bounds `d`, and
3628/// [`solve_operator_metric_projection_dual_active_set`] computes it exactly.
3629/// This is also a semantic boundary: a quadratic-projection API either returns
3630/// the certified minimizer or an error. It never substitutes a generic feasible
3631/// descent direction after exhausting a working-set path (#979).
3632fn solve_strictly_convex_quadratic_with_constraint_set_dual(
3633    hessian: &Array2<f64>,
3634    rhs: &Array1<f64>,
3635    beta_start: &Array1<f64>,
3636    set: &ConstraintSet,
3637    warm_active_set: Option<&[usize]>,
3638) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3639    let p = rhs.len();
3640    if p == 0
3641        || hessian.nrows() != p
3642        || hessian.ncols() != p
3643        || beta_start.len() != p
3644        || set.ncols() != p
3645        || hessian.iter().any(|value| !value.is_finite())
3646        || rhs.iter().any(|value| !value.is_finite())
3647        || beta_start.iter().any(|value| !value.is_finite())
3648    {
3649        crate::bail_invalid_estim!("operator metric-projection dimension/finite contract failed");
3650    }
3651    let factor = hessian.cholesky(Side::Lower).map_err(|error| {
3652        EstimationError::InvalidInput(format!(
3653            "operator metric projection requires a strictly positive-definite Hessian: {error}"
3654        ))
3655    })?;
3656    let unconstrained = factor.solvevec(rhs);
3657    if !array_is_finite(&unconstrained) {
3658        crate::bail_invalid_estim!("operator metric-projection free solve is non-finite");
3659    }
3660
3661    let ops = ConstraintSetOps::new(set, 0.0)?;
3662    // Warm faces are point-local: globalization may accept only part of the
3663    // previous QP chord, so retain only cached rows that remain tight at this
3664    // cycle's accepted `beta_start`. They are an ENTERING ORDER, not a preset
3665    // basis — the dual solve admits each through the same certified step as any
3666    // other row, so a stale hint costs a skipped queue pop and nothing else.
3667    let warm_tight =
3668        constraint_set_rows_tight_at_point(set, beta_start, warm_active_set.unwrap_or(&[]))?;
3669    let (candidate, dual_basis) = solve_operator_metric_projection_dual_active_set(
3670        hessian,
3671        rhs,
3672        &unconstrained,
3673        &factor,
3674        &ops,
3675        &warm_tight,
3676    )?;
3677
3678    // ── what "active" means to a caller ─────────────────────────────────────
3679    //
3680    // `dual_basis` is what the dual walk ADMITTED, and it admits a row only
3681    // when the running candidate VIOLATES it. A row the caller handed in as its
3682    // current face, which the answer still lies on, is therefore never admitted
3683    // and never listed. Every consumer reads this set as the FACE the answer
3684    // lies on: it nulls the set's tangent before certifying curvature, seeds the
3685    // next cycle's working set from it, and decides which blockers were
3686    // resolved. A silently dropped face row makes the consumer count a direction
3687    // free that the polytope pins — which is precisely how a PHANTOM saddle is
3688    // manufactured, a failure `resolve_constrained_converged_mode` already names
3689    // and repairs with `widen_active_sets_to_tight_face` at one call site
3690    // (#2589; #2432 turned six reduced-face tests red on exactly this, each
3691    // reporting `[1]` where the face is `[0, 1]`).
3692    //
3693    // So report the rows the solve actually established the answer on: the ones
3694    // it admitted, plus the caller's incoming face rows that the ANSWER is tight
3695    // on. Note that is tight at the ANSWER, not at `beta_start`: a warm row can
3696    // be slack at entry and pinned at the endpoint, which is exactly the
3697    // projection case (`beta_start = 1e-9`, answer `0`), so `warm_tight` above
3698    // is the wrong set to widen with.
3699    //
3700    // Rank-reduce the union, because a degenerate vertex can hand back four
3701    // parallel rows for one geometric face and this is a face description, not a
3702    // row list. Deliberately NOT a rescan of every row: an operator carrier can
3703    // present thousands of equivalent rows (a Khatri-Rao cone tight at one
3704    // coefficient point) and expanding to all of them is what
3705    // `operator_cone_does_not_materialize_a_whole_tight_face` forbids. The union
3706    // is bounded by the rows this solve was already given or found.
3707    let dual_basis_rows = dual_basis.len();
3708    let mut face_candidates = dual_basis;
3709    for row in constraint_set_rows_tight_at_point(set, &candidate, warm_active_set.unwrap_or(&[]))?
3710    {
3711        if !face_candidates.contains(&row) {
3712            face_candidates.push(row);
3713        }
3714    }
3715    if face_candidates.len() == dual_basis_rows {
3716        return Ok((candidate, face_candidates));
3717    }
3718    // The dual's own rows lead, so rank reduction keeps them as the
3719    // representatives when an incoming row is parallel to one of them.
3720    let gathered = ops.gather_unit_rows(&face_candidates)?;
3721    let groups: Vec<Vec<usize>> = face_candidates.iter().map(|row| vec![*row]).collect();
3722    let (_, _, kept, _) =
3723        rank_reduce_rows_pivoted_qr_with_dependence(gathered.a, gathered.b, groups);
3724    // One id per kept group, not every member of it. Rank reduction MERGES a
3725    // parallel class into a single group rather than dropping its members, so
3726    // flattening would hand back all four rows of a degenerate vertex and
3727    // undo the reduction. The lowest index is the canonical representative,
3728    // which is also what `accepted_face_is_canonical_across_degenerate_qp_row_bases`
3729    // expects of a face that must not carry the path that produced it.
3730    let mut face_rows: Vec<usize> = kept
3731        .into_iter()
3732        .filter_map(|group| group.into_iter().min())
3733        .collect();
3734    face_rows.sort_unstable();
3735    face_rows.dedup();
3736    Ok((candidate, face_rows))
3737}
3738
3739pub fn solve_quadratic_with_constraint_set(
3740    hessian: &Array2<f64>,
3741    rhs: &Array1<f64>,
3742    beta_start: &Array1<f64>,
3743    set: &ConstraintSet,
3744    warm_active_set: Option<&[usize]>,
3745) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3746    match set {
3747        ConstraintSet::Dense(dense) => solve_quadratic_with_linear_constraints(
3748            hessian,
3749            rhs,
3750            beta_start,
3751            dense,
3752            warm_active_set,
3753        ),
3754        _ => {
3755            if hessian.ncols() != hessian.nrows()
3756                || rhs.len() != hessian.nrows()
3757                || beta_start.len() != hessian.nrows()
3758                || set.ncols() != hessian.nrows()
3759            {
3760                crate::bail_invalid_estim!(
3761                    "operator-constrained quadratic solve: system dimension mismatch"
3762                );
3763            }
3764            solve_strictly_convex_quadratic_with_constraint_set_dual(
3765                hessian,
3766                rhs,
3767                beta_start,
3768                set,
3769                warm_active_set,
3770            )
3771        }
3772    }
3773}
3774
3775pub(crate) fn solve_newton_direction_with_linear_constraints(
3776    hessian: &Array2<f64>,
3777    gradient: &Array1<f64>,
3778    beta: &Array1<f64>,
3779    constraints: &LinearInequalityConstraints,
3780    direction_out: &mut Array1<f64>,
3781    active_hint: Option<&mut Vec<usize>>,
3782) -> Result<(), EstimationError> {
3783    if hessian.nrows() != hessian.ncols()
3784        || gradient.len() != hessian.nrows()
3785        || beta.len() != hessian.nrows()
3786        || constraints.a.ncols() != hessian.nrows()
3787    {
3788        crate::bail_invalid_estim!("linear-constrained Newton system dimension mismatch");
3789    }
3790    // `gradient = H·beta - rhs` for the local quadratic model, hence
3791    // `rhs = H·beta - gradient`. Solve the strict-convex QP itself rather than
3792    // asking the legacy primal face walk for a merely feasible descent chord:
3793    // a Newton-step API that returns before KKT stationarity makes the outer
3794    // solver optimize a different object on the next cycle (#2366/#2432).
3795    let rhs = hessian.dot(beta) - gradient;
3796    let warm_active = active_hint.as_ref().map(|hint| hint.as_slice());
3797    let (candidate, active) = solve_quadratic_with_linear_constraints(
3798        hessian,
3799        &rhs,
3800        beta,
3801        constraints,
3802        warm_active,
3803    )?;
3804    if direction_out.len() != beta.len() {
3805        *direction_out = Array1::zeros(beta.len());
3806    }
3807    direction_out.assign(&(&candidate - beta));
3808    if let Some(hint) = active_hint {
3809        hint.clear();
3810        hint.extend(active);
3811    }
3812    Ok(())
3813}
3814
3815pub fn solve_quadratic_with_linear_constraints(
3816    hessian: &Array2<f64>,
3817    rhs: &Array1<f64>,
3818    beta_start: &Array1<f64>,
3819    constraints: &LinearInequalityConstraints,
3820    warm_active_set: Option<&[usize]>,
3821) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3822    if hessian.ncols() != hessian.nrows()
3823        || rhs.len() != hessian.nrows()
3824        || beta_start.len() != hessian.nrows()
3825        || constraints.a.ncols() != hessian.nrows()
3826    {
3827        crate::bail_invalid_estim!("constrained quadratic solve: system dimension mismatch");
3828    }
3829    // Canonicalize at the chokepoint: reject non-finite / infeasible-zero rows
3830    // and unit-normalize every row, so all downstream slack, activation, and
3831    // rank tolerances are geometric (scale-free) regardless of the units the
3832    // caller expressed the constraints in. Row order is preserved, so
3833    // `warm_active_set` indices and the returned active ids stay valid.
3834    let constraints = constraints.canonicalized().map_err(|e| {
3835        EstimationError::ParameterConstraintViolation(format!(
3836            "constrained quadratic solve: invalid constraint system: {e}"
3837        ))
3838    })?;
3839    // Dense and factored carriers are the same mathematical problem. The old
3840    // Dense arm used a separate primal add/drop walk with no monotone merit
3841    // function and, on the competing-risks fixture, stopped on a feasible but
3842    // nonstationary 3/332 face. Route it through the finite dual metric
3843    // projection already used by operator carriers: every admitted face is an
3844    // exact equality-constrained minimizer, multipliers remain nonnegative, and
3845    // a full pivot strictly increases the dual objective. Warm rows affect
3846    // ordering only, never the unique answer.
3847    let set = ConstraintSet::Dense(constraints);
3848    solve_strictly_convex_quadratic_with_constraint_set_dual(
3849        hessian,
3850        rhs,
3851        beta_start,
3852        &set,
3853        warm_active_set,
3854    )
3855}
3856
3857#[cfg(test)]
3858mod tests {
3859
3860    /// The dual KKT channels are judged relative to the gradient scale, as the
3861    /// stationarity channel is (gam#2695, gam#2714): the measured refusal —
3862    /// `dual = 0`, `|μ·slack| = 4.778e-6` under `‖g‖∞ = 1.913580e9` — is a
3863    /// certified face, the same product at unit scale is a real violation, and
3864    /// a violation that is material at the gradient's own scale is refused.
3865    #[test]
3866    fn dual_kkt_channels_are_judged_at_the_gradient_scale_2695() {
3867        let (dual, complementarity) = super::kkt_dual_channel_violations(0.0, 4.778e-6, 1.913580e9);
3868        assert!(!dual && !complementarity, "a roundoff slack under a 2e9 multiplier is certified");
3869        let (dual, complementarity) = super::kkt_dual_channel_violations(0.0, 4.778e-6, 1.0);
3870        assert!(!dual && complementarity, "the same product at unit gradient scale is refused");
3871        let (dual, complementarity) = super::kkt_dual_channel_violations(0.0, 1.0e-4 * 1.913580e9, 1.913580e9);
3872        assert!(!dual && complementarity, "a material relative complementarity is refused");
3873        let (dual, complementarity) = super::kkt_dual_channel_violations(0.0, 0.0, 1.0);
3874        assert!(!dual && !complementarity, "a zero dual violation is certified");
3875        let (dual, complementarity) = super::kkt_dual_channel_violations(3.0e-8, 0.0, 1.0);
3876        assert!(dual && !complementarity, "a negative multiplier at unit scale is refused");
3877        let (dual, complementarity) = super::kkt_dual_channel_violations(3.0e-8, 0.0, 1.0e9);
3878        assert!(!dual && !complementarity, "the same multiplier under a 1e9 gradient is roundoff");
3879    }
3880    #[test]
3881    fn a_metric_projection_lands_on_the_binding_face_and_names_it_2695() {
3882        use ndarray::array;
3883        // x >= 0, y >= 0, x + y >= 1, written as A·β >= b.
3884        let set = ConstraintSet::Dense(
3885            LinearInequalityConstraints::new(
3886                array![[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]],
3887                array![0.0, 0.0, 1.0],
3888            )
3889            .expect("constraint construction"),
3890        );
3891        // Euclidean projection of the origin onto x + y >= 1 is (½, ½); only the
3892        // diagonal row binds there, and it binds exactly — no interior margin.
3893        let (euclid, face) =
3894            super::project_point_onto_constraint_set_in_metric(&array![0.0, 0.0], None, &set, None)
3895                .expect("euclidean projection");
3896        assert!((euclid[0] - 0.5).abs() <= 1e-12 && (euclid[1] - 0.5).abs() <= 1e-12, "{euclid:?}");
3897        assert_eq!(face, vec![2], "the binding row is reported exactly");
3898        assert!((euclid[0] + euclid[1] - 1.0).abs() <= 1e-12, "the point sits ON the face");
3899        // In the metric diag(1, 4) the same projection minimises x² + 4y² on the
3900        // face, which is (4/5, 1/5): the metric is honoured, not merely accepted.
3901        let (metric, face) = super::project_point_onto_constraint_set_in_metric(
3902            &array![0.0, 0.0],
3903            Some(&array![1.0, 4.0]),
3904            &set,
3905            None,
3906        )
3907        .expect("metric projection");
3908        assert!((metric[0] - 0.8).abs() <= 1e-12 && (metric[1] - 0.2).abs() <= 1e-12, "{metric:?}");
3909        assert_eq!(face, vec![2]);
3910        // A feasible interior point is returned unchanged with an empty face.
3911        let (same, face) = super::project_point_onto_constraint_set_in_metric(
3912            &array![0.7, 0.9],
3913            Some(&array![3.0, 0.5]),
3914            &set,
3915            None,
3916        )
3917        .expect("interior projection");
3918        assert!((same[0] - 0.7).abs() <= 1e-12 && (same[1] - 0.9).abs() <= 1e-12);
3919        assert!(face.is_empty(), "no row binds at an interior point: {face:?}");
3920        // (-1, 0.2) projects onto the line x + y = 1 at x = -0.1, outside the
3921        // quadrant, so the true projection is the vertex (0, 1) where x >= 0 and
3922        // x + y >= 1 bind together — both are named.
3923        let (vertex, mut face) = super::project_point_onto_constraint_set_in_metric(
3924            &array![-1.0, 0.2],
3925            None,
3926            &set,
3927            None,
3928        )
3929        .expect("vertex projection");
3930        face.sort_unstable();
3931        assert!(vertex[0].abs() <= 1e-12 && (vertex[1] - 1.0).abs() <= 1e-12, "{vertex:?}");
3932        assert_eq!(face, vec![0, 2], "both rows bind at the vertex");
3933    }
3934
3935    use super::{
3936        ACTIVE_SET_INTERIOR_SEED_MARGIN, ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL,
3937        ACTIVE_SET_PRIMAL_FEASIBILITY_TOL, ConstraintRowId, ConstraintSet, ConstraintSetOps,
3938        ConstraintSetReducedFace, LinearInequalityConstraints,
3939        array_is_finite, certify_active_equalities, compute_constraint_kkt_diagnostics,
3940        constraint_set_rows_tight_at_point,
3941        independent_violated_operator_rows,
3942        khatri_rao_cone_reduced_face, least_squares_min_norm_any_shape,
3943        nonnegative_cone_multipliers,
3944        project_point_strictly_into_feasible_cone,
3945        project_point_strictly_into_feasible_constraint_set,
3946        project_stationarity_residual_on_constraint_cone,
3947        project_stationarity_residual_on_constraint_set,
3948        rank_reduce_rows_pivoted_qr_with_dependence,
3949        scaled_constraint_slack, scan_operator_violations, solve_kkt_direction,
3950        solve_newton_direction_with_linear_constraints, solve_quadratic_with_constraint_set,
3951        solve_quadratic_with_linear_constraints,
3952
3953    };
3954    use crate::estimate::EstimationError;
3955    use approx::assert_relative_eq;
3956    use gam_problem::KhatriRaoConeConstraints;
3957    use ndarray::{Array1, Array2, array};
3958
3959    fn gather_linear_constraint_rows(
3960        constraints: &LinearInequalityConstraints,
3961        rows: &[usize],
3962    ) -> Result<LinearInequalityConstraints, EstimationError> {
3963        let p = constraints.a.ncols();
3964        let mut a = Array2::<f64>::zeros((rows.len(), p));
3965        let mut b = Array1::<f64>::zeros(rows.len());
3966        for (out, &row) in rows.iter().enumerate() {
3967            if row >= constraints.a.nrows() {
3968                crate::bail_invalid_estim!(
3969                    "active constraint row {} out of bounds for {} rows",
3970                    row,
3971                    constraints.a.nrows()
3972                );
3973            }
3974            a.row_mut(out).assign(&constraints.a.row(row));
3975            b[out] = constraints.b[row];
3976        }
3977        LinearInequalityConstraints::new(a, b)
3978            .map_err(|error| EstimationError::ParameterConstraintViolation(error.to_string()))
3979    }
3980
3981    fn moreau_projection_via_strict_qp(
3982        residual: &Array1<f64>,
3983        active_a: &Array2<f64>,
3984    ) -> Option<(Array1<f64>, Array1<f64>)> {
3985        let p = residual.len();
3986        let m = active_a.nrows();
3987        let constraints =
3988            LinearInequalityConstraints::new(active_a.clone(), Array1::<f64>::zeros(m))
3989                .ok()?
3990                .canonicalized()
3991                .ok()?;
3992
3993        // Independent oracle: solve the strictly convex primal tangent-cone QP
3994        // and reconstruct its canonical-face multipliers.
3995        let identity = Array2::<f64>::eye(p);
3996        let origin = Array1::<f64>::zeros(p);
3997        let rhs = -residual;
3998        let (tangent_direction, tangent_active) = solve_quadratic_with_linear_constraints(
3999            &identity,
4000            &rhs,
4001            &origin,
4002            &constraints,
4003            None,
4004        )
4005        .ok()?;
4006        if !array_is_finite(&tangent_direction) {
4007            return None;
4008        }
4009        let projected = -&tangent_direction;
4010
4011        let mut lambda_canonical = Array1::<f64>::zeros(m);
4012        if !tangent_active.is_empty() {
4013            let gathered = gather_linear_constraint_rows(&constraints, &tangent_active).ok()?;
4014            let design = gathered.a.t().to_owned();
4015            let solved =
4016                least_squares_min_norm_any_shape(&design, &(residual + &tangent_direction))?;
4017            let scale = residual
4018                .iter()
4019                .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
4020                .max(1.0);
4021            let tol = 100.0 * f64::EPSILON * (p.max(m) as f64) * scale;
4022            for (position, &row) in tangent_active.iter().enumerate() {
4023                let value = solved[position];
4024                if !value.is_finite() || value < -tol {
4025                    return None;
4026                }
4027                lambda_canonical[row] = value.max(0.0);
4028            }
4029        }
4030        let reconstructed = residual - &constraints.a.t().dot(&lambda_canonical);
4031        let reconstruction_error = reconstructed
4032            .iter()
4033            .zip(projected.iter())
4034            .fold(0.0_f64, |acc, (&left, &right)| {
4035                acc.max((left - right).abs())
4036            });
4037        let scale = residual
4038            .iter()
4039            .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
4040            .max(1.0);
4041        if reconstruction_error > 1e-8 * scale || !array_is_finite(&lambda_canonical) {
4042            return None;
4043        }
4044
4045        let mut lambda = Array1::<f64>::zeros(m);
4046        for row in 0..m {
4047            let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
4048            if norm > 0.0 {
4049                lambda[row] = lambda_canonical[row] / norm;
4050            }
4051        }
4052        Some((projected, lambda))
4053    }
4054
4055    #[test]
4056    fn active_equality_certificate_rejects_public_tolerance_band_drift() {
4057        // The #979 production endpoint was accepted by the public 1e-8 primal
4058        // gate while carrying this much active-equality drift. Against a
4059        // unit-normalized active row, that is many orders above representational
4060        // roundoff and must not seed the next reduced-face quadratic.
4061        let active_a = array![[1.0, 0.0]];
4062        let rhs = array![0.0];
4063        let direction = array![8.604942e-9, 0.0];
4064        let certificate = certify_active_equalities(&active_a, &rhs, &direction);
4065        assert!(
4066            !certificate.is_certified(),
4067            "a tolerance-band endpoint is not a roundoff-resolved active equality"
4068        );
4069        assert_eq!(certificate.worst_row, 0);
4070        assert_relative_eq!(certificate.residual, 8.604942e-9, epsilon = 0.0);
4071        assert!(certificate.residual > 1.0e6 * certificate.allowed);
4072    }
4073
4074    #[test]
4075    fn active_equality_certificate_uses_the_solve_scale_not_the_collapsed_row_scale() {
4076        // A degenerate face: row 0 is supported only on coordinate 2, and the
4077        // solve drove that coordinate to a pure-underflow residue while the rest
4078        // of the direction stayed O(1). This is the ordinary state of a factored
4079        // cone whose coefficient block has gone to zero — every observation row
4080        // over that block is tight at once.
4081        //
4082        // Bounding the row by `sum_j |a_ij d_j|` alone makes the tolerance
4083        // collapse WITH the coordinate (~1e-48 here), so the certificate demands
4084        // an equality residual no f64 arithmetic can produce and the face can
4085        // never be certified. That is the observed #979 refusal signature:
4086        // residual/allowed pinned at ~1/eps regardless of the actual geometry.
4087        let active_a = array![[0.0, 0.0, 1.0, 0.0], [1.0, 0.0, 0.0, 0.0]];
4088        let rhs = array![0.0, 0.5];
4089        let collapsed = array![0.5, 0.3, 1.0e-33, 0.0];
4090        let certificate = certify_active_equalities(&active_a, &rhs, &collapsed);
4091        assert!(
4092            certificate.is_certified(),
4093            "an equality residual {:.3e} that is 1e-33 of the solve scale is \
4094             roundoff-resolved, not a face defect (allowed {:.3e})",
4095            certificate.residual,
4096            certificate.allowed
4097        );
4098
4099        // …and the ambient scale does NOT become a blanket loosening: a drift in
4100        // the public tolerance band on the same face is still refused, because
4101        // `gamma · ||a_i||_1 · ||d||_inf` is ~1e-16 here, not ~1e-9.
4102        let drifted = array![0.5, 0.3, 1.0e-9, 0.0];
4103        let certificate = certify_active_equalities(&active_a, &rhs, &drifted);
4104        assert!(
4105            !certificate.is_certified(),
4106            "a 1e-9 equality drift against an O(1) solve scale is a real defect"
4107        );
4108        assert_eq!(certificate.worst_row, 0);
4109    }
4110
4111    #[test]
4112    fn stiff_null_space_solve_returns_roundoff_resolved_active_equality() {
4113        // A strongly anisotropic SPD metric coupled to an oblique equality.
4114        // Normwise backward stability against the 1e16 Hessian entry alone is
4115        // insufficient: the active equality itself must resolve to its
4116        // length-p dot-product floor.
4117        let hessian = array![[1.0e16, 1.0e8], [1.0e8, 2.0]];
4118        let gradient = array![1.0e8, -3.0];
4119        let active_a = array![[0.6, 0.8]];
4120        let active_residual = array![1.0e-4];
4121        let (direction, multiplier) =
4122            solve_kkt_direction(&hessian, &gradient, &active_a, Some(&active_residual))
4123                .expect("stiff null-space constrained solve");
4124
4125        let certificate =
4126            certify_active_equalities(&active_a, &active_residual, &direction);
4127        assert!(
4128            certificate.is_certified(),
4129            "active equality residual {:.3e} exceeds its roundoff bound {:.3e}",
4130            certificate.residual,
4131            certificate.allowed,
4132        );
4133        assert!(multiplier.iter().all(|value| value.is_finite()));
4134    }
4135
4136    #[test]
4137    fn dependent_active_equalities_share_one_null_space() {
4138        // Two scaled copies of one equality describe one geometric face. The
4139        // SVD must retain that rank-one row space, optimize in its orthogonal
4140        // complement, and return a direction satisfying both original rows.
4141        let hessian = array![
4142            [1.0e12, 0.0, 0.0],
4143            [0.0, 3.0, 0.5],
4144            [0.0, 0.5, 2.0],
4145        ];
4146        let gradient = array![2.0e5, -4.0, 1.0];
4147        let active_a = array![[1.0, 2.0, 0.0], [2.0, 4.0, 0.0]];
4148        let active_residual = array![1.0e-4, 2.0e-4];
4149        let (direction, multiplier) =
4150            solve_kkt_direction(&hessian, &gradient, &active_a, Some(&active_residual))
4151                .expect("rank-deficient active face must have one certified null space");
4152
4153        let residual = &active_a.dot(&direction) - &active_residual;
4154        assert!(
4155            residual.iter().all(|value| value.abs() <= 1.0e-14),
4156            "dependent active equations were not resolved: {residual:?}"
4157        );
4158        assert!(multiplier.iter().all(|value| value.is_finite()));
4159    }
4160
4161    #[test]
4162    fn warm_face_rows_are_point_local_for_dense_and_operator_constraints() {
4163        // The previous QP endpoint was x=0, where x>=0 binds, but a trust
4164        // step accepted only an interior point x=1. Reusing row 0 as an
4165        // equality at x=1 would solve the wrong problem and drive x back to
4166        // the stale boundary. The actual quadratic has its feasible minimizer
4167        // at x=2, so both carriers must discard the slack warm row and return
4168        // the unconstrained interior minimizer with an empty face.
4169        let hessian = array![[1.0_f64]];
4170        let rhs = array![2.0_f64];
4171        let interior = array![1.0_f64];
4172        let dense = LinearInequalityConstraints::new(array![[1.0]], array![0.0])
4173            .expect("one-dimensional half-line");
4174        let (dense_solution, dense_active) =
4175            solve_quadratic_with_linear_constraints(&hessian, &rhs, &interior, &dense, Some(&[0]))
4176                .expect("dense stale-face solve");
4177        assert_relative_eq!(dense_solution[0], 2.0, epsilon = 1e-12);
4178        assert!(dense_active.is_empty());
4179
4180        let factor = std::sync::Arc::new(array![[1.0_f64]]);
4181        let cone = KhatriRaoConeConstraints::new(factor, vec![0], 1)
4182            .expect("one-dimensional factored half-line");
4183        let operator = ConstraintSet::KhatriRaoCone(cone);
4184        let stale_terminal_face = constraint_set_rows_tight_at_point(&operator, &interior, &[0])
4185            .expect("terminal face classification");
4186        assert!(stale_terminal_face.is_empty());
4187        let (operator_solution, operator_active) =
4188            solve_quadratic_with_constraint_set(&hessian, &rhs, &interior, &operator, Some(&[0]))
4189                .expect("operator stale-face solve");
4190        assert_relative_eq!(operator_solution[0], 2.0, epsilon = 1e-12);
4191        assert!(operator_active.is_empty());
4192    }
4193
4194    /// A `β = 0` seed sits on the boundary of EVERY row of a homogeneous
4195    /// (`b = 0`) convex/concave second-difference cone — it is the cone vertex.
4196    /// The strict-interior projection must move it to a point with a strictly
4197    /// positive scaled slack on every row, so the inner active-set QP starts
4198    /// from an EMPTY working set rather than an all-rows-active degenerate face
4199    /// (the #873 cache-dependence root cause). The zero seed is the worst case:
4200    /// the nearest interior point is unique up to the margin, and a buggy
4201    /// "min-norm" feasibility fallback would return `0` again.
4202    #[test]
4203    fn strict_interior_projection_lifts_vertex_seed_off_every_constraint_row() {
4204        // Signed second-difference rows of a 5-coefficient concave smooth:
4205        // -(β_{i+2} - 2β_{i+1} + β_i) ≥ 0 for i = 0..3.
4206        let p = 5usize;
4207        let rows = p - 2;
4208        let mut a = Array2::<f64>::zeros((rows, p));
4209        for i in 0..rows {
4210            a[[i, i]] = -1.0;
4211            a[[i, i + 1]] = 2.0;
4212            a[[i, i + 2]] = -1.0;
4213        }
4214        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4215            .expect("test constraint shape invariant");
4216
4217        let vertex = Array1::<f64>::zeros(p);
4218        // The vertex is feasible (all rows exactly tight) but on every boundary.
4219        for i in 0..rows {
4220            assert!(
4221                scaled_constraint_slack(&vertex, &constraints, i).abs() < 1e-12,
4222                "vertex seed should sit exactly on row {i}"
4223            );
4224        }
4225
4226        let interior = project_point_strictly_into_feasible_cone(&vertex, &constraints)
4227            .expect("strict-interior projection of the vertex must succeed");
4228        let min_slack = (0..rows)
4229            .map(|i| scaled_constraint_slack(&interior, &constraints, i))
4230            .fold(f64::INFINITY, f64::min);
4231        assert!(
4232            min_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4233            "projected seed must be strictly interior on every row; min scaled slack = {min_slack:.3e}"
4234        );
4235    }
4236
4237    /// Mirrors `s(x, shape=concave, bc=clamped)`: shape curvature reparameterized
4238    /// to independent coordinate lower bounds `γ_j ≥ 0` (genuine one-sided rows),
4239    /// MERGED with a boundary condition encoded as an anti-parallel inequality
4240    /// PAIR `{r·β ≥ t, −r·β ≥ −t}` (an equality `r·β = t`). A naive
4241    /// shift-every-row-inward projection turns that pair into the empty set
4242    /// `t+δ ≤ r·β ≤ t−δ`, fails, and the caller falls back to the cone vertex —
4243    /// silently reintroducing the #873 seed for the combined case. The
4244    /// anti-parallel-aware margin must leave the equality pair tight while still
4245    /// pushing the genuine shape rows strictly interior.
4246    #[test]
4247    fn strict_interior_projection_keeps_equality_pairs_tight_with_shape_bounds() {
4248        let p = 5usize;
4249        // Rows 0..3: shape lower bounds γ_2,γ_3,γ_4 ≥ 0 (homogeneous, b = 0).
4250        // Rows 3,4: endpoint equality β_0 = 0 as {e_0·β ≥ 0, −e_0·β ≥ 0}.
4251        let m = 3 + 2;
4252        let mut a = Array2::<f64>::zeros((m, p));
4253        a[[0, 2]] = 1.0;
4254        a[[1, 3]] = 1.0;
4255        a[[2, 4]] = 1.0;
4256        a[[3, 0]] = 1.0;
4257        a[[4, 0]] = -1.0;
4258        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(m))
4259            .expect("test constraint shape invariant");
4260
4261        // A seed that violates the shape bounds (negative curvature coords) and
4262        // the equality (β_0 ≠ 0).
4263        let point = Array1::from_vec(vec![0.7, -0.2, -0.5, -0.3, -0.1]);
4264        let seed = project_point_strictly_into_feasible_cone(&point, &constraints).expect(
4265            "strict-interior projection must succeed when an equality pair is present, \
4266             not collapse to the empty set and fall back to the vertex",
4267        );
4268
4269        // Genuine one-sided shape rows are pushed strictly interior.
4270        for i in 0..3 {
4271            assert!(
4272                scaled_constraint_slack(&seed, &constraints, i)
4273                    >= 0.4 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4274                "shape row {i} not strictly interior: scaled slack = {:.3e}",
4275                scaled_constraint_slack(&seed, &constraints, i)
4276            );
4277        }
4278        // The equality pair stays tight (β_0 ≈ 0), i.e. the seed is projected
4279        // onto the boundary hyperplane rather than shifted off it.
4280        assert!(
4281            seed[0].abs() <= 1e-6,
4282            "boundary equality must be enforced, got β_0 = {:.3e}",
4283            seed[0]
4284        );
4285    }
4286
4287    /// A seed that already carries genuine (concave) curvature and clears the
4288    /// interior margin is returned essentially unchanged — the projection only
4289    /// nudges boundary/violating seeds, it does not discard usable curvature.
4290    #[test]
4291    fn strict_interior_projection_preserves_a_curvature_carrying_seed() {
4292        let p = 5usize;
4293        let rows = p - 2;
4294        let mut a = Array2::<f64>::zeros((rows, p));
4295        for i in 0..rows {
4296            a[[i, i]] = -1.0;
4297            a[[i, i + 1]] = 2.0;
4298            a[[i, i + 2]] = -1.0;
4299        }
4300        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4301            .expect("test constraint shape invariant");
4302        // A strictly concave coefficient profile (-(j-2)^2): every second
4303        // difference is -(-2) = +2 > 0 after the concave sign flip, well above
4304        // the interior margin.
4305        let seed = Array1::from_iter((0..p).map(|j| -((j as f64 - 2.0).powi(2))));
4306        let projected = project_point_strictly_into_feasible_cone(&seed, &constraints)
4307            .expect("already-interior seed must project");
4308        let max_move = seed
4309            .iter()
4310            .zip(projected.iter())
4311            .map(|(a, b)| (a - b).abs())
4312            .fold(0.0_f64, f64::max);
4313        assert!(
4314            max_move < 1e-3,
4315            "strictly-interior curvature-carrying seed should be preserved; max move = {max_move:.3e}"
4316        );
4317    }
4318
4319    #[test]
4320    fn dense_dual_newton_returns_the_exact_boundary_solution() {
4321        let hessian = array![[1.0]];
4322        let gradient = array![-1.0];
4323        let beta = array![0.0];
4324        let constraints = LinearInequalityConstraints {
4325            a: array![[-1.0]],
4326            b: array![-0.1],
4327        };
4328        let mut direction = Array1::zeros(1);
4329        let mut active_hint = Vec::new();
4330
4331        solve_newton_direction_with_linear_constraints(
4332            &hessian,
4333            &gradient,
4334            &beta,
4335            &constraints,
4336            &mut direction,
4337            Some(&mut active_hint),
4338        )
4339        .expect("finite dual solve should return the unique boundary solution");
4340
4341        assert_relative_eq!(direction[0], 0.1, epsilon = 1e-12);
4342        assert_eq!(active_hint, vec![0]);
4343    }
4344
4345    #[test]
4346    fn dense_dual_releases_a_boundary_with_negative_multiplier() {
4347        // At x=0 under x>=0, gradient=-1 points toward increasing x: the
4348        // equality multiplier is negative and the exact constrained minimizer
4349        // leaves the face. A warm row is an ordering hint, not permission to
4350        // retain that non-KKT boundary.
4351        let hessian = array![[1.0_f64]];
4352        let beta = array![0.0_f64];
4353        let gradient = array![-1.0_f64];
4354        let constraints =
4355            LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("one-sided bound");
4356        let mut direction = Array1::<f64>::zeros(1);
4357        let mut active = vec![0];
4358        solve_newton_direction_with_linear_constraints(
4359            &hessian,
4360            &gradient,
4361            &beta,
4362            &constraints,
4363            &mut direction,
4364            Some(&mut active),
4365        )
4366        .expect("negative-multiplier face must be released");
4367
4368        assert_relative_eq!(direction[0], 1.0, epsilon = 1e-12);
4369        assert!(gradient.dot(&direction) < 0.0);
4370        assert!(active.is_empty(), "descent moves strictly into the cone");
4371    }
4372
4373    #[test]
4374    fn rank_reduce_agrees_with_a_rank_revealing_factorization_on_an_ill_conditioned_face_2600() {
4375        // Six unit rows sampled from a smooth basis at closely spaced nodes —
4376        // the shape a spline/Khatri-Rao constraint face actually has — plus one
4377        // row that is EXACTLY their fourth finite difference. The block's rank
4378        // is six and the seventh row is redundant by construction, not by
4379        // tolerance: it is a linear combination of rows already present.
4380        //
4381        // What makes it a gate is the SIZE of that combination's coefficients.
4382        // The kept six run `sigma` from `2.8` down to `3.6e-7`, and a fourth
4383        // difference of near-collinear rows cancels down to `2e-3`, so
4384        // Gram-Schmidt subtracts O(1) projections to leave an O(1e-3) residual
4385        // and then has to see that the rest is zero. A single modified sweep
4386        // leaves contamination above this scan's own
4387        // `tol = 100*eps*8 = 1.78e-13`, so the row clears the floor and the
4388        // scan reports rank SEVEN. Reorthogonalizing once brings it back to
4389        // `O(eps)` and the scan reports six.
4390        //
4391        // That is #2600's pit arm in miniature: `face_rows = rank + 1` on 90
4392        // consecutive reduced-face solves, handed to a factorization that
4393        // refused the block as a singular affine system and ended the fit.
4394        //
4395        // The assertion is deliberately NOT a hardcoded six. It is that this
4396        // scan and a rank-revealing factorization of the same block, at the
4397        // same floor, agree — which is the invariant the physical reduced face
4398        // needs and the one that was violated.
4399        use gam_linalg::faer_ndarray::FaerSvd;
4400
4401        const NODE_SPACING: f64 = 0.05;
4402        let p = 8usize;
4403        let independent = 6usize;
4404        let mut rows = Array2::<f64>::zeros((independent + 1, p));
4405        for index in 0..independent {
4406            let node = 1.0 + NODE_SPACING * index as f64;
4407            let mut power = 1.0_f64;
4408            for column in 0..p {
4409                rows[[index, column]] = power;
4410                power *= node;
4411            }
4412            let norm = rows.row(index).dot(&rows.row(index)).sqrt();
4413            let normalized = &rows.row(index).to_owned() / norm;
4414            rows.row_mut(index).assign(&normalized);
4415        }
4416        // Fourth finite difference of the first five rows: coefficients
4417        // 1, -4, 6, -4, 1 — exactly in the span, with an O(1e-3) norm.
4418        let difference_weights = [1.0_f64, -4.0, 6.0, -4.0, 1.0];
4419        let mut combination = Array1::<f64>::zeros(p);
4420        for (index, weight) in difference_weights.iter().enumerate() {
4421            combination.scaled_add(*weight, &rows.row(index));
4422        }
4423        let combination_norm = combination.dot(&combination).sqrt();
4424        rows.row_mut(independent)
4425            .assign(&(&combination / combination_norm));
4426
4427        let (_u, singular, _vt) = rows.svd(false, false).expect("face SVD");
4428        let singular_max = singular.iter().copied().fold(0.0_f64, f64::max);
4429        let svd_floor = 100.0 * f64::EPSILON * (rows.nrows().max(p) as f64) * singular_max;
4430        let svd_rank = singular.iter().filter(|&&s| s > svd_floor).count();
4431        assert_eq!(
4432            svd_rank, independent,
4433            "the fixture must be rank-deficient by construction, not by tolerance \
4434             (singular values {singular:?})"
4435        );
4436
4437        let b = Array1::<f64>::zeros(independent + 1);
4438        let groups: Vec<Vec<usize>> = (0..=independent).map(|row| vec![row]).collect();
4439        let (reduced, _b_out, groups_out, _dependence) =
4440            rank_reduce_rows_pivoted_qr_with_dependence(rows, b, groups);
4441
4442        assert_eq!(
4443            reduced.nrows(),
4444            svd_rank,
4445            "the independence scan kept {} rows where a rank-revealing factorization \
4446             of the same block at the same floor finds {svd_rank}; a face carrying a \
4447             redundant row is what #2600's affine solve then refused",
4448            reduced.nrows()
4449        );
4450        assert_eq!(groups_out.len(), svd_rank);
4451    }
4452
4453    #[test]
4454    fn rank_reduce_zero_rows_returns_empty_working_set() {
4455        let a = array![[0.0, 0.0], [0.0, 0.0],];
4456        let b = array![0.0, 0.0];
4457        let groups = vec![vec![0], vec![1]];
4458
4459        let (a_out, b_out, groups_out, _) =
4460            rank_reduce_rows_pivoted_qr_with_dependence(a, b, groups);
4461
4462        assert_eq!(a_out.nrows(), 0);
4463        assert_eq!(a_out.ncols(), 2);
4464        assert_eq!(b_out.len(), 0);
4465        assert!(groups_out.is_empty());
4466    }
4467
4468    #[test]
4469    fn cone_projection_solves_nonnegative_least_squares_not_one_way_pruning() {
4470        let active_a = array![
4471            [0.85258593, -0.77270261],
4472            [-1.22152485, 2.05129351],
4473            [0.22794844, 1.56987265],
4474        ];
4475        let residual = array![-0.50524761, -1.10104911];
4476
4477        let (projected, multipliers) =
4478            project_stationarity_residual_on_constraint_cone(&residual, &active_a)
4479                .expect("cone projection should solve");
4480
4481        let row0 = active_a.row(0);
4482        let expected_mu0 = row0.dot(&residual) / row0.dot(&row0);
4483        assert_relative_eq!(multipliers[0], expected_mu0, epsilon = 1e-8);
4484        assert_relative_eq!(multipliers[1], 0.0, epsilon = 1e-10);
4485        assert_relative_eq!(multipliers[2], 0.0, epsilon = 1e-10);
4486
4487        let raw_norm2 = residual.dot(&residual);
4488        let projected_norm2 = projected.dot(&projected);
4489        assert!(
4490            projected_norm2 < raw_norm2 - 0.1,
4491            "NNLS projection should keep the improving active row: raw={raw_norm2:.6e}, projected={projected_norm2:.6e}"
4492        );
4493        let dual = active_a.dot(&projected);
4494        for (idx, (&mu, &w)) in multipliers.iter().zip(dual.iter()).enumerate() {
4495            if mu <= 1e-10 {
4496                assert!(
4497                    w <= 1e-8,
4498                    "inactive cone generator {idx} has positive reduced gradient {w:.3e}"
4499                );
4500            }
4501        }
4502    }
4503
4504    /// The direct Lawson–Hanson route must agree with the strict-QP Moreau
4505    /// projection wherever the latter succeeds: both compute the projection
4506    /// of `residual` onto the polar of the generated cone.
4507    #[test]
4508    fn nnls_moreau_projection_matches_strict_qp_route() {
4509        let cases: Vec<(Array2<f64>, Array1<f64>)> = vec![
4510            (
4511                array![
4512                    [0.85258593, -0.77270261],
4513                    [-1.22152485, 2.05129351],
4514                    [0.22794844, 1.56987265],
4515                ],
4516                array![-0.50524761, -1.10104911],
4517            ),
4518            (array![[1.0, 0.0], [0.0, 1.0]], array![3.0, -2.0]),
4519            (
4520                array![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [2.0, 2.0, 0.0]],
4521                array![1.5, 0.25, -0.75],
4522            ),
4523        ];
4524        for (rows, target) in cases {
4525            let qp = moreau_projection_via_strict_qp(&target, &rows)
4526                .expect("strict QP route must solve these well-posed instances");
4527            let (lambda, projected) = nonnegative_cone_multipliers(&rows, &target)
4528                .expect("LH route must solve the same instances");
4529            for (left, right) in qp.0.iter().zip(projected.iter()) {
4530                assert_relative_eq!(left, right, epsilon = 1e-8);
4531            }
4532            // λ ≥ 0 and exact reconstruction by construction.
4533            assert!(lambda.iter().all(|&v| v >= 0.0));
4534            let reconstructed = &target - &rows.t().dot(&lambda);
4535            for (left, right) in reconstructed.iter().zip(projected.iter()) {
4536                assert_relative_eq!(left, right, epsilon = 1e-12);
4537            }
4538        }
4539    }
4540
4541    #[test]
4542    fn nnls_projects_axis_cone_exactly() {
4543        let rows = array![[1.0, 0.0], [0.0, 1.0]];
4544        let target = array![3.0, -2.0];
4545        let (lambda, projected) =
4546            nonnegative_cone_multipliers(&rows, &target).expect("axis cone NNLS");
4547        assert_relative_eq!(lambda[0], 3.0, epsilon = 1e-10);
4548        assert_relative_eq!(lambda[1], 0.0, epsilon = 1e-10);
4549        assert_relative_eq!(projected[0], 0.0, epsilon = 1e-10);
4550        assert_relative_eq!(projected[1], -2.0, epsilon = 1e-10);
4551    }
4552
4553    /// A dependent active row that is weakly aligned with every kept row
4554    /// individually (`a3 = (a1 + a2)/(2ε)`, pairwise alignment ≈ ε) breaks the
4555    /// single-target multiplier attribution: `λ/coeff` explodes by `1/ε` and
4556    /// manufactures phantom huge duals. The existence-form certificate sees the
4557    /// exact nonnegative closure `g = 1·a3` and must certify stationarity.
4558    #[test]
4559    fn nnls_closes_stationarity_on_weakly_aligned_dependent_face() {
4560        let eps = 1e-8_f64;
4561        let rows = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4562        let target = array![0.0, 1.0];
4563        let (lambda, projected) =
4564            nonnegative_cone_multipliers(&rows, &target).expect("dependent-face NNLS");
4565        let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4566        assert!(
4567            closure <= 1e-10,
4568            "λ = e3 closes stationarity exactly; got closure {closure:.3e}"
4569        );
4570        assert!(lambda.iter().all(|&v| v >= 0.0));
4571    }
4572
4573    /// End-to-end: the constrained Newton solve on the same weakly-aligned
4574    /// degenerate face must certify the vertex instead of chasing phantom
4575    /// negative duals into a working-set cycle and refusing (#2298 survival
4576    /// monotonicity faces, #979 CTN faces).
4577    #[test]
4578    fn degenerate_face_with_weak_alignment_certifies_instead_of_cycling() {
4579        let eps = 1e-8_f64;
4580        let a = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4581        let b = array![0.0, 0.0, 0.0];
4582        let constraints = LinearInequalityConstraints::new(a.clone(), b).expect("constraints");
4583        let hessian = Array2::<f64>::eye(2);
4584        // KKT at d* = 0 with λ = e3 ≥ 0: gradient = A^T e3 = a3.
4585        let gradient = array![0.0, 1.0];
4586        let beta = array![0.0, 0.0];
4587        let mut direction = Array1::<f64>::zeros(2);
4588        solve_newton_direction_with_linear_constraints(
4589            &hessian,
4590            &gradient,
4591            &beta,
4592            &constraints,
4593            &mut direction,
4594            None,
4595        )
4596        .expect("the vertex is a certified KKT point; refusal is the #2298 defect");
4597        let step = direction.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4598        assert!(
4599            step <= 1e-8,
4600            "optimum is the vertex itself; got |d|∞ = {step:.3e}"
4601        );
4602    }
4603
4604    #[test]
4605    fn cone_projection_preserves_original_multiplier_units_after_row_canonicalization() {
4606        let residual = array![2.0, -1.0];
4607        let unit_row = array![[1.0, 0.0]];
4608        let scaled_row = array![[4.0, 0.0]];
4609
4610        let (projected_unit, multiplier_unit) =
4611            project_stationarity_residual_on_constraint_cone(&residual, &unit_row)
4612                .expect("unit-row cone projection should solve");
4613        let (projected_scaled, multiplier_scaled) =
4614            project_stationarity_residual_on_constraint_cone(&residual, &scaled_row)
4615                .expect("scaled-row cone projection should solve");
4616
4617        assert_relative_eq!(projected_unit[0], 0.0, epsilon = 1e-12);
4618        assert_relative_eq!(projected_unit[1], -1.0, epsilon = 1e-12);
4619        assert_relative_eq!(projected_scaled[0], projected_unit[0], epsilon = 1e-12);
4620        assert_relative_eq!(projected_scaled[1], projected_unit[1], epsilon = 1e-12);
4621        assert_relative_eq!(multiplier_unit[0], 2.0, epsilon = 1e-12);
4622        assert_relative_eq!(multiplier_scaled[0], 0.5, epsilon = 1e-12);
4623
4624        let reconstructed_unit = &residual - &unit_row.t().dot(&multiplier_unit);
4625        let reconstructed_scaled = &residual - &scaled_row.t().dot(&multiplier_scaled);
4626        assert_relative_eq!(reconstructed_unit[0], projected_unit[0], epsilon = 1e-12);
4627        assert_relative_eq!(
4628            reconstructed_scaled[0],
4629            projected_scaled[0],
4630            epsilon = 1e-12
4631        );
4632    }
4633
4634    // #2601 — a cone-projection REFUSAL must be visible in what a reader is
4635    // shown, not only in a field nobody rendered.
4636    //
4637    // `project_stationarity_residual_on_constraint_cone` returns `None` on a
4638    // non-finite target, so a non-finite gradient on a fully active face drives
4639    // the production diagnostic down the real refusal path. Both facts then have
4640    // to hold: the flag is set, and the note that carries it into a rendered
4641    // verdict is non-empty and says the residual is unprojected.
4642    #[test]
4643    fn a_refused_cone_projection_is_named_in_the_rendered_verdict_2601() {
4644        // β on the boundary of both rows, so the face is fully active and the
4645        // projector is actually reached (it is skipped on an empty face).
4646        let beta = array![0.0, 0.0];
4647        let constraints = LinearInequalityConstraints {
4648            a: array![[1.0, 0.0], [0.0, 1.0]],
4649            b: array![0.0, 0.0],
4650        };
4651
4652        let finite = compute_constraint_kkt_diagnostics(&beta, &array![1.0, 2.0], &constraints);
4653        assert!(
4654            !finite.cone_projection_refused,
4655            "a finite gradient on a full-rank active face must be projected, not refused"
4656        );
4657        assert_eq!(
4658            finite.cone_projection_note(),
4659            "",
4660            "a projection that happened must contribute no note"
4661        );
4662
4663        let refused =
4664            compute_constraint_kkt_diagnostics(&beta, &array![f64::NAN, 2.0], &constraints);
4665        assert_eq!(
4666            refused.n_active, 2,
4667            "the fixture must reach the projector: an empty active face skips it entirely"
4668        );
4669        assert!(
4670            refused.cone_projection_refused,
4671            "a non-finite target is one of the projector's documented refusals"
4672        );
4673        assert!(
4674            refused.cone_projection_note().contains("REFUSED"),
4675            "the rendered note must name the refusal; got {:?}",
4676            refused.cone_projection_note()
4677        );
4678        assert!(
4679            refused.cone_projection_note().contains("UNPROJECTED"),
4680            "the note must say the reported stat was never projected; got {:?}",
4681            refused.cone_projection_note()
4682        );
4683    }
4684
4685    // #500: the KKT primal residual must be the *geometric* distance to the
4686    // constraint hyperplane — invariant to how the constraint row is scaled.
4687    // A B-spline endpoint-derivative clamp carries a large row norm, so the
4688    // raw slack `a·β − b` of a near-feasible iterate is inflated by ‖a‖ and a
4689    // downstream raw primal gate would spuriously refuse it. The same geometry
4690    // expressed with a unit-norm row must yield the same primal.
4691    #[test]
4692    fn kkt_primal_is_per_row_scale_invariant() {
4693        // β sits 2.071e-8 on the infeasible side of the hyperplane `row·β ≥ 0`
4694        // (the exact geometric residual reported in #500's startup abort).
4695        let geometric_violation = 2.071e-8_f64;
4696        let gradient = Array1::<f64>::zeros(2);
4697
4698        // Unit-norm row: raw slack == geometric distance.
4699        let beta_unit = array![-geometric_violation, 0.0];
4700        let unit = LinearInequalityConstraints {
4701            a: array![[1.0, 0.0]],
4702            b: array![0.0],
4703        };
4704        let diag_unit = compute_constraint_kkt_diagnostics(&beta_unit, &gradient, &unit);
4705
4706        // Same hyperplane, row scaled ×1000: raw slack would be 2.071e-5, but
4707        // the *scaled* primal must still equal the geometric distance.
4708        let beta_big = array![-geometric_violation, 0.0];
4709        let big = LinearInequalityConstraints {
4710            a: array![[1000.0, 0.0]],
4711            b: array![0.0],
4712        };
4713        let diag_big = compute_constraint_kkt_diagnostics(&beta_big, &gradient, &big);
4714
4715        assert_relative_eq!(
4716            diag_unit.primal_feasibility,
4717            geometric_violation,
4718            epsilon = 1e-14
4719        );
4720        assert_relative_eq!(
4721            diag_big.primal_feasibility,
4722            geometric_violation,
4723            epsilon = 1e-14
4724        );
4725        // The scaled diagnostic must NOT report the ‖a‖-inflated raw slack.
4726        assert!(
4727            diag_big.primal_feasibility < 1e-7,
4728            "scaled primal {:.3e} should pass a 1e-7 gate; raw slack would be {:.3e}",
4729            diag_big.primal_feasibility,
4730            1000.0 * geometric_violation
4731        );
4732    }
4733
4734    // A B-spline `bc=clamped`/`bc=anchored` constraint is an EQUALITY
4735    // `a·β = b` encoded as two opposing inequalities `a·β ≥ b` and
4736    // `−a·β ≥ −b`. The active-set solver must drive the unconstrained
4737    // optimum back onto the hyperplane `a·β = b`. This is the isolated
4738    // analogue of the `bc=clamped` startup-validation abort: the exact
4739    // validation solve left `a·β ≈ 7.76` instead of 0, so the KKT primal
4740    // residual blew past tolerance and every seed was refused.
4741    #[test]
4742    fn opposing_inequality_pair_pins_equality_to_target() {
4743        // Minimize ½‖β‖² − rhs·β  (H = I) ⇒ unconstrained optimum β* = rhs.
4744        // rhs = [5,5,0,0] ⇒ a·β* = 10 with a = [1,1,0,0].
4745        // The opposing pair must pull a·β back to the target 0.
4746        let hessian = array![
4747            [1.0, 0.0, 0.0, 0.0],
4748            [0.0, 1.0, 0.0, 0.0],
4749            [0.0, 0.0, 1.0, 0.0],
4750            [0.0, 0.0, 0.0, 1.0],
4751        ];
4752        let rhs = array![5.0, 5.0, 0.0, 0.0];
4753        let beta_start = Array1::<f64>::zeros(4);
4754        let constraints = LinearInequalityConstraints {
4755            a: array![[1.0, 1.0, 0.0, 0.0], [-1.0, -1.0, 0.0, 0.0]],
4756            b: array![0.0, 0.0],
4757        };
4758
4759        let (beta, _active) = solve_quadratic_with_linear_constraints(
4760            &hessian,
4761            &rhs,
4762            &beta_start,
4763            &constraints,
4764            None,
4765        )
4766        .expect("opposing-inequality equality QP must solve");
4767
4768        let a_dot_beta = beta[0] + beta[1];
4769        assert!(
4770            a_dot_beta.abs() < 1e-8,
4771            "opposing inequalities must pin a·β to 0, got {a_dot_beta:.6e} (β = {beta:?})"
4772        );
4773    }
4774
4775    // Same as above but with a non-zero target and a large row norm — the
4776    // exact shape of a B-spline endpoint-derivative clamp, whose rows carry
4777    // ‖a‖ ≫ 1. The equality must still be pinned in geometric coordinates.
4778    #[test]
4779    fn opposing_inequality_pair_pins_scaled_equality_to_nonzero_target() {
4780        let hessian = array![
4781            [1.0, 0.0, 0.0, 0.0],
4782            [0.0, 1.0, 0.0, 0.0],
4783            [0.0, 0.0, 1.0, 0.0],
4784            [0.0, 0.0, 0.0, 1.0],
4785        ];
4786        let rhs = array![5.0, 5.0, 0.0, 0.0];
4787        let beta_start = Array1::<f64>::zeros(4);
4788        // Row scaled ×1000 (mimics a derivative-clamp row norm) with target 3000
4789        // ⇒ geometric target a·β = 3.0 in unit coordinates.
4790        let constraints = LinearInequalityConstraints {
4791            a: array![[1000.0, 1000.0, 0.0, 0.0], [-1000.0, -1000.0, 0.0, 0.0]],
4792            b: array![3000.0, -3000.0],
4793        };
4794
4795        let (beta, _active) = solve_quadratic_with_linear_constraints(
4796            &hessian,
4797            &rhs,
4798            &beta_start,
4799            &constraints,
4800            None,
4801        )
4802        .expect("scaled opposing-inequality equality QP must solve");
4803
4804        let a_dot_beta = 1000.0 * (beta[0] + beta[1]);
4805        assert!(
4806            (a_dot_beta - 3000.0).abs() < 1e-5,
4807            "opposing inequalities must pin a·β to 3000, got {a_dot_beta:.6e} (β = {beta:?})"
4808        );
4809    }
4810
4811    // `bc=clamped` at BOTH ends produces TWO opposing-inequality equalities
4812    // (4 rows total). The real abort reports `active=2/4` — only ONE of the
4813    // two equalities is being pinned. Reproduce two independent equalities
4814    // and require BOTH to be driven to their targets.
4815    #[test]
4816    fn two_opposing_inequality_equalities_both_pinned() {
4817        let hessian = array![
4818            [1.0, 0.0, 0.0, 0.0],
4819            [0.0, 1.0, 0.0, 0.0],
4820            [0.0, 0.0, 1.0, 0.0],
4821            [0.0, 0.0, 0.0, 1.0],
4822        ];
4823        let rhs = array![5.0, 5.0, 5.0, 5.0];
4824        let beta_start = Array1::<f64>::zeros(4);
4825        // Equality A: β0 + β1 = 0 (rows 0,1). Equality B: β2 + β3 = 0 (rows 2,3).
4826        let constraints = LinearInequalityConstraints {
4827            a: array![
4828                [1.0, 1.0, 0.0, 0.0],
4829                [-1.0, -1.0, 0.0, 0.0],
4830                [0.0, 0.0, 1.0, 1.0],
4831                [0.0, 0.0, -1.0, -1.0],
4832            ],
4833            b: array![0.0, 0.0, 0.0, 0.0],
4834        };
4835
4836        let (beta, _active) = solve_quadratic_with_linear_constraints(
4837            &hessian,
4838            &rhs,
4839            &beta_start,
4840            &constraints,
4841            None,
4842        )
4843        .expect("two-equality QP must solve");
4844
4845        assert!(
4846            (beta[0] + beta[1]).abs() < 1e-8,
4847            "equality A not pinned: β0+β1 = {:.6e}",
4848            beta[0] + beta[1]
4849        );
4850        assert!(
4851            (beta[2] + beta[3]).abs() < 1e-8,
4852            "equality B not pinned: β2+β3 = {:.6e}",
4853            beta[2] + beta[3]
4854        );
4855    }
4856
4857    // Faithful to the failing fit: the penalized IRLS Hessian `X'WX + λS`
4858    // with λ at the over-smoothing ceiling is severely ill-conditioned — the
4859    // penalty `S` is rank-deficient (null space = the unpenalized polynomial
4860    // part), so directions in null(S) are governed by a tiny `X'WX` block
4861    // while penalized directions carry a huge λ. The opposing-inequality
4862    // equalities must STILL be pinned under this conditioning.
4863    #[test]
4864    fn opposing_inequality_equalities_pinned_under_ill_conditioned_penalty() {
4865        // H = diag(1, 1, λ, λ) with λ = 1e8 — penalized directions 2,3 are
4866        // ~1e8 stiffer than the data directions 0,1.
4867        let lam = 1.0e8_f64;
4868        let hessian = array![
4869            [1.0, 0.0, 0.0, 0.0],
4870            [0.0, 1.0, 0.0, 0.0],
4871            [0.0, 0.0, lam, 0.0],
4872            [0.0, 0.0, 0.0, lam],
4873        ];
4874        let rhs = array![5.0, 5.0, 5.0, 5.0];
4875        let beta_start = Array1::<f64>::zeros(4);
4876        // Two equalities that COUPLE a stiff and a soft coordinate, like a
4877        // B-spline derivative row spanning penalized and unpenalized parts:
4878        // A: β0 + β2 = 0, B: β1 + β3 = 0.
4879        let constraints = LinearInequalityConstraints {
4880            a: array![
4881                [1.0, 0.0, 1.0, 0.0],
4882                [-1.0, 0.0, -1.0, 0.0],
4883                [0.0, 1.0, 0.0, 1.0],
4884                [0.0, -1.0, 0.0, -1.0],
4885            ],
4886            b: array![0.0, 0.0, 0.0, 0.0],
4887        };
4888
4889        let (beta, _active) = solve_quadratic_with_linear_constraints(
4890            &hessian,
4891            &rhs,
4892            &beta_start,
4893            &constraints,
4894            None,
4895        )
4896        .expect("ill-conditioned two-equality QP must solve");
4897
4898        assert!(
4899            (beta[0] + beta[2]).abs() < 1e-6,
4900            "equality A not pinned under ill-conditioning: β0+β2 = {:.6e}",
4901            beta[0] + beta[2]
4902        );
4903        assert!(
4904            (beta[1] + beta[3]).abs() < 1e-6,
4905            "equality B not pinned under ill-conditioning: β1+β3 = {:.6e}",
4906            beta[1] + beta[3]
4907        );
4908    }
4909
4910    // ==== gam#2306: operator (ConstraintSet) solver vs dense oracle ====
4911
4912    /// Small Khatri-Rao cone whose dense materialization is exact: Ψ is
4913    /// 4 × 2, coefficient block is 3 × 2 (row 0 unconstrained location,
4914    /// rows 1–2 coupled), so p = 6 and the cone has 8 rows.
4915    fn small_cone() -> KhatriRaoConeConstraints {
4916        let psi = array![[1.0_f64, 0.2], [1.0, -0.4], [1.0, 1.3], [1.0, 0.8],];
4917        KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3).expect("small cone")
4918    }
4919
4920    /// Parallel tight rows collapse to the lowest-index representative, and the
4921    /// duplicate is recorded in the dependence map with its scalar ratio.
4922    #[test]
4923    fn cone_reduced_face_collapses_parallel_rows_to_lowest_index() {
4924        // ψ_2 = 2·ψ_0 (parallel); ψ_1 independent. p_cov=2, one coupled row.
4925        let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
4926        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4927            .expect("parallel cone");
4928        // β = 0 ⇒ every Γ = 0 ⇒ every row tight.
4929        let beta = Array1::<f64>::zeros(2 * 2);
4930        let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4931        assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
4932        // Rank 2: reps are the two independent directions at their lowest obs.
4933        assert_eq!(face.representatives, rows(&[0, 1]));
4934        assert_eq!(face.dependence.len(), 2);
4935        // ψ_2 (flat id 2) is parallel to representative ψ_0 (rep index 0), coeff 2.
4936        assert_eq!(face.dependence[0].len(), 1);
4937        assert_eq!(face.dependence[0][0].row.index(), 2);
4938        assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
4939        assert!(face.dependence[1].is_empty());
4940    }
4941
4942    /// A full-rank tight face keeps every row and records no dependence.
4943    #[test]
4944    fn cone_reduced_face_full_rank_has_no_dependence() {
4945        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
4946        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4947            .expect("full-rank cone");
4948        let beta = Array1::<f64>::zeros(2 * 2);
4949        let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4950        assert_eq!(face.representatives, rows(&[0, 1]));
4951        assert!(face.dependence.iter().all(|d| d.is_empty()));
4952        assert_eq!(face.tight_rows, rows(&[0, 1]));
4953    }
4954
4955    /// A general-position dependent (in the span but parallel to no single rep)
4956    /// is dropped from the working set (full rank cut) but gets NO dependence
4957    /// entry — the (A)-strict contract that avoids a phantom distributed dual.
4958    #[test]
4959    fn cone_reduced_face_general_combination_gets_no_dependence_entry() {
4960        // ψ_2 = ψ_0 + ψ_1: in the span, but cos with each rep is 1/√2 < 1.
4961        let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [1.0, 1.0]];
4962        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4963            .expect("general-combo cone");
4964        let beta = Array1::<f64>::zeros(2 * 2);
4965        let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4966        assert_eq!(face.representatives, rows(&[0, 1])); // ψ_2 dropped
4967        assert_eq!(face.tight_rows, rows(&[0, 1, 2])); // but still in the tight set
4968        assert!(
4969            face.dependence.iter().all(|d| d.is_empty()),
4970            "a general-position drop must carry no distributed multiplier"
4971        );
4972    }
4973
4974    /// Cross-block cone rows are automatically orthogonal (e_k ⊥ e_{k'}), so each
4975    /// shape block reduces independently — no cross-block dependence, and flat
4976    /// ids stay in the slot*n+obs space.
4977    #[test]
4978    fn cone_reduced_face_reduces_each_shape_block_independently() {
4979        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
4980        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3)
4981            .expect("two-block cone");
4982        let beta = Array1::<f64>::zeros(3 * 2);
4983        let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4984        // Block 0 → flat 0,1; block 1 → flat 2,3 (slot*n+obs, n=2). All independent.
4985        assert_eq!(face.representatives, rows(&[0, 1, 2, 3]));
4986        assert!(face.dependence.iter().all(|d| d.is_empty()));
4987        assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
4988    }
4989
4990    /// The Dense arm of the `ConstraintSet::reduced_face` dispatcher matches the
4991    /// cone arm's contract: parallel tight rows collapse to the lowest-index
4992    /// representative with the scalar ratio recorded; flat id = the row index.
4993    #[test]
4994    fn dense_reduced_face_via_dispatcher_collapses_parallel_rows() {
4995        // Row 2 = 2·row 0 (parallel); row 1 independent. b = 0 ⇒ every row tight
4996        // at β = 0 (scaled slack 0).
4997        let a = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
4998        let set = ConstraintSet::Dense(
4999            LinearInequalityConstraints::new(a, Array1::<f64>::zeros(3)).expect("dense"),
5000        );
5001        let beta = Array1::<f64>::zeros(2);
5002        let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5003        assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
5004        assert_eq!(face.representatives, rows(&[0, 1]));
5005        assert_eq!(face.dependence[0].len(), 1);
5006        assert_eq!(face.dependence[0][0].row.index(), 2);
5007        assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
5008        assert!(face.dependence[1].is_empty());
5009    }
5010
5011    /// Constraint-row ids for the `ReducedFace` assertions below.
5012    fn rows(ids: &[usize]) -> Vec<ConstraintRowId> {
5013        ids.iter().copied().map(ConstraintRowId).collect()
5014    }
5015
5016    /// A block-diagonal set whose FIRST member constrains fewer rows than it has
5017    /// coefficients — one `β₀ ≥ 0` row over a 3-wide block whose remaining two
5018    /// coordinates are unconstrained (intercept / covariate columns) — followed
5019    /// by a square 2×2 member at `col_start = 3`. This is the configuration that
5020    /// separates the constraint-row offset (`nrows`: 1) from the coefficient
5021    /// offset (`col_start`: 3); every pre-existing multi-block test used square
5022    /// members, where the two coincide and nothing can be distinguished.
5023    fn mixed_width_block_diagonal() -> ConstraintSet {
5024        let narrow = gam_problem::PlacedConstraintBlock {
5025            col_start: 0,
5026            set: ConstraintSet::Dense(
5027                LinearInequalityConstraints::new(
5028                    array![[1.0_f64, 0.0, 0.0]],
5029                    Array1::<f64>::zeros(1),
5030                )
5031                .expect("narrow block"),
5032            ),
5033        };
5034        let square = gam_problem::PlacedConstraintBlock {
5035            col_start: 3,
5036            set: ConstraintSet::Dense(
5037                LinearInequalityConstraints::new(
5038                    array![[1.0_f64, 0.0], [2.0, 0.0]],
5039                    Array1::<f64>::zeros(2),
5040                )
5041                .expect("square block"),
5042            ),
5043        };
5044        ConstraintSet::block_diagonal(vec![narrow, square], 5).expect("block-diagonal")
5045    }
5046
5047    /// Every id a mixed-width block-diagonal reduced face emits addresses the
5048    /// JOINT CONSTRAINT-ROW space: it indexes `values()` and resolves through
5049    /// `bound()` / `row_norm()` to the member row it came from, and the tight
5050    /// rows really are tight there. This pins the id space that #2368 questioned
5051    /// — the running-`nrows()` shift is the one consistent with the rest of the
5052    /// `ConstraintSet` row API (`values` layout, `block_for_row` decoding).
5053    #[test]
5054    fn block_diagonal_reduced_face_row_ids_address_the_joint_constraint_row_space() {
5055        let set = mixed_width_block_diagonal();
5056        let beta = Array1::<f64>::zeros(5);
5057        let values = set.values(beta.view()).expect("values");
5058        let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5059
5060        // Joint rows: block 0 contributes row 0; block 1 contributes rows 1, 2.
5061        assert_eq!(set.nrows(), 3);
5062        assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
5063        // Block 1's row 1 = 2·row 0, so it collapses onto joint representative 1.
5064        assert_eq!(face.representatives, rows(&[0, 1]));
5065        assert_eq!(face.dependence[1][0].row.index(), 2);
5066
5067        for id in &face.tight_rows {
5068            let row = id.index();
5069            assert!(row < set.nrows(), "id {row} outside the joint row space");
5070            let norm = set.row_norm(row).expect("row norm resolves");
5071            let bound = set.bound(row).expect("bound resolves");
5072            assert!(
5073                (values[row] - bound) / norm <= 1e-8,
5074                "row {row} reported tight but has slack {}",
5075                (values[row] - bound) / norm
5076            );
5077        }
5078    }
5079
5080    /// The BlockDiagonal arm composes member reductions and concatenates their
5081    /// row ids in order (each member's flat ids shift by the running member row
5082    /// count), so a parallel dependent in the second block reports its global id.
5083    #[test]
5084    fn block_diagonal_reduced_face_concatenates_member_row_ids() {
5085        // Two Dense blocks over disjoint columns; each: row0 independent, row1 =
5086        // 2·row0. b = 0 ⇒ all tight. Block 1's rows shift by block 0's 2 rows.
5087        let make = |c0: usize| gam_problem::PlacedConstraintBlock {
5088            col_start: c0,
5089            set: ConstraintSet::Dense(
5090                LinearInequalityConstraints::new(
5091                    array![[1.0_f64, 0.0], [2.0, 0.0]],
5092                    Array1::<f64>::zeros(2),
5093                )
5094                .expect("dense block"),
5095            ),
5096        };
5097        let set = ConstraintSet::block_diagonal(vec![make(0), make(2)], 4).expect("block-diagonal");
5098        let beta = Array1::<f64>::zeros(4);
5099        let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5100        assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
5101        assert_eq!(face.representatives, rows(&[0, 2]));
5102        assert_eq!(face.dependence[0][0].row.index(), 1);
5103        assert_eq!(face.dependence[1][0].row.index(), 3);
5104    }
5105
5106    /// Deterministic PD Hessian with off-diagonal coupling so active-set
5107    /// choices are not axis-trivial.
5108    fn coupled_pd_hessian(p: usize) -> Array2<f64> {
5109        let mut h = Array2::<f64>::eye(p) * 2.0;
5110        for i in 0..p {
5111            for j in 0..p {
5112                if i != j {
5113                    h[[i, j]] = 0.3 / (1.0 + (i as f64 - j as f64).abs());
5114                }
5115            }
5116        }
5117        h
5118    }
5119
5120    #[test]
5121    fn operator_cone_qp_matches_dense_oracle_when_constraints_bind() {
5122        let cone = small_cone();
5123        let set = ConstraintSet::KhatriRaoCone(cone.clone());
5124        let dense = cone.to_dense().expect("dense oracle");
5125        let p = set.ncols();
5126        let hessian = coupled_pd_hessian(p);
5127        // rhs pulls the coupled rows negative so the unconstrained optimum
5128        // violates the cone and several rows must bind.
5129        let rhs = array![0.5_f64, -0.3, -2.0, 1.0, -1.5, -0.7];
5130        // Feasible start: coupled coefficient rows give strictly positive
5131        // functionals under every Ψ row (constant 1 with small slope loads).
5132        let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
5133
5134        let (beta_op, mut active_op) =
5135            solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5136                .expect("operator solve");
5137        let (beta_dense, mut active_dense) =
5138            solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
5139                .expect("dense solve");
5140
5141        for j in 0..p {
5142            assert!(
5143                (beta_op[j] - beta_dense[j]).abs() < 1e-7,
5144                "operator/dense coefficient {j} mismatch: {} vs {}",
5145                beta_op[j],
5146                beta_dense[j]
5147            );
5148        }
5149        // The binding face must agree GEOMETRICALLY: both carriers land on the
5150        // same point (asserted above), so every reported active row must be
5151        // tight there, and both must carry the same number of independent
5152        // rows. Exact row-id equality is too strong — the fixture's coupled
5153        // rows admit alternate representations of the same face, and which
5154        // redundant row a carrier keeps is a tie-break, not semantics.
5155        active_op.sort_unstable();
5156        active_dense.sort_unstable();
5157        let values_at_solution = set.values(beta_op.view()).expect("values at solution");
5158        let tight_at_solution: Vec<usize> = (0..set.nrows())
5159            .filter(|&row| {
5160                let norm = set.row_norm(row).expect("norm");
5161                norm > 0.0 && values_at_solution[row] / norm <= 1e-7
5162            })
5163            .collect();
5164        for &row in active_op.iter().chain(active_dense.iter()) {
5165            assert!(
5166                tight_at_solution.contains(&row),
5167                "reported active row {row} is not tight at the common solution \
5168                 (op face {active_op:?}, dense face {active_dense:?}, tight {tight_at_solution:?})"
5169            );
5170        }
5171        assert_eq!(
5172            active_op.len(),
5173            active_dense.len(),
5174            "carriers disagree on the face dimension: op {active_op:?} vs dense {active_dense:?}"
5175        );
5176        assert!(
5177            !active_op.is_empty(),
5178            "fixture must actually bind at least one cone row"
5179        );
5180        // And the operator answer must be feasible on the full cone.
5181        let values = set.values(beta_op.view()).expect("values");
5182        let (worst, _) = set.max_scaled_violation(beta_op.view()).expect("violation");
5183        assert!(worst <= 1e-8, "operator answer infeasible: {worst:.3e}");
5184        assert_eq!(values.len(), 8);
5185    }
5186
5187    #[test]
5188    fn operator_metric_dual_solves_the_non_diagonal_projection() {
5189        // Nonnegative quadrant with a genuinely coupled metric. The free
5190        // solution violates x>=0. On the binding face x=0, the exact minimizer
5191        // is y=1 and its row multiplier is 2:
5192        //
5193        // H [0,1]' - rhs = [2,0]'.
5194        //
5195        // An identity-metric Moreau projection would return a different point,
5196        // so this pins the H-metric dual rather than only cone feasibility.
5197        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5198        let cone =
5199            KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5200                .expect("nonnegative quadrant");
5201        let set = ConstraintSet::KhatriRaoCone(cone);
5202        let hessian = array![[4.0_f64, 1.0], [1.0, 2.0]];
5203        let rhs = array![-1.0_f64, 2.0];
5204        let beta_start = array![0.0_f64, 0.0];
5205
5206        let (candidate, active) =
5207            solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5208                .expect("strict metric projection");
5209
5210        assert_relative_eq!(candidate[0], 0.0, epsilon = 1e-12);
5211        assert_relative_eq!(candidate[1], 1.0, epsilon = 1e-12);
5212        assert_eq!(active, vec![0]);
5213        let gradient = hessian.dot(&candidate) - rhs;
5214        assert_relative_eq!(gradient[0], 2.0, epsilon = 1e-12);
5215        assert_relative_eq!(gradient[1], 0.0, epsilon = 1e-12);
5216    }
5217
5218    /// A KKT tolerance is an acceptance bound, not permission for a warm face to
5219    /// change the unique minimizer of a strictly convex QP. Here the free
5220    /// optimum is `epsilon` inside the cone while the warm boundary face has a
5221    /// negative multiplier whose magnitude is only half the dual tolerance.
5222    /// The history-independent Moreau solve must return the interior optimum,
5223    /// not retain the numerically admissible but suboptimal boundary point.
5224    #[test]
5225    fn operator_metric_dual_uses_the_certificate_multiplier_cone_2432() {
5226        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5227        let cone =
5228            KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5229                .expect("nonnegative quadrant");
5230        let set = ConstraintSet::KhatriRaoCone(cone);
5231        let hessian = Array2::<f64>::eye(2);
5232        let epsilon = 0.5 * ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
5233        let rhs = array![epsilon, 1.0];
5234        let beta_start = array![0.0_f64, 0.0];
5235
5236        let (candidate, active) = solve_quadratic_with_constraint_set(
5237            &hessian,
5238            &rhs,
5239            &beta_start,
5240            &set,
5241            Some(&[0]),
5242        )
5243        .expect("warm face must not perturb the unique cone projection");
5244
5245        assert!(
5246            active.is_empty(),
5247            "the exact interior optimum has no active cone row"
5248        );
5249        assert_relative_eq!(candidate[0], epsilon, epsilon = 1e-14);
5250        assert_relative_eq!(candidate[1], 1.0, epsilon = 1e-14);
5251        let gradient = hessian.dot(&candidate) - rhs;
5252        assert_relative_eq!(gradient[0], 0.0, epsilon = 1e-14);
5253        assert_relative_eq!(gradient[1], 0.0, epsilon = 1e-14);
5254    }
5255
5256    /// Exact reduced analogue of the public competing-risks failure shared by
5257    /// #2366/#2432. The supplied point is feasible and exactly three of 332 rows
5258    /// are tight, but its face certificate has the observed signature:
5259    /// negative dual `6.987e-1` and tangential stationarity residual `1.130e1`.
5260    /// Feasibility is therefore not a QP solution. Both a cold solve and a warm
5261    /// solve seeded with that wrong face must return the same unique KKT point,
5262    /// with strictly-positive multipliers on the two rows that truly bind.
5263    #[test]
5264    fn dense_metric_dual_leaves_feasible_nonstationary_three_of_332_face_2432() {
5265        let p = 5usize;
5266        let m = 332usize;
5267        let mut a = Array2::<f64>::zeros((m, p));
5268        let mut b = Array1::<f64>::from_elem(m, -100.0);
5269        for row in 0..3 {
5270            a[[row, row]] = 1.0;
5271            b[row] = 0.0;
5272        }
5273        // The remaining rows are deliberately slack but non-vacuous. They pin
5274        // the production cardinality without changing the five-dimensional
5275        // geometry of the bad face.
5276        for row in 3..m {
5277            a[[row, (row - 3) % p]] = 1.0;
5278        }
5279        let constraints =
5280            LinearInequalityConstraints::new(a, b).expect("332-row dense constraint system");
5281        let hessian = Array2::from_diag(&array![1.0_f64, 2.0, 3.0, 1.0, 4.0]);
5282        let rhs = array![0.6987_f64, -2.0, -3.0, -11.3, 0.0];
5283        let wrong_face_point = Array1::<f64>::zeros(p);
5284
5285        let (cold, cold_active) = solve_quadratic_with_linear_constraints(
5286            &hessian,
5287            &rhs,
5288            &wrong_face_point,
5289            &constraints,
5290            None,
5291        )
5292        .expect("cold finite dual solve");
5293        let (warm, warm_active) = solve_quadratic_with_linear_constraints(
5294            &hessian,
5295            &rhs,
5296            &wrong_face_point,
5297            &constraints,
5298            Some(&[0, 1, 2]),
5299        )
5300        .expect("wrong-face warm hint must affect ordering only");
5301
5302        assert!(
5303            cold.iter()
5304                .zip(warm.iter())
5305                .all(|(&left, &right)| left.to_bits() == right.to_bits()),
5306            "strictly-convex QP answer must be bitwise warm-history independent: \
5307             cold={cold:?}, warm={warm:?}"
5308        );
5309        assert_eq!(cold_active, vec![1, 2]);
5310        assert_eq!(warm_active, vec![1, 2]);
5311        let expected = array![0.6987_f64, 0.0, 0.0, -11.3, 0.0];
5312        for (&actual, &target) in cold.iter().zip(expected.iter()) {
5313            assert_relative_eq!(actual, target, epsilon = 1e-13);
5314        }
5315
5316        let gradient = hessian.dot(&cold) - &rhs;
5317        let active_rows = LinearInequalityConstraints::new(
5318            constraints.a.select(ndarray::Axis(0), &[1, 2]),
5319            constraints.b.select(ndarray::Axis(0), &[1, 2]),
5320        )
5321        .expect("true active face");
5322        let (_, system_multipliers) =
5323            solve_kkt_direction(&hessian, &gradient, &active_rows.a, None)
5324                .expect("true-face multiplier reconstruction");
5325        let multipliers = -system_multipliers;
5326        assert_relative_eq!(multipliers[0], 2.0, epsilon = 1e-13);
5327        assert_relative_eq!(multipliers[1], 3.0, epsilon = 1e-13);
5328        assert!(
5329            multipliers.iter().all(|&value| value > 0.0),
5330            "the returned face must carry nonnegative KKT multipliers"
5331        );
5332        let certified = compute_constraint_kkt_diagnostics(&cold, &gradient, &constraints);
5333        assert!(certified.primal_feasibility <= 1e-14);
5334        assert!(certified.dual_feasibility <= 1e-14);
5335        assert!(certified.complementarity <= 1e-14);
5336        assert!(certified.stationarity <= 1e-13);
5337    }
5338
5339    /// A globalized CTN step can retain only a small subset of the previous
5340    /// endpoint face. The next H-metric projection must recover every missing
5341    /// independent normal direction in one separator batch, not pay one dense
5342    /// face solve per observation-row id.
5343    #[test]
5344    fn operator_metric_projection_batches_a_partial_warm_face_979() {
5345        let rows = 24_000;
5346        let p = 24;
5347        let psi = Array2::from_shape_fn((rows, p), |(row, column)| {
5348            if column == row % p { 1.0 } else { 0.0 }
5349        });
5350        let cone =
5351            KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5352                .expect("many-row coordinate cone");
5353        let set = ConstraintSet::KhatriRaoCone(cone);
5354        let hessian = Array2::<f64>::eye(p);
5355        let rhs = Array1::<f64>::from_elem(p, -1.0);
5356        let beta_start = Array1::<f64>::zeros(p);
5357        let warm = [0usize, 1, 2, 3];
5358
5359        let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5360        let unconstrained = rhs.clone();
5361        let values = ops.values(&unconstrained).expect("free values");
5362        let mut is_active = vec![false; rows];
5363        for &row in &warm {
5364            is_active[row] = true;
5365        }
5366        let banned = vec![false; rows];
5367        let selected = independent_violated_operator_rows(
5368            &ops,
5369            &values,
5370            &warm,
5371            &is_active,
5372            &banned,
5373            p - warm.len(),
5374        )
5375        .expect("batch separation");
5376        assert_eq!(
5377            selected.len(),
5378            p - warm.len(),
5379            "one scan must recover every coefficient-space direction missing from the warm face"
5380        );
5381
5382        let (candidate, active) = solve_quadratic_with_constraint_set(
5383            &hessian,
5384            &rhs,
5385            &beta_start,
5386            &set,
5387            Some(&warm),
5388        )
5389        .expect("batched metric projection");
5390        assert!(
5391            candidate.iter().all(|value| value.abs() <= 1e-12),
5392            "projection onto the repeated coordinate cone must be the origin: {candidate:?}"
5393        );
5394        assert_eq!(
5395            active.len(),
5396            p,
5397            "the returned face must contain one representative per independent coordinate"
5398        );
5399    }
5400
5401    /// The strict-interior repair is an identity-metric projection, so a
5402    /// carrier with many repeated observation rows must cost one finite dual
5403    /// face in coefficient space. Row cardinality may increase operator-scan
5404    /// work; it must never restore the retired row-count pivot budget that made
5405    /// the 320k-row CTN repair grind until the command timeout.
5406    #[test]
5407    fn operator_strict_interior_projection_is_coefficient_bounded_on_repeated_rows_979() {
5408        let rows = 24_000;
5409        let p = 24;
5410        let psi = Array2::from_shape_fn((rows, p), |(row, column)| {
5411            if column == row % p { 1.0 } else { 0.0 }
5412        });
5413        let cone =
5414            KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5415                .expect("many-row coordinate cone");
5416        let set = ConstraintSet::KhatriRaoCone(cone);
5417        let point = Array1::<f64>::from_elem(p, -1.0);
5418
5419        let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5420            .expect("finite dual strict-interior projection");
5421        let values = set.values(projected.view()).expect("projected values");
5422        for row in 0..set.nrows() {
5423            let norm = set.row_norm(row).expect("row norm");
5424            let scaled_slack = values[row] / norm;
5425            assert!(
5426                scaled_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - 1e-9,
5427                "row {row} missed the certified interior: {scaled_slack:.3e}"
5428            );
5429        }
5430        for (column, value) in projected.iter().enumerate() {
5431            assert!(
5432                *value < ACTIVE_SET_INTERIOR_SEED_MARGIN + 1e-8,
5433                "identity projection moved coordinate {column} past its nearest interior face: {value:.3e}"
5434            );
5435        }
5436    }
5437
5438    #[test]
5439    fn operator_scan_separates_primal_feasibility_from_active_equality_979() {
5440        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5441        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5442            .expect("two-row operator cone");
5443        let set = ConstraintSet::KhatriRaoCone(cone);
5444        let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5445        // Row zero has drifted to the feasible side of its nominal equality.
5446        // This is public-primal feasible but not a certified active face: the
5447        // two contracts must remain distinct.
5448        let beta = array![2.0 * ACTIVE_SET_PRIMAL_FEASIBILITY_TOL, 1.0];
5449        let values = ops.values(&beta).expect("operator values");
5450        let scan = scan_operator_violations(&ops, &values, &[true, false])
5451            .expect("full-set violation scan");
5452
5453        assert!(
5454            scan.inactive.is_empty(),
5455            "an active equality is not an admissible entering separator"
5456        );
5457        assert!(
5458            scan.is_primal_feasible(),
5459            "positive active-row slack is feasible for the one-sided public contract"
5460        );
5461        assert_relative_eq!(scan.worst.violation, 0.0, epsilon = 0.0);
5462
5463        let active_rows = ops
5464            .gather_unit_rows(&[0])
5465            .expect("one-row active equality");
5466        let equality = certify_active_equalities(&active_rows.a, &active_rows.b, &beta);
5467        assert_eq!(
5468            equality.worst_row, 0,
5469            "the only active row must own the equality residual"
5470        );
5471        assert!(
5472            !equality.is_certified(),
5473            "feasible-side drift must still fail the two-sided active-equality certificate"
5474        );
5475        assert!(
5476            equality.residual > equality.allowed,
5477            "active equality residual {:.3e} must exceed its roundoff bound {:.3e}",
5478            equality.residual,
5479            equality.allowed,
5480        );
5481    }
5482
5483    #[test]
5484    fn operator_metric_projection_finishes_a_separator_after_partial_drop_979() {
5485        // Unit normals n0=(1,0) and n1=(c,s), with H=I. The free point is
5486        // chosen so admitting n0 first and then pivoting toward n1 releases n0
5487        // with only half the public feasibility tolerance left on n1.
5488        //
5489        // The pre-fix dual loop abandoned n1 at that point. Its partial step
5490        // had already added a positive n1 multiplier to beta, but n1 was not
5491        // recorded in the active state; terminal conditioning therefore reset
5492        // to the free point and row-order refill repeated the same two pivots
5493        // through every conditioning round. The true unique optimum has only
5494        // n1 active and n0 strictly feasible.
5495        let sine = 0.1_f64;
5496        let cosine = (1.0 - sine * sine).sqrt();
5497        let residual_after_drop = 0.5 * ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
5498        let psi = array![[1.0_f64, 0.0], [cosine, sine]];
5499        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5500            .expect("partial-drop operator cone");
5501        let set = ConstraintSet::KhatriRaoCone(cone);
5502        let hessian = Array2::<f64>::eye(2);
5503        let unconstrained = array![
5504            -1.0_f64,
5505            -sine / cosine - residual_after_drop / sine
5506        ];
5507        let beta_start = Array1::<f64>::zeros(2);
5508
5509        let (candidate, active) = solve_quadratic_with_constraint_set(
5510            &hessian,
5511            &unconstrained,
5512            &beta_start,
5513            &set,
5514            Some(&[0]),
5515        )
5516        .expect("a pending separator must survive its partial dual drop");
5517
5518        let normal = array![cosine, sine];
5519        let multiplier = -normal.dot(&unconstrained);
5520        let expected = &unconstrained + &(&normal * multiplier);
5521        assert!(
5522            residual_after_drop <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5523            "the fixture must leave the pending separator inside the public tolerance after \
5524             its partial drop"
5525        );
5526        assert!(
5527            multiplier > 1.0,
5528            "the true multiplier must be cumulative, not the tolerance-sized final step: \
5529             {multiplier:.3e}"
5530        );
5531        for (&actual, &oracle) in candidate.iter().zip(expected.iter()) {
5532            assert_relative_eq!(actual, oracle, epsilon = 5e-14);
5533        }
5534        let gradient = &candidate - &unconstrained;
5535        let expected_gradient = &normal * multiplier;
5536        for (&actual, &oracle) in gradient.iter().zip(expected_gradient.iter()) {
5537            assert_relative_eq!(actual, oracle, epsilon = 5e-14);
5538        }
5539        assert_eq!(
5540            active,
5541            vec![1],
5542            "the unique optimum is supported by the second normal only"
5543        );
5544        let values = set.values(candidate.view()).expect("candidate values");
5545        let scan = scan_operator_violations(
5546            &ConstraintSetOps::new(&set, 0.0).expect("operator geometry"),
5547            &values,
5548            &[false, true],
5549        )
5550        .expect("candidate feasibility");
5551        assert!(scan.is_primal_feasible());
5552        assert!(
5553            candidate[0] > 0.0,
5554            "released row zero must be strictly feasible at the optimum"
5555        );
5556    }
5557
5558    #[test]
5559    fn operator_cone_qp_takes_unconstrained_path_when_interior() {
5560        let cone = small_cone();
5561        let set = ConstraintSet::KhatriRaoCone(cone);
5562        let p = set.ncols();
5563        let hessian = coupled_pd_hessian(p);
5564        // rhs pushing every coupled functional UP: unconstrained optimum is
5565        // strictly interior, so the operator path must equal the plain solve.
5566        let rhs = array![0.2_f64, 0.1, 3.0, 0.2, 2.5, 0.1];
5567        let beta_start = array![0.0_f64, 0.0, 1.0, 0.0, 1.0, 0.0];
5568        let (beta_op, active_op) =
5569            solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5570                .expect("operator solve");
5571        // Dense unconstrained oracle: H β = rhs.
5572        let mut beta_unconstrained = Array1::<f64>::zeros(p);
5573        super::solve_newton_direction_dense(
5574            &hessian,
5575            &(hessian.dot(&beta_start) - &rhs),
5576            &mut beta_unconstrained,
5577        )
5578        .expect("unconstrained newton");
5579        let beta_unconstrained = &beta_start + &beta_unconstrained;
5580        for j in 0..p {
5581            assert!(
5582                (beta_op[j] - beta_unconstrained[j]).abs() < 1e-8,
5583                "interior operator solve must match unconstrained optimum at {j}"
5584            );
5585        }
5586        assert!(
5587            active_op.is_empty(),
5588            "interior optimum must have empty face"
5589        );
5590    }
5591
5592    #[test]
5593    fn operator_projection_returns_strictly_interior_point() {
5594        let cone = small_cone();
5595        let set = ConstraintSet::KhatriRaoCone(cone);
5596        // Infeasible point: coupled row 1 loaded negative everywhere.
5597        let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5598        let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5599            .expect("projection must succeed on a one-sided homogeneous cone");
5600        let values = set.values(projected.view()).expect("values");
5601        for row in 0..set.nrows() {
5602            let norm = set.row_norm(row).expect("norm");
5603            if norm <= 0.0 {
5604                continue;
5605            }
5606            let slack = values[row] / norm;
5607            assert!(
5608                slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - 1e-9,
5609                "projected point not strictly interior on row {row}: slack {slack:.3e}"
5610            );
5611        }
5612        // The location coordinates (unconstrained) must be untouched by the
5613        // projection objective's optimum only if already optimal; at minimum
5614        // they must remain finite and close to the input (they carry no
5615        // constraint rows, and the identity-Hessian QP has no incentive to
5616        // move them).
5617        assert!((projected[0] - point[0]).abs() < 1e-8);
5618        assert!((projected[1] - point[1]).abs() < 1e-8);
5619    }
5620
5621    /// #2378 regression, independent oracle. The operator strict-interior
5622    /// projection onto an OVER-COMPLETE cone face (three of a 2-D block's four
5623    /// half-spaces try to bind — rank 2) must not merely land on *a* feasible
5624    /// point; it must be the correct Euclidean projection. The former loose
5625    /// rank-reduction truncated the true binding extreme (row 2) out of the
5626    /// enforced face and refused the fit; a regression in the over-complete-face
5627    /// exchange would release the wrong representative and land on a different
5628    /// feasible vertex. Both are caught by matching the dense oracle over the
5629    /// same materialized rows AND by pinning which pair binds ({1,2}, not {1,3}).
5630    #[test]
5631    fn operator_projection_adjudicates_the_over_complete_face_2378() {
5632        let cone = small_cone();
5633        let set = ConstraintSet::KhatriRaoCone(cone.clone());
5634        // The #2378 witness point: coupled block 1 = coords[2..4] = (-1, -0.5)
5635        // is over-complete; block 2 is left feasible.
5636        let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5637        let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5638            .expect("operator projection must certify the over-complete-face vertex");
5639
5640        // Ground-truth oracle: the SAME projection over the dense materialization
5641        // of the cone rows, through the independent dense arm.
5642        let dense = ConstraintSet::Dense(cone.to_dense().expect("dense oracle"));
5643        let dense_proj = project_point_strictly_into_feasible_constraint_set(&point, &dense)
5644            .expect("dense projection oracle");
5645        for j in 0..point.len() {
5646            assert!(
5647                (projected[j] - dense_proj[j]).abs() < 1e-7,
5648                "operator projection diverged from the dense oracle at {j}: \
5649                 op={:.9e} dense={:.9e}",
5650                projected[j],
5651                dense_proj[j]
5652            );
5653        }
5654
5655        // The correct binding pair is block-1 rows {1,2} (flat ids 1 and 2 in
5656        // slot 0). Row 2 — the extreme the old code truncated — must be TIGHT,
5657        // not violated. Rows are `slot*n + obs`, n = 4 Ψ rows, coupled slot 0.
5658        let values = set.values(projected.view()).expect("values");
5659        let scaled = |row: usize| values[row] / set.row_norm(row).expect("norm");
5660        // Rows 1 and 2 bind at (near) the strict-interior margin floor…
5661        for row in [1usize, 2] {
5662            assert!(
5663                scaled(row) < ACTIVE_SET_INTERIOR_SEED_MARGIN + 1e-7,
5664                "block-1 row {row} should bind, scaled slack {:.3e}",
5665                scaled(row)
5666            );
5667        }
5668        // …while rows 0 and 3 stay strictly slacker than the binding pair.
5669        for row in [0usize, 3] {
5670            assert!(
5671                scaled(row) > scaled(2) + 1e-9,
5672                "non-binding row {row} (slack {:.3e}) must exceed the binding \
5673                 row 2 (slack {:.3e})",
5674                scaled(row),
5675                scaled(2)
5676            );
5677        }
5678    }
5679
5680    /// #2378 regression at the QP level (non-identity Hessian): operator-native
5681    /// metric projection must reach the same constrained minimizer as the dense
5682    /// oracle when a coupled block is loaded so that three of its half-spaces
5683    /// contend at the optimum.
5684    #[test]
5685    fn operator_cone_qp_over_complete_face_matches_dense_oracle_2378() {
5686        let cone = small_cone();
5687        let set = ConstraintSet::KhatriRaoCone(cone.clone());
5688        let dense = cone.to_dense().expect("dense oracle");
5689        let p = set.ncols();
5690        let hessian = coupled_pd_hessian(p);
5691        // Drive block-1's unconstrained optimum deep into the infeasible corner
5692        // where the extreme Ψ rows 1 and 2 both contend (the over-complete face),
5693        // and pin block-2 with its own mild load.
5694        let rhs = array![0.3_f64, -0.1, -2.5, -1.2, -0.4, 0.2];
5695        let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
5696
5697        let (beta_op, active_op) =
5698            solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5699                .expect("operator QP solve over an over-complete face");
5700        let (beta_dense, _active_dense) =
5701            solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
5702                .expect("dense QP oracle");
5703
5704        for j in 0..p {
5705            assert!(
5706                (beta_op[j] - beta_dense[j]).abs() < 1e-7,
5707                "operator/dense coefficient {j} mismatch: {} vs {}",
5708                beta_op[j],
5709                beta_dense[j]
5710            );
5711        }
5712        // The operator answer is feasible on the full factored cone.
5713        let values = set.values(beta_op.view()).expect("values");
5714        for row in 0..set.nrows() {
5715            let norm = set.row_norm(row).expect("norm");
5716            if norm > 0.0 {
5717                assert!(
5718                    values[row] / norm >= -ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5719                    "row {row} violated at the operator optimum: {:.3e}",
5720                    values[row] / norm
5721                );
5722            }
5723        }
5724        assert!(
5725            active_op.len() <= p,
5726            "operator passive face must contain at most one row per coefficient-space direction: \
5727             active={}, p={p}",
5728            active_op.len()
5729        );
5730    }
5731
5732    #[test]
5733    fn operator_cone_does_not_materialize_a_whole_tight_face() {
5734        // All 4,096 observation rows describe the same half-space.  At the
5735        // cone vertex every row is tight, but one generator completely
5736        // describes the dual support. The operator solver must return that
5737        // compact support instead of gathering/rank-reducing all 4,096
5738        // redundant rows (the large-scale CTN cycle-2 stall from #979).
5739        let mut psi = Array2::<f64>::zeros((4096, 2));
5740        psi.column_mut(0).fill(1.0);
5741        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5742            .expect("repeated-row cone");
5743        let set = ConstraintSet::KhatriRaoCone(cone);
5744        let hessian = Array2::<f64>::eye(4);
5745        let rhs = array![0.3_f64, -0.2, -1.0, 0.0];
5746        let beta_start = Array1::<f64>::zeros(4);
5747
5748        // Seed a non-first representative. The operator arm must consume this
5749        // point-tight warm face instead of rescanning 4,096 equivalent rows and
5750        // deterministically rediscovering row zero.
5751        let warm_row = 2048usize;
5752        let (beta, active) = solve_quadratic_with_constraint_set(
5753            &hessian,
5754            &rhs,
5755            &beta_start,
5756            &set,
5757            Some(&[warm_row]),
5758        )
5759        .expect("vertex solve");
5760
5761        assert_eq!(
5762            active,
5763            vec![warm_row],
5764            "the compact point-tight warm representative was discarded or redundant rows entered"
5765        );
5766        assert!(beta[2].abs() <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
5767        assert!((beta[0] - 0.3).abs() < 1e-10);
5768        assert!((beta[1] + 0.2).abs() < 1e-10);
5769    }
5770
5771    /// The KKT diagnostic's sign convention, pinned on the exact face that
5772    /// produces `stat_rel = 1.000e0` in #2601.
5773    ///
5774    /// `[convex]` on clean linear data reports `primal=8.4e-13`,
5775    /// `comp=4.4e-16`, `active=10/10`, every multiplier zero, and a
5776    /// stationarity residual equal to the whole gradient. Multipliers
5777    /// identically zero on a fully active face is not "slightly off": NNLS
5778    /// leaves `λ ≡ 0` exactly when NO active row has positive correlation with
5779    /// the target, so all ten rows failed the entry test at once. A gradient
5780    /// merely falling outside a pointed cone would normally still recruit some
5781    /// rows and leave `stat_rel < 1`. All of them failing together is what a
5782    /// gradient sitting in the POLAR cone looks like — i.e. a flipped sign.
5783    ///
5784    /// Before hunting the sign at the four call sites, establish which sign the
5785    /// projector itself expects. Constraints are `A β ≥ b`, so for a MINIMISED
5786    /// objective the Lagrangian is `f − λᵀ(Aβ − b)` and stationarity is
5787    /// `∇f = Aᵀλ, λ ≥ 0`. This test asserts exactly that, and asserts the
5788    /// negation produces the observed pathology — so if the projector is ever
5789    /// "fixed" by flipping it, this fails and says which direction is which.
5790    ///
5791    /// The face is the #2601 face: second-difference (convexity) rows, β
5792    /// affine so every row is tight to the last bit and `A_active` is
5793    /// rank-deficient by construction.
5794    #[test]
5795    fn the_kkt_cone_convention_is_grad_equals_a_transpose_lambda_2601() {
5796        let p = 6usize;
5797        let m = p - 2;
5798        let mut a = Array2::<f64>::zeros((m, p));
5799        for i in 0..m {
5800            a[[i, i]] = 1.0;
5801            a[[i, i + 1]] = -2.0;
5802            a[[i, i + 2]] = 1.0;
5803        }
5804        // Affine β has exactly zero second differences, so all m rows are
5805        // tight and the active face is the whole constraint set.
5806        let beta = Array1::from_shape_fn(p, |j| 0.5 + 2.0 * (j as f64));
5807        let constraints =
5808            LinearInequalityConstraints::new(a.clone(), Array1::<f64>::zeros(m))
5809                .expect("second-difference constraints");
5810
5811        // The diagnostic works in per-row-normalised units, so build the
5812        // gradient from the SAME scaled rows its λ will be expressed in.
5813        let row_norm = 6.0_f64.sqrt(); // ‖[1, −2, 1]‖
5814        let a_scaled = a.mapv(|v| v / row_norm);
5815        let lambda_true = Array1::from_vec(vec![0.25, 1.5, 0.0, 3.0]);
5816        assert_eq!(lambda_true.len(), m);
5817
5818        let aligned = a_scaled.t().dot(&lambda_true);
5819        let diag = compute_constraint_kkt_diagnostics(&beta, &aligned, &constraints);
5820        assert_eq!(
5821            diag.n_active, m,
5822            "an affine β must make every second-difference row tight"
5823        );
5824        assert!(
5825            diag.stationarity <= 1e-12 * diag.gradient_scale.max(1.0),
5826            "∇f = Aᵀλ with λ ≥ 0 IS the stationarity condition for A β ≥ b; the cone \
5827             projector must absorb it entirely (stat={:.6e}, ‖g‖∞={:.6e}, active={}/{})",
5828            diag.stationarity,
5829            diag.gradient_scale,
5830            diag.n_active,
5831            diag.n_constraints,
5832        );
5833        assert!(
5834            diag.dual_feasibility <= 1e-12,
5835            "recovered multipliers must stay nonnegative (dual={:.6e})",
5836            diag.dual_feasibility,
5837        );
5838
5839        // Now the negation, which is what a flipped sign would look like.
5840        //
5841        // I expected `λ ≡ 0` and `stat_rel = 1.0` here -- the #2601 signature --
5842        // and the measurement said otherwise: `stat = 1.224745` against
5843        // `‖g‖∞ = 2.449490`, exactly HALF absorbed. A gradient in the polar
5844        // cone still recruits rows on this face, because the cone generated by
5845        // second-difference rows is not self-polar: some `−a_iᵀ(Aᵀλ_true)` are
5846        // still positive and enter the passive set.
5847        //
5848        // That refutes the sign explanation for #2601. A flipped sign cannot
5849        // produce a stationarity residual EQUAL to the gradient on this face,
5850        // so whatever leaves `λ ≡ 0` there is not a convention error. What
5851        // remains is a refusal: `project_stationarity_residual_on_constraint_cone`
5852        // returns `None` on a non-finite target, a width mismatch, or the
5853        // Lawson-Hanson guard, and the caller then leaves `λ` at its zeros --
5854        // which IS exactly `stat_rel = 1.0`. Hence `cone_projection_refused`.
5855        //
5856        // The `0.5` is asserted as a fact about this face, not as a target: it
5857        // is what makes the sign hypothesis untenable, and if it ever moves,
5858        // the reasoning above needs redoing.
5859        let opposed = aligned.mapv(|v| -v);
5860        let flipped = compute_constraint_kkt_diagnostics(&beta, &opposed, &constraints);
5861        let opposed_scale = opposed.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
5862        assert!(
5863            !flipped.cone_projection_refused,
5864            "the projector answered for the negated gradient; it did not refuse"
5865        );
5866        let absorbed_fraction = 1.0 - flipped.stationarity / opposed_scale;
5867        assert!(
5868            absorbed_fraction > 0.25,
5869            "a polar-cone gradient still recruits rows on this face -- it does NOT \
5870             reproduce #2601's stat_rel = 1.0, which is why a flipped sign cannot be \
5871             the explanation there (stat={:.6e}, ‖g‖∞={:.6e}, absorbed={:.6e})",
5872            flipped.stationarity,
5873            opposed_scale,
5874            absorbed_fraction,
5875        );
5876        assert_eq!(
5877            flipped.n_active, m,
5878            "the face is a property of β, not of the gradient's sign"
5879        );
5880    }
5881/// #979 CTN plateau regression: the operator-native Lawson-Hanson Moreau
5882    /// solve must certify a degenerate fully-pinned vertex directly instead of
5883    /// spending a primal-QP iteration budget on one-row blocker exchanges.
5884    #[test]
5885    fn operator_nnls_certifies_pinned_degenerate_vertex_projection_979() {
5886        // Four generators in R^3 (degenerate: a4 = a1 + a2), all tight at the
5887        // origin. The stationarity residual is a nonnegative combination, so
5888        // the projected residual is exactly zero.
5889        let a = array![
5890            [1.0_f64, 0.0, 0.0],
5891            [0.0, 1.0, 0.0],
5892            [0.0, 0.0, 1.0],
5893            [1.0, 1.0, 0.0],
5894        ];
5895        let b = array![0.0_f64, 0.0, 0.0, 0.0];
5896        let set = ConstraintSet::Dense(
5897            LinearInequalityConstraints::new(a, b).expect("degenerate vertex cone"),
5898        );
5899        let beta = array![0.0_f64, 0.0, 0.0];
5900        let residual = array![3.0_f64, 2.0, 0.0]; // = a1 + 2·a4
5901        let (projected, active) = project_stationarity_residual_on_constraint_set(
5902            &residual,
5903            &beta,
5904            &set,
5905            &[0, 1],
5906        )
5907        .expect("operator NNLS must solve the degenerate vertex");
5908        let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
5909        assert!(
5910            closure <= 1e-9,
5911            "residual is in the cone; projection must close to zero, got {closure:.3e}"
5912        );
5913        assert!(!active.is_empty(), "a supported face must be reported");
5914
5915        // A component outside the cone must survive the projection exactly.
5916        let outside = array![1.0_f64, 0.0, -1.0];
5917        let (projected_outside, _) =
5918            project_stationarity_residual_on_constraint_set(&outside, &beta, &set, &[])
5919                .expect("operator NNLS must solve the outside-component case");
5920        assert_relative_eq!(projected_outside[0], 0.0, epsilon = 1e-9);
5921        assert_relative_eq!(projected_outside[1], 0.0, epsilon = 1e-9);
5922        assert_relative_eq!(projected_outside[2], -1.0, epsilon = 1e-9);
5923    }
5924
5925/// The projector is a KKT certificate input, so a row that is NOT tight at
5926    /// `beta` must never enter the generator set: a residual
5927    /// aligned with a slack row must stay unprojected rather than be absorbed
5928    /// by a constraint that is not active at the iterate.
5929    #[test]
5930    fn operator_nnls_excludes_rows_not_tight_at_beta() {
5931        let a = array![[1.0_f64, 0.0], [0.0, 1.0]];
5932        let b = array![0.0_f64, -1.0]; // row 1 has slack 1 at the origin
5933        let set = ConstraintSet::Dense(
5934            LinearInequalityConstraints::new(a, b).expect("half-tight system"),
5935        );
5936        let beta = array![0.0_f64, 0.0];
5937        let residual = array![0.0_f64, 1.0];
5938        let (projected, active) =
5939            project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5940                .expect("operator NNLS must solve the half-tight system");
5941        assert_relative_eq!(projected[1], 1.0, epsilon = 1e-12);
5942        assert!(
5943            !active.contains(&1),
5944            "slack row 1 must not appear in the certified face"
5945        );
5946    }
5947
5948#[test]
5949    fn separable_khatri_rao_tangent_projection_matches_dense_oracle() {
5950        let cone = small_cone();
5951        let set = ConstraintSet::KhatriRaoCone(cone.clone());
5952        let dense = cone.to_dense().expect("dense projection oracle");
5953        let beta = Array1::<f64>::zeros(set.ncols());
5954        let residual = array![0.4_f64, -0.2, 1.1, -0.7, -0.9, 0.8];
5955
5956        let (operator_projected, _) =
5957            project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5958                .expect("separable operator projection");
5959        let (dense_projected, _) =
5960            project_stationarity_residual_on_constraint_cone(&residual, &dense.a)
5961                .expect("dense cone projection");
5962
5963        for index in 0..residual.len() {
5964            assert_relative_eq!(
5965                operator_projected[index],
5966                dense_projected[index],
5967                epsilon = 1e-8
5968            );
5969        }
5970    }
5971
5972/// The current #979 production shape has hundreds of thousands of
5973    /// factored rows over only 24 coefficients. Projection work must scale
5974    /// with batched row products plus the coefficient-dimensional passive
5975    /// set, not with one primal-QP transition per row id.
5976    #[test]
5977    fn operator_moreau_projection_has_coefficient_sized_support_979() {
5978        let rows = 24_000;
5979        let psi = Array2::from_shape_fn((rows, 3), |(row, column)| {
5980            let axis = (row % 6) / 2;
5981            if column == axis {
5982                if row % 2 == 0 { 1.0 } else { -1.0 }
5983            } else {
5984                0.0
5985            }
5986        });
5987        let cone =
5988            KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5989                .expect("many-row low-dimensional cone");
5990        let dense = cone.to_dense().expect("dense parity oracle");
5991        let set = ConstraintSet::KhatriRaoCone(cone);
5992        let beta = Array1::<f64>::zeros(3);
5993        let residual = array![3.0_f64, -2.0, 1.0];
5994
5995        let (operator_projected, active) =
5996            project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5997                .expect("operator Moreau projection");
5998        let (_, dense_projected) =
5999            nonnegative_cone_multipliers(&dense.a, &residual).expect("dense NNLS oracle");
6000
6001        for index in 0..residual.len() {
6002            assert_relative_eq!(
6003                operator_projected[index],
6004                dense_projected[index],
6005                epsilon = 1e-10
6006            );
6007            assert_relative_eq!(operator_projected[index], 0.0, epsilon = 1e-10);
6008        }
6009        assert!(
6010            active.len() <= residual.len(),
6011            "a three-dimensional cone projection gathered {} supported rows",
6012            active.len()
6013        );
6014    }
6015
6016#[test]
6017    fn operator_tangent_projection_does_not_constrain_interior_rows() {
6018        let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
6019        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
6020            .expect("interior tangent cone");
6021        let set = ConstraintSet::KhatriRaoCone(cone);
6022        // The shaped response row is strictly positive for every observation,
6023        // so its tangent cone is the complete coefficient space. A projection
6024        // against the original cone at the origin would incorrectly erase the
6025        // shaped constant component of this residual.
6026        let beta = array![0.0_f64, 0.0, 1.0, 0.0];
6027        let residual = array![0.0_f64, 0.0, 1.0, 0.0];
6028        let (projected, active) =
6029            project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
6030                .expect("interior tangent projection");
6031
6032        for index in 0..residual.len() {
6033            assert_relative_eq!(projected[index], residual[index], epsilon = 1e-12);
6034        }
6035        assert!(active.is_empty(), "interior rows entered the tangent face");
6036    }
6037
6038#[test]
6039    fn operator_tangent_projection_homogenizes_an_affine_boundary() {
6040        let set = ConstraintSet::Dense(
6041            LinearInequalityConstraints::new(array![[1.0_f64, 0.0]], array![2.0])
6042                .expect("affine half-space"),
6043        );
6044        let beta = array![2.0_f64, 0.0];
6045        let residual = array![1.0_f64, -1.0];
6046        let (projected, active) =
6047            project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[0])
6048                .expect("affine-boundary tangent projection");
6049
6050        assert_relative_eq!(projected[0], 0.0, epsilon = 1e-12);
6051        assert_relative_eq!(projected[1], -1.0, epsilon = 1e-12);
6052        assert_eq!(active, vec![0]);
6053    }
6054
6055}