Skip to main content

gam_solve/
active_set.rs

1use crate::estimate::EstimationError;
2use faer::linalg::solvers::{Lblt as FaerLblt, Solve as FaerSolve, SolveLstsq};
3use faer::Side;
4use gam_linalg::faer_ndarray::{FaerArrayView, FaerLinalgError, FaerSvd, array1_to_col_matmut};
5use gam_linalg::utils::{StableSolver, array_is_finite, boundary_hit_step_fraction};
6use gam_problem::{
7    ConstraintRowId, ConstraintSet, KhatriRaoConeConstraints, LinearInequalityConstraints,
8};
9use ndarray::{Array1, Array2, s};
10use serde::{Deserialize, Serialize};
11use std::cell::Cell;
12use std::collections::HashSet;
13
14/// Primal-feasibility tolerance the inequality-constrained active-set Newton
15/// solver guarantees on its returned iterate, measured in the *scaled*
16/// constraint-row coordinate system in which `A * beta >= b` is expressed.
17///
18/// The solver accepts a step when the worst scaled violation
19/// `max_i (b_i - a_i^T beta)` is below this threshold (see the acceptance
20/// gate in [`solve_linear_constrained_newton_step`] and the KKT diagnostics
21/// in [`compute_constraint_kkt_diagnostics`]). Any consumer that re-derives a
22/// raw (un-scaled) feasibility tolerance from a returned iterate must scale
23/// this value by the per-row normalization that the constraint builder
24/// applied; demanding tighter feasibility than this is inconsistent with the
25/// solver contract and will spuriously reject valid boundary solutions.
26pub const ACTIVE_SET_PRIMAL_FEASIBILITY_TOL: f64 = 1e-8;
27
28/// Scaled slack tolerance for membership in an active working face.
29///
30/// This is intentionally tighter than the public primal-feasibility contract:
31/// a row may be numerically feasible without being an equality at the current
32/// point. Warm-start and terminal face provenance both use this value so a QP
33/// endpoint row cannot remain active after globalization accepts an interior
34/// subsegment of the endpoint chord.
35pub const ACTIVE_SET_WORKING_FACE_TOL: f64 = 1e-10;
36
37/// Step fraction to the EXACT constraint boundary. A strictly feasible row
38/// (`slack > 0`) clips the step so the iterate lands ON the boundary — never
39/// inside the `±ACTIVE_SET_PRIMAL_FEASIBILITY_TOL` certified band. The former
40/// `slack + TOL` target deliberately overshot to the band's outer edge, which
41/// (a) returned band-edge answers from problems whose true optimum is on the
42/// boundary (`maxiter_accepts_current_boundary_solution` observed 0.1+1e-8),
43/// (b) made every downstream feasibility re-check a rounding coin flip, and
44/// (c) broke the strict-interior projection repair, whose own identity-QP
45/// landed band-edge and was then rejected by its interior margin.
46///
47/// A row already at or marginally past the boundary (`slack <= 0`, moving
48/// outward) clips to a zero step: the row is added as blocking, and the
49/// zero-progress machinery (projected-gradient tangent escape at
50/// `primal_step_norm <= tol_step`, plus the post-full-step multiplier
51/// adjudication) inspects the escape the pre-#979 code refused — the original
52/// reason the `+TOL` overshoot was introduced, now handled structurally.
53#[inline]
54fn active_set_boundary_hit_step_fraction(
55    scaled_slack: f64,
56    scaled_directional_change: f64,
57    current_step_limit: f64,
58) -> Option<f64> {
59    boundary_hit_step_fraction(
60        scaled_slack.max(0.0),
61        scaled_directional_change,
62        current_step_limit,
63    )
64}
65
66/// Stationarity tolerance for the strong-KKT acceptance gate: the projected
67/// (working-set) gradient residual ‖∇L − Aᵀλ‖∞, either absolute or relative to
68/// `max(1, ‖∇L‖∞)`, must fall below this to certify a constrained stationary
69/// point. Matched against `ACTIVE_SET_KKT_COMPLEMENTARITY_TOL` so both KKT
70/// residual channels are certified at compatible scales.
71const ACTIVE_SET_KKT_STATIONARITY_TOL: f64 = 2e-6;
72
73/// Complementarity-slackness tolerance for the KKT acceptance gate:
74/// `max_i |λ_i · slack_i|` must fall below this for the
75/// active-inactive partition to be consistent.
76const ACTIVE_SET_KKT_COMPLEMENTARITY_TOL: f64 = 1e-6;
77
78/// Dual-feasibility tolerance for the KKT acceptance gate: every working-set
79/// multiplier must satisfy `λ_i ≥ −ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL` (a
80/// strictly-negative multiplier means the constraint should be released).
81const ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL: f64 = 1e-8;
82
83/// Relaxed stationarity tolerance accepted only on a *genuinely degenerate
84/// boundary face* — one whose active rows are linearly dependent
85/// (`rank(A_active) < n_active`), so the active-row multipliers are non-unique
86/// and the exact projected gradient cannot reach
87/// `ACTIVE_SET_KKT_STATIONARITY_TOL`. Still requires primal feasibility,
88/// complementarity, and a relative-stationarity backstop.
89///
90/// Public so the outer REML / PIRLS validation gate can apply the same
91/// relaxation when the diagnostic reports a rank-deficient active face — a
92/// strict 5e-6 check there would otherwise refuse iterates that the inner
93/// active-set solver legitimately certified via its own `degenerate_boundary_ok`
94/// clause.
95///
96/// NOTE: this is *not* the mechanism that fixes the `shape=concave` /
97/// `shape=convex` cold-vs-warm cache divergence (#873). The B-spline shape path
98/// reparameterizes curvature into independent *coordinate lower bounds*
99/// `γ_j ≥ 0` (see `shape_lower_bounds_local`); any subset of those active rows
100/// is full rank, so `working_set_rank_deficient` stays `false` and this
101/// relaxation never fires for them — and must not be widened to. That bug is a
102/// *seed* problem (a cold seed landing on the cone vertex with every curvature
103/// row tight); it is fixed at the source by
104/// `project_point_strictly_into_feasible_cone`, which starts the inner solve
105/// strictly inside the cone so the strict tolerance is reachable.
106pub(crate) const ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL: f64 = 1e-3;
107
108/// Relative scale on the predicted-decrease test `predicted_delta ≤
109/// −ε·(1 + ‖∇L‖∞·‖d‖∞)`: when the working-set Newton step still buys a
110/// quadratic-model decrease at this relative margin the step is a usable
111/// descent direction even if the KKT residual has not yet tightened.
112const ACTIVE_SET_MODEL_DESCENT_REL_TOL: f64 = 1e-10;
113
114/// KKT diagnostics for inequality-constrained Newton subproblems.
115///
116/// Constraints are represented as `A * beta >= b` in the same coefficient
117/// coordinate system as the returned `beta`.
118///
119/// **Invariants** (held by all producers; not enforced at consumer boundary):
120/// - `n_active <= n_constraints` (a row cannot be active twice).
121/// - All four residual components (`primal_feasibility`, `dual_feasibility`,
122///   `complementarity`, `stationarity`) are `>= 0.0` and finite.
123/// - `active_tolerance >= 0.0` and finite.
124#[derive(Clone, Debug, Serialize, Deserialize)]
125pub struct ConstraintKktDiagnostics {
126    /// Number of inequality rows.
127    pub n_constraints: usize,
128    /// Number of rows considered active (`slack <= active_tolerance`).
129    pub n_active: usize,
130    /// Maximum primal feasibility violation: `max_i max(0, b_i - a_i^T beta)`.
131    pub primal_feasibility: f64,
132    /// Maximum dual feasibility violation: `max_i max(0, -lambda_i)`.
133    pub dual_feasibility: f64,
134    /// Maximum complementarity residual: `max_i |lambda_i * slack_i|`.
135    pub complementarity: f64,
136    /// Stationarity residual: `||grad - A^T lambda||_inf`.
137    pub stationarity: f64,
138    /// Tolerance used to classify active constraints from slacks.
139    pub active_tolerance: f64,
140    /// `true` when the active rows are linearly dependent (`rank(A_active) <
141    /// n_active`) — a *degenerate boundary face*. On such a face the active-row
142    /// multipliers are non-unique and the strict stationarity tolerance is
143    /// unreachable by construction. The inner active-set solver certifies these
144    /// iterates via its `ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL` relaxation;
145    /// the outer validation gate must consult this flag to apply the matching
146    /// relaxation, or it will refuse a legitimately-converged constrained
147    /// optimum and abort the REML startup loop.
148    ///
149    /// NOTE: B-spline `shape=concave`/`shape=convex` faces are *not* degenerate
150    /// — that path reparameterizes curvature into independent coordinate lower
151    /// bounds `γ_j ≥ 0` (full-rank active subsets), so this flag stays `false`
152    /// for them. Their cold-start fragility is a seed problem fixed by the
153    /// strictly-interior seed, not by this relaxation.
154    #[serde(default)]
155    pub working_set_rank_deficient: bool,
156    /// Inf-norm of the (raw, unprojected) gradient at `beta`, `‖gradient‖∞` —
157    /// the natural scale of the stationarity residual. A converged constrained
158    /// optimum drives `stationarity = ‖grad − Aᵀλ‖∞` to zero *relative to* this
159    /// scale, not to a fixed absolute floor: the profiled REML latent objective
160    /// carries an O(n) gradient magnitude even at a genuine stationary point
161    /// (issue #879), so a bare absolute stationarity gate is unreachable there
162    /// by construction. The inner active-set solver already certifies
163    /// convergence on the scale-invariant ratio
164    /// `stationarity / max(gradient_scale, 1)` (its `stationarity_rel` path
165    /// against `ACTIVE_SET_KKT_STATIONARITY_TOL`); the outer validation gate
166    /// [`crate::estimate::reml::outer_eval`]`::enforce_constraint_kkt` consults this
167    /// field to apply the identical relative test, so the two stop on the same
168    /// contract instead of the gate spuriously aborting a constrained optimum
169    /// the solver legitimately reached (issue #989). Defaults to `0.0` when
170    /// deserialized from a model saved before this field existed, which makes
171    /// `max(gradient_scale, 1) = 1` and recovers the bare absolute test.
172    #[serde(default)]
173    pub gradient_scale: f64,
174}
175
176/// Inf-norm `‖g‖∞` used as the scale of the stationarity residual in the
177/// relative KKT criterion shared by the inner active-set solver and the outer
178/// validation gate (see [`ConstraintKktDiagnostics::gradient_scale`]).
179fn gradient_inf_norm(gradient: &Array1<f64>) -> f64 {
180    gradient.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
181}
182
183fn solve_newton_direction_dense(
184    hessian: &Array2<f64>,
185    gradient: &Array1<f64>,
186    direction_out: &mut Array1<f64>,
187) -> Result<(), EstimationError> {
188    if direction_out.len() != gradient.len() {
189        *direction_out = Array1::zeros(gradient.len());
190    }
191
192    let factor = StableSolver::new()
193        .factorize(hessian)
194        .map_err(EstimationError::LinearSystemSolveFailed)?;
195    direction_out.assign(gradient);
196    let mut rhsview = array1_to_col_matmut(direction_out);
197    factor.solve_in_place(rhsview.as_mut());
198    direction_out.mapv_inplace(|v| -v);
199    if array_is_finite(direction_out) {
200        return Ok(());
201    }
202    Err(EstimationError::LinearSystemSolveFailed(
203        FaerLinalgError::FactorizationFailed {
204            context: "active-set newton direction non-finite solve",
205        },
206    ))
207}
208
209fn solve_dense_system_via_pseudoinverse(
210    matrix: &Array2<f64>,
211    rhs: &Array1<f64>,
212    out: &mut Array1<f64>,
213) -> Result<(), EstimationError> {
214    if matrix.nrows() != matrix.ncols() || rhs.len() != matrix.nrows() {
215        crate::bail_invalid_estim!("dense pseudoinverse solve dimension mismatch");
216    }
217
218    let (u_opt, singular, vt_opt) = matrix.svd(true, true).map_err(|_| {
219        EstimationError::InvalidInput("dense pseudoinverse solve SVD failed".to_string())
220    })?;
221    let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
222        crate::bail_invalid_estim!("dense pseudoinverse solve missing singular vectors");
223    };
224
225    let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
226    let tol = 100.0
227        * f64::EPSILON
228        * (matrix.nrows().max(matrix.ncols()).max(1) as f64)
229        * max_singular.max(1.0);
230    let mut coeff = u.t().dot(rhs);
231    for (idx, value) in coeff.iter_mut().enumerate() {
232        let sigma = singular[idx];
233        if sigma.abs() > tol {
234            *value /= sigma;
235        } else {
236            *value = 0.0;
237        }
238    }
239    let solution = vt.t().dot(&coeff);
240    if !array_is_finite(&solution) {
241        crate::bail_invalid_estim!("dense pseudoinverse solve produced non-finite values");
242    }
243    if out.len() != solution.len() {
244        *out = Array1::zeros(solution.len());
245    }
246    out.assign(&solution);
247    Ok(())
248}
249
250/// Least-squares `min_z ‖A z − b‖` for `A` of shape `(p, k)` and rhs `b`
251/// (length `p`), returning `z` (length `k`) or `None` on numerical failure.
252///
253/// - Tall or square (`p ≥ k`): the rank-revealing col-pivoted QR (faer
254///   `solve_lstsq`) — the exact prior behavior, byte-for-byte.
255/// - Wide (`k > p`): the system is underdetermined. This arises on a DEGENERATE
256///   active face where more constraint rows are active than the problem has
257///   dimensions — e.g. a monotone coefficient cone plus many binding per-row
258///   derivative guards. The minimum-norm solution `z = Aᵀ (A Aᵀ)⁺ b` is taken
259///   via the SVD pseudoinverse of the square (possibly rank-deficient) Gram
260///   `A Aᵀ`, matching what a wide-capable least-squares would return.
261///
262/// Faer's `solve_lstsq` asserts `nrows ≥ ncols`, so feeding it a wide matrix
263/// panics — and here that panic would cross the Rust/Python FFI boundary,
264/// violating the typed-error contract. Routing the wide case through this helper
265/// keeps the failure typed: callers receive `None` and treat it as
266/// "not certified" (conservative), never a process abort.
267fn least_squares_min_norm_any_shape(a: &Array2<f64>, b: &Array1<f64>) -> Option<Array1<f64>> {
268    let p = a.nrows();
269    let k = a.ncols();
270    if b.len() != p {
271        return None;
272    }
273    if k == 0 {
274        return Some(Array1::zeros(0));
275    }
276    if k <= p {
277        let mut rhs = Array2::<f64>::zeros((p, 1));
278        rhs.column_mut(0).assign(b);
279        let a_view = FaerArrayView::new(a);
280        let rhs_view = FaerArrayView::new(&rhs);
281        let solved = a_view.as_ref().col_piv_qr().solve_lstsq(rhs_view.as_ref());
282        let mut z = Array1::<f64>::zeros(k);
283        for c in 0..k {
284            let value = solved[(c, 0)];
285            if !value.is_finite() {
286                return None;
287            }
288            z[c] = value;
289        }
290        Some(z)
291    } else {
292        // Underdetermined: min-norm `z = Aᵀ (A Aᵀ)⁺ b`. `A Aᵀ` is `p × p`, so it
293        // satisfies the square precondition of the SVD pseudoinverse solve, and
294        // the pseudoinverse absorbs the rank deficiency of an over-complete face.
295        let gram = a.dot(&a.t());
296        let mut y = Array1::<f64>::zeros(p);
297        solve_dense_system_via_pseudoinverse(&gram, b, &mut y).ok()?;
298        let z = a.t().dot(&y);
299        if z.iter().any(|value| !value.is_finite()) {
300            return None;
301        }
302        Some(z)
303    }
304}
305
306pub(crate) fn compute_constraint_kkt_diagnostics(
307    beta: &Array1<f64>,
308    gradient: &Array1<f64>,
309    constraints: &LinearInequalityConstraints,
310) -> ConstraintKktDiagnostics {
311    let m = constraints.a.nrows();
312    let active_tolerance = ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
313
314    // Measure feasibility in the *scaled* (geometric) coordinate system the
315    // solver's tolerance is expressed in: normalize each inequality
316    // `a_i·β ≥ b_i` by ‖a_i‖ so its slack becomes the signed Euclidean
317    // distance from β to the constraint hyperplane. Without this, a row with a
318    // large norm — e.g. a B-spline endpoint *derivative* clamp, whose rows
319    // carry ‖a_i‖ ≫ 1 — reports a raw slack inflated by ‖a_i‖, so an iterate
320    // that is feasible to the solver's scaled `ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`
321    // guarantee can still exceed a raw primal gate downstream and be spuriously
322    // refused. Per-row normalization makes the diagnostic scale-invariant and
323    // consistent with that contract. Dual/complementarity/stationarity are
324    // invariant under this positive per-row rescaling (with λ̂_i = ‖a_i‖·λ_i:
325    // Âᵀλ̂ = Aᵀλ and λ̂_i·ŝ_i = λ_i·s_i), so only primal feasibility and the
326    // active-set threshold change meaning — both toward the geometric distance
327    // the tolerance is meant to bound.
328    let p = constraints.a.ncols();
329    let mut a_scaled = constraints.a.clone();
330    let mut b_scaled = constraints.b.clone();
331    for i in 0..m {
332        let n_i = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
333        if n_i > 0.0 {
334            let inv = 1.0 / n_i;
335            a_scaled.row_mut(i).mapv_inplace(|v| v * inv);
336            b_scaled[i] *= inv;
337        }
338    }
339
340    let mut slack = Array1::<f64>::zeros(m);
341    let mut primal_feasibility: f64 = 0.0;
342    for i in 0..m {
343        let s_i = a_scaled.row(i).dot(beta) - b_scaled[i];
344        slack[i] = s_i;
345        primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
346    }
347
348    let active_idx: Vec<usize> = (0..m).filter(|&i| slack[i] <= active_tolerance).collect();
349    let mut lambda = Array1::<f64>::zeros(m);
350    let mut working_set_rank_deficient = false;
351    if !active_idx.is_empty() {
352        let n_active = active_idx.len();
353        let mut a_active = Array2::<f64>::zeros((n_active, p));
354        for (r, &idx) in active_idx.iter().enumerate() {
355            a_active.row_mut(r).assign(&a_scaled.row(idx));
356        }
357        if let Some((_, lambda_active)) =
358            project_stationarity_residual_on_constraint_cone(gradient, &a_active)
359        {
360            for (r, &idx) in active_idx.iter().enumerate() {
361                lambda[idx] = lambda_active[r];
362            }
363        }
364        // Rank-deficiency detection on the (scaled) active rows. Per-row
365        // positive scaling is rank-preserving, so this answers the same
366        // question the inner solver's `CompressedActiveWorkingSet::
367        // is_degenerate_face` does — `rank(A_active) < n_active`. For curvature
368        // constraints the second-difference operator forces dependence
369        // whenever more than `p` rows bind, and for monotonicity the
370        // first-difference operator does so beyond a similar count. The
371        // diagnostic exposes the flag so the outer validation gate can apply
372        // the same `ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL` relaxation
373        // the inner solver does, instead of refusing the iterate at strict
374        // `ACTIVE_SET_KKT_STATIONARITY_TOL`.
375        working_set_rank_deficient = if n_active > p {
376            true
377        } else if n_active > 1 {
378            let groups: Vec<Vec<usize>> = (0..n_active).map(|i| vec![i]).collect();
379            let b_dummy = Array1::<f64>::zeros(n_active);
380            let (reduced_a, _, _, _) =
381                rank_reduce_rows_pivoted_qr_with_dependence(a_active, b_dummy, groups);
382            reduced_a.nrows() < n_active
383        } else {
384            false
385        };
386    }
387
388    let mut dual_feasibility: f64 = 0.0;
389    let mut complementarity: f64 = 0.0;
390    for i in 0..m {
391        dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
392        complementarity = complementarity.max((lambda[i] * slack[i]).abs());
393    }
394    let stationarity = {
395        let mut resid = gradient.to_owned();
396        resid -= &a_scaled.t().dot(&lambda);
397        resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
398    };
399
400    ConstraintKktDiagnostics {
401        n_constraints: m,
402        n_active: active_idx.len(),
403        primal_feasibility,
404        dual_feasibility,
405        complementarity,
406        stationarity,
407        active_tolerance,
408        working_set_rank_deficient,
409        gradient_scale: gradient_inf_norm(gradient),
410    }
411}
412
413/// Lawson–Hanson nonnegative least squares onto a finitely generated cone.
414///
415/// Solves `min_{λ ≥ 0} ‖rowsᵀ λ − target‖₂` for a row block `rows` (`m × p`,
416/// original row units) and returns `(λ, projected)` with
417/// `projected = target − rowsᵀ λ`. By the Moreau decomposition `rowsᵀ λ` is
418/// the Euclidean projection of `target` onto the cone generated by the rows,
419/// so `projected` is the projection onto that cone's polar.
420///
421/// This is the existence-form dual-feasibility certificate for degenerate
422/// working faces: multipliers on a rank-deficient face are non-unique, and
423/// any single reconstruction (KKT least-squares, per-group attribution) can
424/// carry huge canceling ± components — reporting `dual ≫ 0` at a point where
425/// a different `λ ≥ 0` closes stationarity exactly (#2298 survival
426/// monotonicity faces, #979 CTN Khatri–Rao faces). NNLS answers the right
427/// question: does ANY nonnegative multiplier close stationarity?
428///
429/// Rows are unit-normalized internally so pivot ordering and tolerances are
430/// scale-invariant; the returned `λ` is in original row units. Zero rows
431/// carry `λ = 0`. Classic LH terminates after finitely many passive-set
432/// changes; a `3m + 30` outer guard bounds float pathologies and returns the
433/// best iterate — callers treat an unclosed residual as "not certified", so
434/// early return is conservative, never false-green.
435pub(crate) fn nonnegative_cone_multipliers(
436    rows: &Array2<f64>,
437    target: &Array1<f64>,
438) -> Option<(Array1<f64>, Array1<f64>)> {
439    let p = target.len();
440    let m = rows.nrows();
441    if rows.ncols() != p {
442        return None;
443    }
444    if m == 0 {
445        return Some((Array1::zeros(0), target.clone()));
446    }
447    if target.iter().any(|v| !v.is_finite()) || rows.iter().any(|v| !v.is_finite()) {
448        return None;
449    }
450    let mut norms = Array1::<f64>::zeros(m);
451    let mut unit = Array2::<f64>::zeros((m, p));
452    for i in 0..m {
453        let norm = rows.row(i).dot(&rows.row(i)).sqrt();
454        norms[i] = norm;
455        if norm > 0.0 {
456            unit.row_mut(i).assign(&(&rows.row(i) / norm));
457        }
458    }
459    let target_inf = target.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
460    if target_inf == 0.0 {
461        return Some((Array1::zeros(m), target.clone()));
462    }
463    // Gradient tolerance in λ-space: with unit rows, `w_i = a_î·r` is bounded
464    // by ‖r‖, so a relative band on the target scale is dimensionless.
465    let tol_w = 1e-10 * target_inf;
466    let lambda_floor = 1e-14 * target_inf;
467
468    let mut lambda_unit = Array1::<f64>::zeros(m);
469    let mut passive: Vec<usize> = Vec::new();
470    let mut in_passive = vec![false; m];
471    let mut residual = target.clone();
472    // Rows whose trial coefficient collapsed to zero at the current residual;
473    // re-eligible as soon as the residual moves. Prevents an add/drop loop on
474    // exactly degenerate geometry.
475    let mut banned = vec![false; m];
476
477    let solve_passive = |passive: &[usize]| -> Option<Array1<f64>> {
478        let k = passive.len();
479        // `design` is `p × k` (each column is a unit active row). On a degenerate
480        // over-complete face `k` can exceed `p` (more active rows than
481        // dimensions); the min-norm least-squares helper handles that wide case
482        // instead of panicking inside faer's tall-only `solve_lstsq`.
483        let mut design = Array2::<f64>::zeros((p, k));
484        for (col, &row) in passive.iter().enumerate() {
485            design.column_mut(col).assign(&unit.row(row));
486        }
487        least_squares_min_norm_any_shape(&design, target)
488    };
489
490    let max_outer = 3 * m + 30;
491    for _ in 0..max_outer {
492        // Most-ascent candidate among non-passive, non-banned rows.
493        let mut best: Option<(usize, f64)> = None;
494        for i in 0..m {
495            if in_passive[i] || banned[i] || norms[i] <= 0.0 {
496                continue;
497            }
498            let w = unit.row(i).dot(&residual);
499            if w > tol_w && best.map(|(_, bw)| w > bw).unwrap_or(true) {
500                best = Some((i, w));
501            }
502        }
503        let Some((entering, _)) = best else {
504            break;
505        };
506        passive.push(entering);
507        in_passive[entering] = true;
508
509        let mut inner_ok = false;
510        for _ in 0..(m + 2) {
511            let Some(z) = solve_passive(&passive) else {
512                return None;
513            };
514            let min_z = z.iter().copied().fold(f64::INFINITY, f64::min);
515            if min_z > lambda_floor {
516                for (pos, &row) in passive.iter().enumerate() {
517                    lambda_unit[row] = z[pos];
518                }
519                inner_ok = true;
520                break;
521            }
522            // Interpolate toward z until the first coefficient hits zero,
523            // then drop every zeroed row from the passive set.
524            let mut alpha = 1.0_f64;
525            for (pos, &row) in passive.iter().enumerate() {
526                if z[pos] <= lambda_floor {
527                    let current = lambda_unit[row];
528                    let denom = current - z[pos];
529                    if denom > 0.0 {
530                        alpha = alpha.min((current / denom).clamp(0.0, 1.0));
531                    } else {
532                        alpha = 0.0;
533                    }
534                }
535            }
536            for (pos, &row) in passive.iter().enumerate() {
537                lambda_unit[row] += alpha * (z[pos] - lambda_unit[row]);
538            }
539            let mut retained = Vec::with_capacity(passive.len());
540            for &row in &passive {
541                if lambda_unit[row] > lambda_floor {
542                    retained.push(row);
543                } else {
544                    lambda_unit[row] = 0.0;
545                    in_passive[row] = false;
546                    // The row failed at THIS residual; ban it until the
547                    // residual moves so a degenerate add/drop pair cannot
548                    // cycle within one outer round.
549                    banned[row] = true;
550                }
551            }
552            if retained.len() == passive.len() {
553                // Nothing dropped despite a non-positive trial coefficient:
554                // numerically stuck; stop refining this passive set.
555                inner_ok = true;
556                for (pos, &row) in passive.iter().enumerate() {
557                    lambda_unit[row] = z[pos].max(0.0);
558                }
559                break;
560            }
561            passive = retained;
562            if passive.is_empty() {
563                break;
564            }
565        }
566        // Refresh the residual; any movement re-enables banned rows.
567        let mut fitted = Array1::<f64>::zeros(p);
568        for &row in &passive {
569            fitted.scaled_add(lambda_unit[row], &unit.row(row));
570        }
571        let new_residual = target - &fitted;
572        let moved = new_residual
573            .iter()
574            .zip(residual.iter())
575            .any(|(a, b)| (a - b).abs() > 1e-15 * target_inf);
576        residual = new_residual;
577        if moved {
578            banned.iter_mut().for_each(|b| *b = false);
579        } else if !inner_ok {
580            break;
581        }
582    }
583
584    let mut lambda = Array1::<f64>::zeros(m);
585    for i in 0..m {
586        if norms[i] > 0.0 {
587            lambda[i] = lambda_unit[i] / norms[i];
588        }
589    }
590    if !array_is_finite(&lambda) || !array_is_finite(&residual) {
591        return None;
592    }
593    Some((lambda, residual))
594}
595
596pub fn project_stationarity_residual_on_constraint_cone(
597    residual: &Array1<f64>,
598    active_a: &Array2<f64>,
599) -> Option<(Array1<f64>, Array1<f64>)> {
600    let p = residual.len();
601    if active_a.ncols() != p {
602        return None;
603    }
604    if active_a.nrows() == 0 {
605        return Some((residual.clone(), Array1::zeros(0)));
606    }
607    if let Some(result) = moreau_projection_via_primal_qp(residual, active_a) {
608        return Some(result);
609    }
610    // The primal QP route can fail on degenerate faces (working-set cycling,
611    // multiplier-reconstruction refusals). Projection onto a finitely
612    // generated cone IS nonnegative least squares (Moreau), so the direct LH
613    // solve is an exact fallback: `projected = residual − Aᵀλ*` with
614    // `λ* = argmin_{λ≥0} ‖residual − Aᵀλ‖`. Reconstruction is exact by
615    // construction here, so no cross-check gate is needed.
616    nonnegative_cone_multipliers(active_a, residual).map(|(lambda, projected)| (projected, lambda))
617}
618
619fn moreau_projection_via_primal_qp(
620    residual: &Array1<f64>,
621    active_a: &Array2<f64>,
622) -> Option<(Array1<f64>, Array1<f64>)> {
623    let p = residual.len();
624
625    let m = active_a.nrows();
626    let constraints = LinearInequalityConstraints::new(active_a.clone(), Array1::<f64>::zeros(m))
627        .ok()?
628        .canonicalized()
629        .ok()?;
630
631    // Moreau projection onto the tangent cone is the strictly convex primal
632    // QP
633    //
634    //   min_d  1/2 ||d + residual||^2    s.t. A d >= 0.
635    //
636    // The former implementation solved its dual with a second, nested
637    // Lawson-Hanson active-set method. On the issue-979 CTN faces that nested
638    // solver enumerated up to `3*m*m` passive sets, first with one dense SVD
639    // and later with one fresh QR per pivot. Route the geometry through the
640    // repository's single Bland-ordered, rank-compressed primal QP instead.
641    // Its identity Hessian is strictly positive definite, so the returned face
642    // and direction are unique and need no projected-gradient escape.
643    let identity = Array2::<f64>::eye(p);
644    let origin = Array1::<f64>::zeros(p);
645    let mut tangent_direction = Array1::<f64>::zeros(p);
646    let mut tangent_active = Vec::new();
647    let max_iterations = (p + m + 8) * 4;
648    solve_newton_direction_with_linear_constraints_impl(
649        &identity,
650        residual,
651        &origin,
652        &constraints,
653        &mut tangent_direction,
654        Some(&mut tangent_active),
655        max_iterations,
656        false,
657    )
658    .ok()?;
659    if !array_is_finite(&tangent_direction) {
660        return None;
661    }
662    let projected = -&tangent_direction;
663
664    // Reconstruct the primal QP's nonnegative KKT multipliers once on its
665    // canonical full-row-rank face: residual + d = A_active^T lambda. This is
666    // a single rectangular least-squares solve, never normal equations and
667    // never another active-set loop.
668    let mut lambda_canonical = Array1::<f64>::zeros(m);
669    if !tangent_active.is_empty() {
670        let gathered = gather_linear_constraint_rows(&constraints, &tangent_active).ok()?;
671        // `design` is `p × |tangent_active|`. The face is meant to be
672        // full-row-rank (≤ p active rows), but a degenerate iterate can present
673        // more active rows than dimensions; the min-norm helper handles that wide
674        // case rather than panicking inside faer's tall-only `solve_lstsq`.
675        let design = gathered.a.t().to_owned();
676        let solved = least_squares_min_norm_any_shape(&design, &(residual + &tangent_direction))?;
677        let scale = residual
678            .iter()
679            .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
680            .max(1.0);
681        let tol = 100.0 * f64::EPSILON * (p.max(m) as f64) * scale;
682        for (position, &row) in tangent_active.iter().enumerate() {
683            let value = solved[position];
684            if !value.is_finite() || value < -tol {
685                return None;
686            }
687            lambda_canonical[row] = value.max(0.0);
688        }
689    }
690    let reconstructed = residual - &constraints.a.t().dot(&lambda_canonical);
691    let reconstruction_error = reconstructed
692        .iter()
693        .zip(projected.iter())
694        .fold(0.0_f64, |acc, (&left, &right)| {
695            acc.max((left - right).abs())
696        });
697    let scale = residual
698        .iter()
699        .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
700        .max(1.0);
701    if reconstruction_error > 1e-8 * scale || !array_is_finite(&lambda_canonical) {
702        return None;
703    }
704
705    // `constraints` has unit rows, but this public helper historically returns
706    // multipliers in the caller's original row units. If a_i^canon = a_i/s_i,
707    // then lambda_i^original = lambda_i^canon/s_i preserves
708    // A_original^T lambda_original = A_canon^T lambda_canon.
709    let mut lambda = Array1::<f64>::zeros(m);
710    for row in 0..m {
711        let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
712        if norm > 0.0 {
713            lambda[row] = lambda_canonical[row] / norm;
714        }
715    }
716    Some((projected, lambda))
717}
718
719pub(crate) fn feasible_point_for_linear_constraints(
720    constraints: &LinearInequalityConstraints,
721    p: usize,
722) -> Option<Array1<f64>> {
723    if constraints.a.ncols() != p
724        || constraints.a.nrows() == 0
725        || constraints.b.len() != constraints.a.nrows()
726    {
727        return None;
728    }
729    // The zero-vector shortcut must compare `b` in GEOMETRIC (per-row-scaled)
730    // units: on raw `b` alone, `1e-20·β ≥ 1e-20` — the same half-space as
731    // `β ≥ 1` — would accept `β = 0`. A numerically-zero row is vacuous when
732    // `b_i ≤ 0` and infeasible (no seed exists) when `b_i > 0`.
733    let mut all_scaled_b_tiny = true;
734    for i in 0..constraints.a.nrows() {
735        let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
736        if norm > 0.0 {
737            if constraints.b[i].abs() > 1e-14 * norm {
738                all_scaled_b_tiny = false;
739            }
740        } else if constraints.b[i] > 0.0 {
741            return None;
742        }
743    }
744    if all_scaled_b_tiny {
745        return Some(Array1::zeros(p));
746    }
747
748    let gram = constraints.a.dot(&constraints.a.t());
749    let (u_opt, singular, vt_opt) = gram.svd(true, true).ok()?;
750    let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
751        return None;
752    };
753    let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
754    // Rank tolerance relative to the LARGEST singular value only — an absolute
755    // `max(σ_max, 1)` floor declares a uniformly small (but perfectly
756    // well-conditioned) system rank-deficient purely because of its units.
757    let tol = 100.0 * f64::EPSILON * constraints.a.nrows().max(1) as f64 * max_singular;
758    let mut coeff = u.t().dot(&constraints.b);
759    for (idx, value) in coeff.iter_mut().enumerate() {
760        let sigma = singular[idx];
761        if sigma.abs() > tol {
762            *value /= sigma;
763        } else {
764            *value = 0.0;
765        }
766    }
767    let dual = vt.t().dot(&coeff);
768    let beta = constraints.a.t().dot(&dual);
769    if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
770        return None;
771    }
772    // Accept on per-row GEOMETRIC slack (raw slack over ‖a_i‖), the same
773    // scale-invariant metric the active-set gates use.
774    let feasible = (0..constraints.a.nrows()).all(|i| {
775        let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
776        if norm > 0.0 {
777            (constraints.a.row(i).dot(&beta) - constraints.b[i]) / norm >= -1e-8
778        } else {
779            constraints.b[i] <= 0.0
780        }
781    });
782    if feasible { Some(beta) } else { None }
783}
784
785/// Strictly-interior margin (in per-row geometric / scaled-slack units) required
786/// of the projected cold-start seed produced by
787/// [`project_point_strictly_into_feasible_cone`]. Each constraint row is shifted
788/// to `a_iᵀβ ≥ b_i + ACTIVE_SET_INTERIOR_SEED_MARGIN·‖a_i‖` so that, scaled by
789/// `‖a_i‖`, every row of the returned seed has slack `≥` this margin. The value
790/// is far above the active-set activation threshold (`tol_active = 1e-10`) so the
791/// initial working set the QP step solver builds from the seed is **empty** — no
792/// row is mistaken for "on the boundary" — yet small enough that the seed stays a
793/// negligible distance from the data-driven projection it is derived from.
794const ACTIVE_SET_INTERIOR_SEED_MARGIN: f64 = 1e-6;
795
796/// The strictly-interior cold-start margin (scaled-slack units) that
797/// [`project_point_strictly_into_feasible_cone`] guarantees on its returned
798/// seed. Exposed so the P-IRLS seed builder can decide, on the same scale,
799/// whether the current seed is already strictly interior (and may be used as-is)
800/// or sits on / outside the cone boundary (and must be projected).
801#[inline]
802pub(crate) fn interior_seed_margin() -> f64 {
803    ACTIVE_SET_INTERIOR_SEED_MARGIN
804}
805
806/// Maximum nesting depth of the strictly-interior feasibility repair before the
807/// solver stops re-projecting and surfaces an honest constraint-violation error.
808///
809/// [`project_point_strictly_into_feasible_cone`] and
810/// [`solve_quadratic_with_linear_constraints`] are mutually recursive: the
811/// quadratic solve's final feasibility contract (#1108) projects an infeasible
812/// iterate back onto the cone, and that projection itself solves an inner
813/// identity-Hessian QP whose *own* feasibility contract can project again. On a
814/// well-conditioned cone the repair converges at depth 0–1 — each level shifts
815/// every one-sided row strictly further inward. But on near-anti-parallel rows
816/// (the clamped / anchored monotone time-warp constraints an interval-censored
817/// survival fit emits, which are only *near* — not exactly — anti-parallel and so
818/// slip past the zero-width equality lift below), the inward-shifted QP can keep
819/// returning an infeasible candidate, so the `solve ↔ project` recursion never
820/// bottoms out and exhausts the worker stack. A cone that cannot be certified
821/// feasible within this many successive inward shifts is degenerate; the
822/// projection then returns `None`, which the quadratic solve reports as
823/// [`EstimationError::ParameterConstraintViolation`] rather than recursing.
824const MAX_FEASIBILITY_REPAIR_DEPTH: u32 = 16;
825
826thread_local! {
827    /// Current nesting depth of the `solve ↔ project` feasibility-repair cycle on
828    /// this thread. Every recursion path (the quadratic solve's repair step and
829    /// the projected-gradient fallback alike) routes back through
830    /// [`project_point_strictly_into_feasible_cone`], so bounding its re-entrancy
831    /// bounds the whole cycle. Per-thread because each solve runs to completion on
832    /// a single call stack; independent solves on other worker threads carry their
833    /// own counter.
834    static FEASIBILITY_REPAIR_DEPTH: Cell<u32> = const { Cell::new(0) };
835}
836
837/// RAII depth counter for the feasibility-repair recursion. [`enter`] increments
838/// the per-thread depth and returns a guard whose `Drop` restores it on every
839/// exit path — including the projection's many `return None` branches — so the
840/// counter can never leak. It yields `None` once
841/// [`MAX_FEASIBILITY_REPAIR_DEPTH`] is reached, so the caller bails out of the
842/// recursion instead of descending another level.
843///
844/// [`enter`]: FeasibilityRepairGuard::enter
845struct FeasibilityRepairGuard;
846
847impl FeasibilityRepairGuard {
848    fn enter() -> Option<Self> {
849        FEASIBILITY_REPAIR_DEPTH.with(|depth| {
850            let current = depth.get();
851            if current >= MAX_FEASIBILITY_REPAIR_DEPTH {
852                None
853            } else {
854                depth.set(current + 1);
855                Some(Self)
856            }
857        })
858    }
859}
860
861impl Drop for FeasibilityRepairGuard {
862    fn drop(&mut self) {
863        FEASIBILITY_REPAIR_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
864    }
865}
866
867/// Project `point` to a *strictly interior* feasible point of the polyhedron
868/// `{β : A·β ≥ b}`: the solution of `min_β ½‖β − point‖²` subject to the
869/// margin-shifted system `A·β ≥ b + δ·‖a_i‖`, with `δ =
870/// ACTIVE_SET_INTERIOR_SEED_MARGIN`.
871///
872/// This is the principled feasible cold-start seed for a shape-constrained
873/// (convex / concave / monotone) smooth. It is qualitatively different from
874/// [`feasible_point_for_linear_constraints`], which returns the *minimum-norm*
875/// feasible point — for a homogeneous cone (`b = 0`, as the second-difference
876/// convexity / concavity constraints are) that minimum-norm point is the cone
877/// **vertex** `β = 0` (a flat line) where every constraint row is tight. A
878/// shape-constrained P-IRLS launched from that vertex hands the inner active-set
879/// QP an all-rows-active working set (every row's slack is `0`), and the QP then
880/// stalls on a degenerate, non-stationary face of the cone. The fit's success
881/// then depends on whether a warm-start seed happens to drop it into the right
882/// basin, so the same fit silently diverges (or aborts) between a cold and a
883/// warm cache (#873).
884///
885/// Requiring a strictly-positive margin on every row makes the returned seed an
886/// interior point: the QP step solver starts from an **empty** active set and
887/// adds only the genuinely binding rows, converging to the certified constrained
888/// stationary point regardless of cache state. The projection is the
889/// identity-Hessian instance of [`solve_quadratic_with_linear_constraints`]
890/// (`H = I`, `rhs = point` ⇒ minimizing `½‖β − point‖²`), so the interior seed is
891/// also the *nearest* strictly-interior point to the supplied data-driven
892/// `point` — it inherits whatever curvature `point` already carries. Returns
893/// `None` if the constraints are malformed or the active-set QP cannot certify a
894/// feasible solution, so callers can fall back.
895pub fn project_point_strictly_into_feasible_cone(
896    point: &Array1<f64>,
897    constraints: &LinearInequalityConstraints,
898) -> Option<Array1<f64>> {
899    // Bound the mutually-recursive `solve ↔ project` feasibility repair. Every
900    // recursion path re-enters here, so a too-deep call returns `None` (a
901    // degenerate cone the strictly-interior QP cannot certify) instead of
902    // recursing until the worker stack overflows. The guard restores the
903    // per-thread depth on every early return via its `Drop`.
904    let repair_guard = FeasibilityRepairGuard::enter()?;
905    let p = point.len();
906    let m = constraints.a.nrows();
907    if constraints.a.ncols() != p || m == 0 || constraints.b.len() != m {
908        return None;
909    }
910    let norms: Vec<f64> = (0..m)
911        .map(|i| constraints.a.row(i).dot(&constraints.a.row(i)).sqrt())
912        .collect();
913
914    // Classify rows. An *anti-parallel pair* with ~zero scaled feasible-slab
915    // width is an EQUALITY `rᵀβ = t` encoded as `{rᵀβ ≥ t, −rᵀβ ≥ −t}` (the
916    // canonical encoding emitted by a clamped / anchored boundary condition).
917    // Representing an equality as two opposing inequalities makes the inequality
918    // active-set QP CYCLE: it adds one side, the equality-split multiplier turns
919    // the other negative, it removes it, and the working set repeats until cycle
920    // detection aborts the solve — so the projection would fail and the caller
921    // would fall back to the cone vertex, silently reintroducing the #873 seed
922    // for the *combined* case (`shape=concave`/`convex` with `bc=clamped`). So we
923    // lift such pairs out as genuine equalities, eliminate them through the null
924    // space, and run the strictly-interior QP only on the one-sided rows. A pure
925    // shape cone has no anti-parallel rows, so `equality_rows` is empty and this
926    // reduces to the original single-QP path verbatim.
927    const ANTIPARALLEL_COS_TOL: f64 = -1.0 + 1e-9;
928    const EQUALITY_WIDTH_TOL: f64 = 1e-9;
929    let mut is_equality_member = vec![false; m];
930    let mut equality_rows: Vec<usize> = Vec::new();
931    let mut margin = vec![ACTIVE_SET_INTERIOR_SEED_MARGIN; m];
932    for i in 0..m {
933        if norms[i] == 0.0 {
934            margin[i] = 0.0;
935            continue;
936        }
937        for j in (i + 1)..m {
938            if norms[j] == 0.0 {
939                continue;
940            }
941            let cos = constraints.a.row(i).dot(&constraints.a.row(j)) / (norms[i] * norms[j]);
942            if cos > ANTIPARALLEL_COS_TOL {
943                continue;
944            }
945            // Anti-parallel rows â and −â: row i is `âᵀβ ≥ b_i/‖a_i‖`, row j is
946            // `âᵀβ ≤ −b_j/‖a_j‖`. Scaled feasible-slab width:
947            let width = -constraints.b[j] / norms[j] - constraints.b[i] / norms[i];
948            if width.abs() <= EQUALITY_WIDTH_TOL {
949                // Zero width ⇒ equality. Record it once (row i's orientation) and
950                // exclude both rows from the one-sided interior shift.
951                if !is_equality_member[i] && !is_equality_member[j] {
952                    equality_rows.push(i);
953                }
954                is_equality_member[i] = true;
955                is_equality_member[j] = true;
956            } else {
957                // Genuine (wide) two-sided bound: cap each side's inward shift at
958                // `w/3` so the shifted slab `s_i + s_j ≤ w` stays non-empty.
959                let cap = (width / 3.0).max(0.0);
960                margin[i] = margin[i].min(cap);
961                margin[j] = margin[j].min(cap);
962            }
963        }
964    }
965
966    // One-sided rows (everything not lifted into an equality), shifted strictly
967    // inward by `margin·‖a‖`.
968    let ineq_rows: Vec<usize> = (0..m).filter(|&i| !is_equality_member[i]).collect();
969    let mut a_ineq = Array2::<f64>::zeros((ineq_rows.len(), p));
970    let mut b_ineq = Array1::<f64>::zeros(ineq_rows.len());
971    for (r, &i) in ineq_rows.iter().enumerate() {
972        a_ineq.row_mut(r).assign(&constraints.a.row(i));
973        b_ineq[r] = constraints.b[i] + margin[i] * norms[i];
974    }
975
976    let beta = if equality_rows.is_empty() {
977        // No equalities: the original single strictly-interior QP
978        // (`min ½‖β − point‖²` s.t. the margin-shifted one-sided rows).
979        let interior = LinearInequalityConstraints::new(a_ineq, b_ineq)
980            .expect("shifted interior constraint shape invariant");
981        let identity = Array2::<f64>::eye(p);
982        solve_quadratic_with_linear_constraints(&identity, point, point, &interior, None)
983            .ok()?
984            .0
985    } else {
986        // Eliminate `E β = e` through its null space. From the thin SVD
987        // `E = U Σ Vᵀ` (rank `r`): the row space is `span(v_0..v_{r-1})`, the
988        // minimum-norm particular solution is `β_p = Σ_{i<r} (uᵢᵀe / σᵢ) vᵢ`, and
989        // an orthonormal null basis `Z` (p × (p−r)) is the complement of the row
990        // space (built by Gram-Schmidt of the standard axes — `p` is a single
991        // smooth-term width, so this is cheap and exact). Writing `β = β_p + Z u`
992        // and using `ZᵀZ = I`, the projection becomes the reduced strictly-
993        // interior QP `min ½‖u − Zᵀ(point − β_p)‖²` s.t. `(A_ineq Z) u ≥ b_ineq −
994        // A_ineq β_p`, whose rows carry no anti-parallel pair, so it can't cycle.
995        let k = equality_rows.len();
996        let mut e_mat = Array2::<f64>::zeros((k, p));
997        let mut e_rhs = Array1::<f64>::zeros(k);
998        for (r, &i) in equality_rows.iter().enumerate() {
999            e_mat.row_mut(r).assign(&constraints.a.row(i));
1000            e_rhs[r] = constraints.b[i];
1001        }
1002        let (u_opt, sing, vt_opt) = e_mat.svd(true, true).ok()?;
1003        let (u_mat, vt) = (u_opt?, vt_opt?);
1004        let smax = sing.iter().fold(0.0_f64, |acc, &v| acc.max(v));
1005        let rank_tol = smax.max(1.0) * (k.max(p) as f64) * f64::EPSILON * 100.0;
1006        let rank = sing.iter().filter(|&&s| s > rank_tol).count();
1007        if rank == 0 || rank >= p {
1008            return None;
1009        }
1010        let mut beta_p = Array1::<f64>::zeros(p);
1011        for idx in 0..rank {
1012            let coeff = u_mat.column(idx).dot(&e_rhs) / sing[idx];
1013            beta_p.scaled_add(coeff, &vt.row(idx));
1014        }
1015        // Orthonormal null basis: Gram-Schmidt the standard axes against the row
1016        // space `vt[0..rank]` and the null vectors collected so far.
1017        let mut basis: Vec<Array1<f64>> = (0..rank).map(|i| vt.row(i).to_owned()).collect();
1018        let mut z = Array2::<f64>::zeros((p, p - rank));
1019        let mut collected = 0usize;
1020        for axis in 0..p {
1021            if collected == p - rank {
1022                break;
1023            }
1024            let mut v = Array1::<f64>::zeros(p);
1025            v[axis] = 1.0;
1026            for q in basis.iter() {
1027                let c = q.dot(&v);
1028                v.scaled_add(-c, q);
1029            }
1030            let nrm = v.dot(&v).sqrt();
1031            if nrm > 1e-8 {
1032                v /= nrm;
1033                z.column_mut(collected).assign(&v);
1034                basis.push(v);
1035                collected += 1;
1036            }
1037        }
1038        if collected != p - rank {
1039            return None;
1040        }
1041        let a_red = a_ineq.dot(&z);
1042        let b_red = &b_ineq - &a_ineq.dot(&beta_p);
1043        let u0 = z.t().dot(&(point - &beta_p));
1044        let reduced = LinearInequalityConstraints::new(a_red, b_red)
1045            .expect("reduced constraint shape invariant");
1046        let identity = Array2::<f64>::eye(z.ncols());
1047        let (u_sol, _active) =
1048            solve_quadratic_with_linear_constraints(&identity, &u0, &u0, &reduced, None).ok()?;
1049        &beta_p + &z.dot(&u_sol)
1050    };
1051
1052    if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
1053        return None;
1054    }
1055    // Certify against the ORIGINAL constraints: every genuine one-sided row must
1056    // clear (most of) its requested margin so the QP step solver sees no spurious
1057    // active rows; equality-pair rows need only be feasible — they are
1058    // legitimately tight.
1059    const SEED_FEASIBILITY_TOL: f64 = 1e-9;
1060    for i in 0..m {
1061        let s = scaled_constraint_slack(&beta, constraints, i);
1062        let lower = if is_equality_member[i] {
1063            -SEED_FEASIBILITY_TOL
1064        } else {
1065            0.5 * margin[i] - SEED_FEASIBILITY_TOL
1066        };
1067        if s < lower {
1068            return None;
1069        }
1070    }
1071    // All mutually-recursive `solve ↔ project` calls are complete; release the
1072    // per-thread recursion-depth guard explicitly on the success path (early
1073    // returns above release it via `Drop`). Named + dropped (not `let _guard`)
1074    // to satisfy the underscore-binding ban without changing its lifetime.
1075    drop(repair_guard);
1076    Some(beta)
1077}
1078
1079/// Worst primal-feasibility violation across all rows of `constraints`,
1080/// measured in the per-row-scaled (geometric) coordinate system documented on
1081/// [`ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`]. A row's slack is divided by ‖a_i‖
1082/// so the returned value is the signed Euclidean distance from `beta` to the
1083/// constraint hyperplane, not the raw dot-product residual. This matches
1084/// [`compute_constraint_kkt_diagnostics`] and keeps the in-solver acceptance
1085/// gate, the downstream KKT report, and any post-fit feasibility check on a
1086/// single scale-invariant metric. A row with ‖a_i‖ = 38 (a typical B-spline
1087/// endpoint-derivative clamp at k = 12) has a 1e-6 raw violation that is only
1088/// 2.6e-8 in geometric units — accepting on raw alone is anisotropic in
1089/// input-space and breaks the published contract.
1090fn max_linear_constraint_violation(
1091    beta: &Array1<f64>,
1092    constraints: &LinearInequalityConstraints,
1093) -> (f64, usize) {
1094    let mut worst = 0.0_f64;
1095    let mut worst_row = 0usize;
1096    for i in 0..constraints.a.nrows() {
1097        let slack = scaled_constraint_slack(beta, constraints, i);
1098        let viol = (-slack).max(0.0);
1099        if viol > worst {
1100            worst = viol;
1101            worst_row = i;
1102        }
1103    }
1104    (worst, worst_row)
1105}
1106
1107/// Per-row signed scaled slack: `(a_i·beta - b_i) / ‖a_i‖`. A degenerate row
1108/// with `‖a_i‖ = 0` carries no direction, but it is NOT free of content: for
1109/// `b_i > 0` the row `0ᵀβ ≥ b_i` is unconditionally violated (−∞ slack), and
1110/// only for `b_i ≤ 0` is it vacuously satisfied (+∞ slack). Returning zero for
1111/// both let an impossible row report zero violation and pass every gate.
1112#[inline]
1113fn scaled_constraint_slack(
1114    beta: &Array1<f64>,
1115    constraints: &LinearInequalityConstraints,
1116    i: usize,
1117) -> f64 {
1118    let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1119    if norm > 0.0 {
1120        (constraints.a.row(i).dot(beta) - constraints.b[i]) / norm
1121    } else if constraints.b[i] > 0.0 {
1122        f64::NEG_INFINITY
1123    } else {
1124        f64::INFINITY
1125    }
1126}
1127
1128pub(crate) fn solve_kkt_direction(
1129    hessian: &Array2<f64>,
1130    gradient: &Array1<f64>,
1131    active_a: &Array2<f64>,
1132    active_residual: Option<&Array1<f64>>,
1133) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
1134    let p = hessian.nrows();
1135    let m = active_a.nrows();
1136    if hessian.ncols() != p || gradient.len() != p || active_a.ncols() != p {
1137        crate::bail_invalid_estim!("KKT solve dimension mismatch");
1138    }
1139    if let Some(residual) = active_residual
1140        && residual.len() != m
1141    {
1142        crate::bail_invalid_estim!(
1143            "KKT active residual length mismatch: got {}, expected {}",
1144            residual.len(),
1145            m
1146        );
1147    }
1148    if m == 0 {
1149        let mut d = Array1::<f64>::zeros(p);
1150        solve_newton_direction_dense(hessian, gradient, &mut d)?;
1151        return Ok((d, Array1::zeros(0)));
1152    }
1153    let mut kkt = Array2::<f64>::zeros((p + m, p + m));
1154    kkt.slice_mut(s![0..p, 0..p]).assign(hessian);
1155    kkt.slice_mut(s![0..p, p..(p + m)]).assign(&active_a.t());
1156    kkt.slice_mut(s![p..(p + m), 0..p]).assign(active_a);
1157
1158    let mut rhs = Array1::<f64>::zeros(p + m);
1159    for i in 0..p {
1160        rhs[i] = -gradient[i];
1161    }
1162    if let Some(residual) = active_residual {
1163        for i in 0..m {
1164            rhs[p + i] = residual[i];
1165        }
1166    }
1167    let rhs_target = rhs.clone();
1168
1169    let kkt_view = FaerArrayView::new(&kkt);
1170    let factor = FaerLblt::new(kkt_view.as_ref(), Side::Lower);
1171    let mut rhs_col = array1_to_col_matmut(&mut rhs);
1172    factor.solve_in_place(rhs_col.as_mut());
1173    if !rhs.iter().all(|v| v.is_finite()) {
1174        solve_dense_system_via_pseudoinverse(&kkt, &rhs_target, &mut rhs)?;
1175    }
1176    let d = rhs.slice(s![0..p]).to_owned();
1177    let lambda = rhs.slice(s![p..(p + m)]).to_owned();
1178    Ok((d, lambda))
1179}
1180
1181#[derive(Clone, Debug)]
1182pub(crate) struct CompressedActiveWorkingSet {
1183    pub(crate) constraints: LinearInequalityConstraints,
1184    /// Original active positions collapsed into each compressed (representative)
1185    /// row: `groups[g][0]` is the representative and the rest are its exactly-
1186    /// parallel (positively-aligned, same-constraint-up-to-scale) dependents. The
1187    /// whole group is released together when the representative's dual is negative.
1188    pub(crate) groups: Vec<Vec<usize>>,
1189    pub(crate) original_active_count: usize,
1190}
1191
1192/// One dependent row of the WORKING SET expressed against its representative:
1193/// `a_dep ≈ coeff · a_rep`.
1194///
1195/// Recorded ONLY for exactly-parallel (positively-aligned scalar-multiple)
1196/// dependents; a general-position dependent is dropped from the working set with
1197/// NO entry and re-enters via the next feasibility scan (it never receives a
1198/// distributed/phantom multiplier).
1199///
1200/// `active_pos` is an ACTIVE-SET POSITION: an index into the caller's `active`
1201/// slice, so the original constraint id is `active[active_pos]` (see
1202/// [`compress_active_working_set`], which seeds `groups` with exactly these
1203/// positions). It is NOT a constraint-row id and NOT a coefficient index. The
1204/// reduced-face op reports its dependents in constraint-row space instead and
1205/// therefore uses its own [`ConstraintRowDependence`] — the two must not be
1206/// interchanged.
1207#[derive(Clone, Copy, Debug)]
1208pub struct ActiveRowDependence {
1209    pub active_pos: usize,
1210    pub coeff: f64,
1211}
1212
1213/// One tight row of a REDUCED FACE expressed against its representative:
1214/// `a_dep ≈ coeff · a_rep`, with the dependent named in constraint-row space.
1215///
1216/// Same `(A)`-strict recording rule as [`ActiveRowDependence`], different index
1217/// space: `row` is the dependent's [`ConstraintRowId`] in the reduced set's own
1218/// row space. The representative is identified by the index of the owning
1219/// [`ReducedFace::dependence`] slot, which is aligned with
1220/// [`ReducedFace::representatives`].
1221#[derive(Clone, Copy, Debug)]
1222pub struct ConstraintRowDependence {
1223    pub row: ConstraintRowId,
1224    pub coeff: f64,
1225}
1226
1227/// The result of reducing a tight active face to a minimal independent set — the
1228/// shared output of the `ConstraintSet` reduced-face op (Dense arm =
1229/// [`dense_reduced_face`]; KhatriRaoCone / BlockDiagonal arms produce the same
1230/// shape). Determinism: representatives are the lowest-flat-index row per
1231/// independent direction, ascending, with no float tie-break.
1232///
1233/// INDEX SPACE: every id here is a [`ConstraintRowId`] in the reduced set's own
1234/// constraint-row space (`0..nrows()`), addressing `values()` / `bound()` /
1235/// `row_norm()`. It is NOT a coefficient index; to reach β coordinates go
1236/// through [`gam_problem::ConstraintSet::row_column_support`].
1237#[derive(Clone, Debug)]
1238pub struct ReducedFace {
1239    /// Kept independent rows — the lowest-flat-index representative per direction,
1240    /// ascending. Flat id space is `0..nrows` (Dense) / `slot*n + obs` (cone) /
1241    /// the concatenation of the member row spaces (block-diagonal).
1242    pub representatives: Vec<ConstraintRowId>,
1243    /// Per-representative parallel-dependent map, index-aligned with
1244    /// `representatives`. `dependence[i]` lists the exactly-parallel dependents of
1245    /// `representatives[i]` (empty when it has none); general-position dependents
1246    /// are absent (dropped, re-enter on the next feasibility scan).
1247    pub dependence: Vec<Vec<ConstraintRowDependence>>,
1248    /// The full tight set that was reduced, ascending flat ids.
1249    pub tight_rows: Vec<ConstraintRowId>,
1250}
1251
1252/// Reduce the tight active face of a Khatri–Rao monotonicity cone to its minimal
1253/// independent set — the `KhatriRaoCone` arm of the `ConstraintSet` reduced-face
1254/// op (gam#2306; the Dense arm is [`dense_reduced_face`]).
1255///
1256/// A cone row `(slot, i)` has normal `e_{k} ⊗ ψ_i` (`k = coupled_rows[slot]`),
1257/// so two normals' inner product is `δ_{slot,slot'}·(ψ_iᵀ ψ_{i'})`: cross-block
1258/// normals are ALWAYS orthogonal and never redundant, and redundancy occurs only
1259/// WITHIN a shape block among linearly dependent covariate rows `ψ_i`. The
1260/// reduction therefore decomposes into independent per-block Gram–Schmidt scans
1261/// over the block's tight `ψ_i` rows — never forming the `n·|coupled|` system.
1262///
1263/// Contract (matches the Dense arm): FULL rank cut (every dependent row is
1264/// dropped from `representatives`, parallel OR general-position); the dependence
1265/// map records `(A)`-strict — ONLY exactly-parallel dependents
1266/// (`|cos(ψ_dep, ψ_rep)| ≥ 1 − 1e-9`) get a [`ConstraintRowDependence`] against their
1267/// single representative (`coeff = ψ_depᵀψ_rep / ‖ψ_rep‖²`, so `a_dep ≈ coeff·a_rep`);
1268/// general-position drops get no entry and re-enter via the next feasibility
1269/// scan. Representatives are the lowest-flat-index row per direction (ascending
1270/// obs within a block), host-deterministic with no float tie-break. The rank
1271/// tolerance mirrors the Dense scan (`100·ε·max(n_tight, p_cov)·max‖ψ‖`), so the
1272/// two arms cut to the same numerical rank. Flat id is `slot*n + obs`, matching
1273/// [`KhatriRaoConeConstraints::values`].
1274pub fn khatri_rao_cone_reduced_face(
1275    cone: &KhatriRaoConeConstraints,
1276    beta: ndarray::ArrayView1<'_, f64>,
1277    membership_tol: f64,
1278) -> Result<ReducedFace, EstimationError> {
1279    let psi = cone.factor();
1280    let n = psi.nrows();
1281    let p_cov = psi.ncols();
1282    let coupled = cone.coupled_rows();
1283    let values = cone.values(beta).map_err(|error| {
1284        EstimationError::ParameterConstraintViolation(format!(
1285            "Khatri-Rao cone reduced-face values: {error}"
1286        ))
1287    })?;
1288
1289    // ‖ψ_i‖ is shared across coupled slots (the same covariate factor).
1290    let row_norms: Vec<f64> = (0..n)
1291        .map(|i| {
1292            let row = psi.row(i);
1293            row.dot(&row).sqrt()
1294        })
1295        .collect();
1296
1297    const RANK_ALPHA: f64 = 100.0;
1298    // Exactly-parallel threshold, matching the Dense scan's ±1e-9 cosine band.
1299    const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1300
1301    let mut representatives: Vec<ConstraintRowId> = Vec::new();
1302    let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1303    let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
1304
1305    for slot in 0..coupled.len() {
1306        // Tight obs in this block, ascending. A zero-norm ψ_i is a vacuous row
1307        // (0ᵀβ ≥ 0 always holds) — never a constraint direction, never a rep.
1308        let mut tight_obs: Vec<usize> = Vec::new();
1309        for i in 0..n {
1310            let norm_i = row_norms[i];
1311            if norm_i <= 0.0 {
1312                continue;
1313            }
1314            let scaled_slack = values[slot * n + i] / norm_i;
1315            if scaled_slack <= membership_tol {
1316                tight_rows.push(ConstraintRowId(slot * n + i));
1317                tight_obs.push(i);
1318            }
1319        }
1320        if tight_obs.is_empty() {
1321            continue;
1322        }
1323
1324        let max_norm = tight_obs
1325            .iter()
1326            .map(|&i| row_norms[i])
1327            .fold(0.0_f64, f64::max);
1328        let rank_tol =
1329            RANK_ALPHA * f64::EPSILON * (tight_obs.len().max(p_cov).max(1) as f64) * max_norm;
1330
1331        let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1332        // Kept representatives in THIS block: (obs, ψ_obs, index into representatives).
1333        let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1334        for &i in &tight_obs {
1335            let psi_i = psi.row(i).to_owned();
1336            let mut resid = psi_i.clone();
1337            for q in &ortho_basis {
1338                let proj = resid.dot(q);
1339                resid.scaled_add(-proj, q);
1340            }
1341            let resid_norm = resid.dot(&resid).sqrt();
1342            let flat = ConstraintRowId(slot * n + i);
1343            if resid_norm > rank_tol {
1344                ortho_basis.push(&resid / resid_norm);
1345                let out_idx = representatives.len();
1346                representatives.push(flat);
1347                dependence.push(Vec::new());
1348                kept.push((i, psi_i, out_idx));
1349            } else {
1350                // (A)-strict: record ONLY an exactly-parallel single-representative
1351                // dependence; general-position drops carry no multiplier.
1352                let mut best_abs_cos = 0.0_f64;
1353                let mut best: Option<(usize, f64)> = None;
1354                for (rep_obs, rep_psi, rep_out_idx) in &kept {
1355                    let rep_norm = row_norms[*rep_obs];
1356                    let dot = psi_i.dot(rep_psi);
1357                    let cos = if rep_norm > 0.0 {
1358                        dot / (row_norms[i] * rep_norm)
1359                    } else {
1360                        0.0
1361                    };
1362                    if cos.abs() > best_abs_cos {
1363                        best_abs_cos = cos.abs();
1364                        best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1365                    }
1366                }
1367                if best_abs_cos >= PARALLEL_COS_TOL {
1368                    if let Some((out_idx, coeff)) = best {
1369                        dependence[out_idx].push(ConstraintRowDependence {
1370                            row: flat,
1371                            coeff,
1372                        });
1373                    }
1374                }
1375            }
1376        }
1377    }
1378
1379    Ok(ReducedFace {
1380        representatives,
1381        dependence,
1382        tight_rows,
1383    })
1384}
1385
1386/// Dense arm of the reduced-face op: reduce the tight rows of an explicit
1387/// `A x ≥ b` set at `beta` to a minimal independent set. Mirrors
1388/// [`khatri_rao_cone_reduced_face`] exactly — ascending-index greedy MGS,
1389/// `RANK_ALPHA·ε·max(n_tight,p)·max‖a‖` tolerance, (A)-strict parallel-only
1390/// dependence (|cos| ≥ 1−1e-9, `coeff = a_depᵀa_rep/‖a_rep‖²`, `row` = the
1391/// dependent row's flat id) — so both carriers produce the same `ReducedFace`
1392/// contract. Flat id = the constraint row index. A zero-norm row is vacuous
1393/// (never a direction, never a representative).
1394pub fn dense_reduced_face(
1395    lin: &LinearInequalityConstraints,
1396    beta: ndarray::ArrayView1<'_, f64>,
1397    membership_tol: f64,
1398) -> Result<ReducedFace, EstimationError> {
1399    let a = &lin.a;
1400    let b = &lin.b;
1401    let n = a.nrows();
1402    let p = a.ncols();
1403
1404    let row_norms: Vec<f64> = (0..n)
1405        .map(|i| {
1406            let row = a.row(i);
1407            row.dot(&row).sqrt()
1408        })
1409        .collect();
1410
1411    const RANK_ALPHA: f64 = 100.0;
1412    const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1413
1414    // The scan runs in raw row indices (they address `a` / `row_norms`); the
1415    // ids are wrapped into constraint-row space once, at the return boundary.
1416    let mut tight: Vec<usize> = Vec::new();
1417    for i in 0..n {
1418        let norm_i = row_norms[i];
1419        if norm_i <= 0.0 {
1420            continue;
1421        }
1422        let scaled_slack = (a.row(i).dot(&beta) - b[i]) / norm_i;
1423        if scaled_slack <= membership_tol {
1424            tight.push(i);
1425        }
1426    }
1427
1428    let mut representatives: Vec<ConstraintRowId> = Vec::new();
1429    let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1430    if tight.is_empty() {
1431        return Ok(ReducedFace {
1432            representatives,
1433            dependence,
1434            tight_rows: Vec::new(),
1435        });
1436    }
1437
1438    let max_norm = tight
1439        .iter()
1440        .map(|&i| row_norms[i])
1441        .fold(0.0_f64, f64::max);
1442    let rank_tol = RANK_ALPHA * f64::EPSILON * (tight.len().max(p).max(1) as f64) * max_norm;
1443
1444    let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1445    // Kept representatives: (row, a_row, index into `representatives`).
1446    let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1447    for &i in &tight {
1448        let a_i = a.row(i).to_owned();
1449        let mut resid = a_i.clone();
1450        for q in &ortho_basis {
1451            let proj = resid.dot(q);
1452            resid.scaled_add(-proj, q);
1453        }
1454        let resid_norm = resid.dot(&resid).sqrt();
1455        if resid_norm > rank_tol {
1456            ortho_basis.push(&resid / resid_norm);
1457            let out_idx = representatives.len();
1458            representatives.push(ConstraintRowId(i));
1459            dependence.push(Vec::new());
1460            kept.push((i, a_i, out_idx));
1461        } else {
1462            // (A)-strict: record ONLY an exactly-parallel single-representative
1463            // dependence; a general-position drop carries no multiplier and
1464            // re-enters via the next feasibility scan.
1465            let mut best_abs_cos = 0.0_f64;
1466            let mut best: Option<(usize, f64)> = None;
1467            for (rep_row, rep_a, rep_out_idx) in &kept {
1468                let rep_norm = row_norms[*rep_row];
1469                let dot = a_i.dot(rep_a);
1470                let cos = if rep_norm > 0.0 {
1471                    dot / (row_norms[i] * rep_norm)
1472                } else {
1473                    0.0
1474                };
1475                if cos.abs() > best_abs_cos {
1476                    best_abs_cos = cos.abs();
1477                    best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1478                }
1479            }
1480            if best_abs_cos >= PARALLEL_COS_TOL {
1481                if let Some((out_idx, coeff)) = best {
1482                    dependence[out_idx].push(ConstraintRowDependence {
1483                        row: ConstraintRowId(i),
1484                        coeff,
1485                    });
1486                }
1487            }
1488        }
1489    }
1490
1491    Ok(ReducedFace {
1492        representatives,
1493        dependence,
1494        tight_rows: tight.into_iter().map(ConstraintRowId).collect(),
1495    })
1496}
1497
1498/// Lift a MEMBER's constraint-row id into the JOINT block-diagonal row space.
1499///
1500/// Derivation: `ConstraintSet::BlockDiagonal` stacks its members' constraint
1501/// ROWS in block order — `ConstraintSet::values` writes member `m`'s values into
1502/// `out[off .. off + m.nrows()]`, and `bound` / `row_norm` decode a joint row by
1503/// walking the same running `nrows()` sum (`block_for_row`). So the joint id of
1504/// member row `local` is `local + Σ_{earlier m} m.nrows()`, which is what
1505/// `row_offset` accumulates.
1506///
1507/// The offset is deliberately NOT `col_start`. That is the COEFFICIENT offset,
1508/// and it advances by `ncols()`. Using one for the other is only invisible while
1509/// every member is square (`nrows() == ncols()`); the moment a member constrains
1510/// fewer rows than it has coefficients, the two sequences diverge and the ids
1511/// silently name the wrong block. To go from these ids to β coordinates, use
1512/// `ConstraintSet::row_column_support` — never arithmetic on the id.
1513#[inline]
1514fn lift_member_row(local: ConstraintRowId, row_offset: usize) -> ConstraintRowId {
1515    ConstraintRowId(local.index() + row_offset)
1516}
1517
1518/// The shared tight-face reduction op over the `ConstraintSet` carrier union.
1519/// An extension trait (not an inherent method) so the numeric reduction
1520/// stays in gam-solve where the solvers consume it, keeping `gam-problem` a pure
1521/// data crate. All three arms produce the same `ReducedFace` contract.
1522pub trait ConstraintSetReducedFace {
1523    fn reduced_face(
1524        &self,
1525        beta: ndarray::ArrayView1<'_, f64>,
1526        membership_tol: f64,
1527    ) -> Result<ReducedFace, EstimationError>;
1528}
1529
1530impl ConstraintSetReducedFace for ConstraintSet {
1531    fn reduced_face(
1532        &self,
1533        beta: ndarray::ArrayView1<'_, f64>,
1534        membership_tol: f64,
1535    ) -> Result<ReducedFace, EstimationError> {
1536        match self {
1537            ConstraintSet::Dense(lin) => dense_reduced_face(lin, beta, membership_tol),
1538            ConstraintSet::KhatriRaoCone(cone) => {
1539                khatri_rao_cone_reduced_face(cone, beta, membership_tol)
1540            }
1541            ConstraintSet::BlockDiagonal { blocks, .. } => {
1542                // Compose per inner block. TWO independent offsets are in play and
1543                // they are NOT interchangeable:
1544                //   * `block.col_start` slices β — COEFFICIENT space, advancing by
1545                //     each member's `ncols()`;
1546                //   * `row_offset` lifts the returned ids — CONSTRAINT-ROW space,
1547                //     advancing by each member's `nrows()`.
1548                // They coincide only when every member is a square carrier, which
1549                // is why a mixed block (a constrained sub-basis alongside
1550                // unconstrained intercept/covariate columns, `nrows() < ncols()`)
1551                // is the case that separates them. See `lift_member_row`.
1552                let mut representatives: Vec<ConstraintRowId> = Vec::new();
1553                let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1554                let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
1555                let mut row_offset = 0usize;
1556                for block in blocks {
1557                    let start = block.col_start;
1558                    let end = start + block.set.ncols();
1559                    let beta_block = beta.slice(ndarray::s![start..end]);
1560                    let sub = block.set.reduced_face(beta_block, membership_tol)?;
1561                    for r in sub.representatives {
1562                        representatives.push(lift_member_row(r, row_offset));
1563                    }
1564                    for deps in sub.dependence {
1565                        dependence.push(
1566                            deps.into_iter()
1567                                .map(|d| ConstraintRowDependence {
1568                                    row: lift_member_row(d.row, row_offset),
1569                                    coeff: d.coeff,
1570                                })
1571                                .collect(),
1572                        );
1573                    }
1574                    for t in sub.tight_rows {
1575                        tight_rows.push(lift_member_row(t, row_offset));
1576                    }
1577                    row_offset += block.set.nrows();
1578                }
1579                Ok(ReducedFace {
1580                    representatives,
1581                    dependence,
1582                    tight_rows,
1583                })
1584            }
1585        }
1586    }
1587}
1588
1589impl CompressedActiveWorkingSet {
1590    fn is_degenerate_face(&self) -> bool {
1591        self.constraints.a.nrows() < self.original_active_count
1592            || self.groups.iter().any(|group| group.len() > 1)
1593    }
1594
1595    /// The lowest-original-index representative direction whose compressed dual is
1596    /// negative beyond `tol_dual`, returned as the FULL set of original active
1597    /// positions collapsed into it (the representative plus its exactly-parallel
1598    /// dependents — the same constraint up to positive scale).
1599    ///
1600    /// Adjudicating the representative and releasing the WHOLE direction replaces
1601    /// the former per-original-row reconstruction, which divided the direction's
1602    /// dual by each dependent's `coeff` to synthesize a per-row multiplier and
1603    /// released ONE row at a time — a phantom ±1/ε dual on a redundant tight row
1604    /// (#2298/#979/#2132) and a churn source: releasing one row of an exactly-
1605    /// parallel group leaves the others pinning the same direction, so the working
1606    /// set oscillates. A negative representative dual means the aggregate pull along
1607    /// that independent direction is wrong-signed, so the whole group is released
1608    /// together; general-position dependents were never grouped (they carry no
1609    /// dependence entry and re-enter via the next feasibility scan).
1610    ///
1611    /// `active` maps `active_pos → original constraint id` so the choice is the
1612    /// deterministic lowest-original-index direction. `None` ⇒ every representative
1613    /// dual is non-negative: a KKT point on the reduced face.
1614    fn negative_representative_group(
1615        &self,
1616        lambda_system: &Array1<f64>,
1617        tol_dual: f64,
1618        active: &[usize],
1619    ) -> Option<Vec<usize>> {
1620        self.groups
1621            .iter()
1622            .enumerate()
1623            .filter(|&(group_pos, _)| {
1624                // lambda_true = -lambda_system[group_pos]; release iff < -tol_dual.
1625                lambda_system
1626                    .get(group_pos)
1627                    .is_some_and(|&value| -value < -tol_dual)
1628            })
1629            .min_by_key(|&(_, group)| {
1630                let first = group.first().copied().unwrap_or(usize::MAX);
1631                (active.get(first).copied().unwrap_or(usize::MAX), first)
1632            })
1633            .map(|(_, group)| group.clone())
1634    }
1635
1636    /// True iff the active position `pos` is ENFORCED by this compressed face —
1637    /// it is a representative or an exactly-parallel dependent of one, so its
1638    /// half-space is carried by the enforced equality system. A general-position
1639    /// active row that was rank-reduced out of the face belongs to no group and
1640    /// is therefore NOT enforced (its violation cannot be closed by the current
1641    /// KKT solve, and it cannot be re-added because it is already active).
1642    fn position_enforced(&self, pos: usize) -> bool {
1643        self.groups.iter().any(|group| group.contains(&pos))
1644    }
1645
1646    /// Over-complete-face adjudication (#2378). `violated` is the normal (in any
1647    /// scaling) of a constraint row that is ACTIVE yet was rank-reduced out of
1648    /// the enforced representative face: it is linearly dependent on the
1649    /// representatives, so the ordinary add/release transitions dead-end — it
1650    /// can be neither re-added (already active) nor released (not a
1651    /// representative). An over-complete face is inconsistent as EQUALITIES, so
1652    /// the method must adjudicate it by an active-set EXCHANGE rather than
1653    /// silently truncate it: release the representative the violated row is most
1654    /// positively aligned with, freeing that dependent direction so the violated
1655    /// row becomes an independent representative and binds on the next
1656    /// iteration. Returns the FULL representative group (representative +
1657    /// exactly-parallel dependents) to release, or `None` when no positively
1658    /// aligned representative exists (the caller then defers to the exit gate).
1659    /// Deterministic lowest-original-index tie-break.
1660    fn over_complete_release_group(
1661        &self,
1662        violated: ndarray::ArrayView1<'_, f64>,
1663        active: &[usize],
1664    ) -> Option<Vec<usize>> {
1665        let v_norm = violated.dot(&violated).sqrt();
1666        if !(v_norm > 0.0) {
1667            return None;
1668        }
1669        const COS_TIE_TOL: f64 = 1e-12;
1670        let mut best: Option<(f64, (usize, usize), usize)> = None;
1671        for (group_pos, group) in self.groups.iter().enumerate() {
1672            let rep = self.constraints.a.row(group_pos);
1673            let rep_norm = rep.dot(&rep).sqrt();
1674            if !(rep_norm > 0.0) {
1675                continue;
1676            }
1677            let cos = rep.dot(&violated) / (rep_norm * v_norm);
1678            if cos <= 0.0 {
1679                continue;
1680            }
1681            let first = group.first().copied().unwrap_or(usize::MAX);
1682            let key = (active.get(first).copied().unwrap_or(usize::MAX), first);
1683            let take = match &best {
1684                None => true,
1685                Some((best_cos, best_key, _)) => {
1686                    cos > best_cos + COS_TIE_TOL
1687                        || ((cos - best_cos).abs() <= COS_TIE_TOL && key < *best_key)
1688                }
1689            };
1690            if take {
1691                best = Some((cos, key, group_pos));
1692            }
1693        }
1694        best.map(|(_, _, group_pos)| self.groups[group_pos].clone())
1695    }
1696}
1697
1698pub(crate) fn compress_active_working_set(
1699    x: &Array1<f64>,
1700    constraints: &LinearInequalityConstraints,
1701    active: &[usize],
1702) -> Result<CompressedActiveWorkingSet, EstimationError> {
1703    let p = constraints.a.ncols();
1704    if x.len() != p {
1705        crate::bail_invalid_estim!("active working-set compression dimension mismatch");
1706    }
1707
1708    let mut a_out = Array2::<f64>::zeros((active.len(), p));
1709    let mut b_out = Array1::<f64>::zeros(active.len());
1710    let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(active.len());
1711    for (pos, &idx) in active.iter().enumerate() {
1712        if idx >= constraints.a.nrows() {
1713            crate::bail_invalid_estim!(
1714                "active working-set index {} out of bounds for {} constraints",
1715                idx,
1716                constraints.a.nrows()
1717            );
1718        }
1719        a_out.row_mut(pos).assign(&constraints.a.row(idx));
1720        b_out[pos] = constraints.b[idx];
1721        groups_out.push(vec![pos]);
1722    }
1723
1724    // The parallel-dependent map (4th return) is in ACTIVE-SET-POSITION space,
1725    // not the constraint-row space of the `ReducedFace` op; the working-set
1726    // release adjudicates whole representative groups, so it is not stored here.
1727    let (a_out, b_out, groups_out, _) =
1728        rank_reduce_rows_pivoted_qr_with_dependence(a_out, b_out, groups_out);
1729
1730    Ok(CompressedActiveWorkingSet {
1731        constraints: LinearInequalityConstraints::new(a_out, b_out)
1732            .expect("compressed active constraint shape invariant"),
1733        groups: groups_out,
1734        original_active_count: active.len(),
1735    })
1736}
1737
1738fn identity_multiplier_dependence(groups: &[Vec<usize>]) -> Vec<Vec<ActiveRowDependence>> {
1739    groups
1740        .iter()
1741        .map(|group| {
1742            group
1743                .iter()
1744                .copied()
1745                .map(|active_pos| ActiveRowDependence {
1746                    active_pos,
1747                    coeff: 1.0,
1748                })
1749                .collect()
1750        })
1751        .collect()
1752}
1753
1754pub fn rank_reduce_rows_pivoted_qr_with_dependence(
1755    a: Array2<f64>,
1756    b: Array1<f64>,
1757    groups: Vec<Vec<usize>>,
1758) -> (
1759    Array2<f64>,
1760    Array1<f64>,
1761    Vec<Vec<usize>>,
1762    Vec<Vec<ActiveRowDependence>>,
1763) {
1764    let k = a.nrows();
1765    let p = a.ncols();
1766    if k <= 1 {
1767        let multiplier_dependence = identity_multiplier_dependence(&groups);
1768        return (a, b, groups, multiplier_dependence);
1769    }
1770
1771    // DETERMINISTIC, host-independent representative selection. The former faer
1772    // `col_piv_qr` pivots by largest column norm; on an equal-norm tie
1773    // (near-parallel / identical active rows — the degenerate-face case) faer's
1774    // internal tie-break can differ across CPU arch / SIMD width / library
1775    // version, which would record a host-dependent active face and re-introduce
1776    // the nondeterministic cross-host certification the face feeds. Instead do a
1777    // greedy ASCENDING-original-index independence scan: iterate rows in index
1778    // order and keep row r iff its residual after projecting onto the orthonormal
1779    // span of the already-kept rows exceeds the rank tolerance; otherwise record
1780    // it dependent. This yields the lowest-index representative per independent
1781    // direction with no float-comparison tie-break.
1782    //
1783    // Rank tolerance is relative to the largest row norm — the same |R00| scale
1784    // (= largest column norm of Aᵀ = largest row norm of A) the pivoted QR used —
1785    // so the accepted COUNT matches the prior numerical rank; only WHICH
1786    // representative is chosen among tied near-parallel rows changes, and it
1787    // changes deterministically. The scale carries NO absolute floor, preserving
1788    // the unit-robustness of the prior tolerance (a perfectly independent system
1789    // in tiny units, e.g. A = 1e-20·I, keeps full rank rather than being dropped).
1790    const RANK_ALPHA: f64 = 100.0;
1791    let max_row_norm = (0..k)
1792        .map(|r| {
1793            let row = a.row(r);
1794            row.dot(&row).sqrt()
1795        })
1796        .fold(0.0_f64, f64::max);
1797    let tol = RANK_ALPHA * f64::EPSILON * (k.max(p).max(1) as f64) * max_row_norm;
1798
1799    let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1800    let mut kept_orig: Vec<usize> = Vec::new();
1801    let mut dropped_orig: Vec<usize> = Vec::new();
1802    for r in 0..k {
1803        let mut resid = a.row(r).to_owned();
1804        for q in &ortho_basis {
1805            let proj = resid.dot(q);
1806            resid.scaled_add(-proj, q);
1807        }
1808        let resid_norm = resid.dot(&resid).sqrt();
1809        if resid_norm > tol {
1810            kept_orig.push(r);
1811            ortho_basis.push(&resid / resid_norm);
1812        } else {
1813            dropped_orig.push(r);
1814        }
1815    }
1816    let rank = kept_orig.len();
1817    if rank >= k {
1818        let multiplier_dependence = identity_multiplier_dependence(&groups);
1819        return (a, b, groups, multiplier_dependence);
1820    }
1821    if rank == 0 {
1822        log::debug!(
1823            "rank-reduced active constraints from {} to 0 rows (all active rows numerically zero)",
1824            k
1825        );
1826        return (
1827            Array2::<f64>::zeros((0, p)),
1828            Array1::<f64>::zeros(0),
1829            Vec::new(),
1830            Vec::new(),
1831        );
1832    }
1833
1834    let mut orig_to_out = std::collections::HashMap::with_capacity(rank);
1835    let mut a_out = Array2::<f64>::zeros((rank, p));
1836    let mut b_out = Array1::<f64>::zeros(rank);
1837    let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(rank);
1838    let mut multiplier_dependence: Vec<Vec<ActiveRowDependence>> = Vec::with_capacity(rank);
1839    for (out_idx, &orig_idx) in kept_orig.iter().enumerate() {
1840        a_out.row_mut(out_idx).assign(&a.row(orig_idx));
1841        b_out[out_idx] = b[orig_idx];
1842        groups_out.push(groups[orig_idx].clone());
1843        multiplier_dependence.push(
1844            groups[orig_idx]
1845                .iter()
1846                .copied()
1847                .map(|active_pos| ActiveRowDependence {
1848                    active_pos,
1849                    coeff: 1.0,
1850                })
1851                .collect(),
1852        );
1853        orig_to_out.insert(orig_idx, out_idx);
1854    }
1855
1856    // (A)-strict merge, matching the shared `dense_reduced_face` /
1857    // `khatri_rao_cone_reduced_face` `ReducedFace` contract. A dropped row joins
1858    // a representative's group — and receives a distributed multiplier — ONLY
1859    // when it is exactly PARALLEL to that representative (the same half-space up
1860    // to positive scale). A GENERAL-POSITION dependent — dependent only because
1861    // more normals bind than the face dimension (e.g. three normals inside a 2-D
1862    // coupled block) — is dropped outright with NO group entry and NO
1863    // multiplier: it re-enters the working set via the next feasibility scan and
1864    // is never conflated with a different half-space's dual (#979). The former
1865    // `best_positive_align` merge folded such a row into whichever kept row it
1866    // was most positively aligned with, silently truncating a general-position
1867    // active row out of the enforced face and pinning the wrong vertex (#2378).
1868    const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1869    for &dropped_idx in &dropped_orig {
1870        let dropped_row = a.row(dropped_idx);
1871        let dropped_norm = dropped_row.dot(&dropped_row).sqrt();
1872        let mut best_abs_cos = 0.0_f64;
1873        let mut best_target: Option<(usize, f64)> = None;
1874        for &kept_idx in &kept_orig {
1875            let kept_row = a.row(kept_idx);
1876            let kept_norm = kept_row.dot(&kept_row).sqrt();
1877            let dot = kept_row.dot(&dropped_row);
1878            let cos = if kept_norm > 0.0 && dropped_norm > 0.0 {
1879                dot / (kept_norm * dropped_norm)
1880            } else {
1881                0.0
1882            };
1883            let coeff = if kept_norm > 0.0 {
1884                dot / (kept_norm * kept_norm)
1885            } else {
1886                0.0
1887            };
1888            if cos.abs() > best_abs_cos {
1889                best_abs_cos = cos.abs();
1890                best_target = Some((kept_idx, coeff));
1891            }
1892        }
1893        // Only an exactly-parallel dependent is recorded; a general-position
1894        // drop carries no phantom distributed dual. The group (whose whole-set
1895        // release the working-set loop drives) additionally requires POSITIVE
1896        // parallelism — same constraint up to positive scale — so an opposing
1897        // (anti-parallel) tight row is never released together with it.
1898        if best_abs_cos >= PARALLEL_COS_TOL {
1899            if let Some((target, coeff)) = best_target {
1900                let &out_idx = orig_to_out
1901                    .get(&target)
1902                    .expect("merge target must be a kept row");
1903                for &active_pos in &groups[dropped_idx] {
1904                    multiplier_dependence[out_idx].push(ActiveRowDependence { active_pos, coeff });
1905                }
1906                if coeff > 0.0 {
1907                    groups_out[out_idx].extend_from_slice(&groups[dropped_idx]);
1908                }
1909            }
1910        }
1911    }
1912
1913    for group in &mut groups_out {
1914        group.sort_unstable();
1915        group.dedup();
1916    }
1917    for dependencies in &mut multiplier_dependence {
1918        dependencies.sort_unstable_by_key(|dependency| dependency.active_pos);
1919        dependencies.dedup_by_key(|dependency| dependency.active_pos);
1920    }
1921
1922    let mut row_order: Vec<usize> = (0..groups_out.len()).collect();
1923    row_order.sort_by_key(|&idx| groups_out[idx].first().copied().unwrap_or(usize::MAX));
1924    if row_order.iter().enumerate().any(|(idx, &orig)| idx != orig) {
1925        let mut a_sorted = Array2::<f64>::zeros((rank, p));
1926        let mut b_sorted = Array1::<f64>::zeros(rank);
1927        let mut groups_sorted = Vec::with_capacity(rank);
1928        let mut dependence_sorted = Vec::with_capacity(rank);
1929        for (out_idx, orig_idx) in row_order.into_iter().enumerate() {
1930            a_sorted.row_mut(out_idx).assign(&a_out.row(orig_idx));
1931            b_sorted[out_idx] = b_out[orig_idx];
1932            groups_sorted.push(groups_out[orig_idx].clone());
1933            dependence_sorted.push(multiplier_dependence[orig_idx].clone());
1934        }
1935        a_out = a_sorted;
1936        b_out = b_sorted;
1937        groups_out = groups_sorted;
1938        multiplier_dependence = dependence_sorted;
1939    }
1940
1941    if rank < k {
1942        log::debug!(
1943            "rank-reduced active constraints from {} to {} rows (rank deficiency {})",
1944            k,
1945            rank,
1946            k - rank
1947        );
1948    }
1949
1950    (a_out, b_out, groups_out, multiplier_dependence)
1951}
1952
1953pub(crate) fn working_set_kkt_diagnostics_from_multipliers(
1954    x: &Array1<f64>,
1955    gradient: &Array1<f64>,
1956    working_constraints: &LinearInequalityConstraints,
1957    lambda_active_true: &Array1<f64>,
1958    n_total_constraints: usize,
1959) -> Result<ConstraintKktDiagnostics, EstimationError> {
1960    let p = working_constraints.a.ncols();
1961    if x.len() != p || gradient.len() != p {
1962        crate::bail_invalid_estim!("working-set KKT diagnostic dimension mismatch");
1963    }
1964    if lambda_active_true.len() != working_constraints.a.nrows() {
1965        crate::bail_invalid_estim!(
1966            "working-set KKT multiplier length mismatch: got {}, expected {}",
1967            lambda_active_true.len(),
1968            working_constraints.a.nrows()
1969        );
1970    }
1971    // Primal feasibility and complementarity are measured in the per-row-scaled
1972    // (geometric) coordinate system the public solver contract is expressed in
1973    // (see [`ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`] and
1974    // [`compute_constraint_kkt_diagnostics`]). Without scaling, a row with
1975    // ‖a_i‖ ≫ 1 — e.g. a B-spline endpoint-derivative clamp — reports a raw
1976    // slack inflated by ‖a_i‖, and the in-solver acceptance gate
1977    // (`worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`) becomes anisotropic across
1978    // rows. Complementarity is the SCALED product `λ̂_i · ŝ_i` with
1979    // `λ̂_i = ‖a_i‖·λ_i`; that's invariant under the same per-row rescaling, so
1980    // its semantics are unchanged while the units match the primal column.
1981    let m = working_constraints.a.nrows();
1982    let mut slack = Array1::<f64>::zeros(m);
1983    let mut primal_feasibility: f64 = 0.0;
1984    for i in 0..m {
1985        let s_i = scaled_constraint_slack(x, working_constraints, i);
1986        slack[i] = s_i;
1987        primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
1988    }
1989
1990    let lambda = lambda_active_true.to_owned();
1991
1992    let mut dual_feasibility: f64 = 0.0;
1993    let mut complementarity: f64 = 0.0;
1994    for i in 0..m {
1995        dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
1996        // Scale-invariant complementarity `λ̂_i · ŝ_i` with `λ̂_i = ‖a_i‖·λ_i`
1997        // and `ŝ_i` the already-scaled slack: this product equals the raw
1998        // `λ_i · (a_iᵀx − b_i)`, invariant under per-row rescaling — matching the
1999        // documented contract above (`λ̂_i = ‖a_i‖·λ_i`). `lambda_active_true`
2000        // here is the RAW multiplier, so without the `‖a_i‖` factor this would
2001        // understate complementarity by `1/‖a_i‖` on high-norm rows (e.g. a
2002        // B-spline endpoint-derivative clamp, ‖a‖ ≈ 38).
2003        let norm_i = working_constraints
2004            .a
2005            .row(i)
2006            .dot(&working_constraints.a.row(i))
2007            .sqrt();
2008        complementarity = complementarity.max((norm_i * lambda[i] * slack[i]).abs());
2009    }
2010    let stationarity = {
2011        let mut resid = gradient.to_owned();
2012        resid -= &working_constraints.a.t().dot(&lambda);
2013        resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
2014    };
2015
2016    Ok(ConstraintKktDiagnostics {
2017        n_constraints: n_total_constraints,
2018        n_active: m,
2019        primal_feasibility,
2020        dual_feasibility,
2021        complementarity,
2022        stationarity,
2023        active_tolerance: ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
2024        // `working_constraints` is the already-rank-reduced compressed
2025        // working set, so by construction `rank(working_constraints.a) ==
2026        // n_active`. Whether the *original* (uncompressed) active set was
2027        // rank-deficient is the caller's responsibility to track when it
2028        // needs to surface that to a downstream gate; here we report the
2029        // post-compression view honestly.
2030        working_set_rank_deficient: false,
2031        gradient_scale: gradient_inf_norm(gradient),
2032    })
2033}
2034
2035fn canonicalize_active_constraint_ids(
2036    x: &Array1<f64>,
2037    constraints: &LinearInequalityConstraints,
2038    active: &[usize],
2039) -> Result<Vec<usize>, EstimationError> {
2040    if active.is_empty() {
2041        return Ok(Vec::new());
2042    }
2043    let compressed_working = compress_active_working_set(x, constraints, active)?;
2044    let mut canonical = Vec::with_capacity(compressed_working.groups.len());
2045    for group in &compressed_working.groups {
2046        if let Some(&active_pos) = group.first() {
2047            canonical.push(active[active_pos]);
2048        }
2049    }
2050    Ok(canonical)
2051}
2052
2053fn gather_linear_constraint_rows(
2054    constraints: &LinearInequalityConstraints,
2055    rows: &[usize],
2056) -> Result<LinearInequalityConstraints, EstimationError> {
2057    let p = constraints.a.ncols();
2058    let mut a = Array2::<f64>::zeros((rows.len(), p));
2059    let mut b = Array1::<f64>::zeros(rows.len());
2060    for (out, &row) in rows.iter().enumerate() {
2061        if row >= constraints.a.nrows() {
2062            crate::bail_invalid_estim!(
2063                "active constraint row {} out of bounds for {} rows",
2064                row,
2065                constraints.a.nrows()
2066            );
2067        }
2068        a.row_mut(out).assign(&constraints.a.row(row));
2069        b[out] = constraints.b[row];
2070    }
2071    LinearInequalityConstraints::new(a, b)
2072        .map_err(|error| EstimationError::ParameterConstraintViolation(error.to_string()))
2073}
2074
2075fn fallback_projected_gradient_direction(
2076    beta: &Array1<f64>,
2077    x: &Array1<f64>,
2078    d_total: &Array1<f64>,
2079    gradient: &Array1<f64>,
2080    working_constraints: &LinearInequalityConstraints,
2081    constraints: &LinearInequalityConstraints,
2082) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
2083    let p = gradient.len();
2084    if x.len() != p || d_total.len() != p || beta.len() != p || constraints.a.ncols() != p {
2085        crate::bail_invalid_estim!("projected-gradient fallback dimension mismatch");
2086    }
2087
2088    // Project onto the FEASIBLE tangent cone `A_active d >= 0`, not merely
2089    // the equality tangent space `A_active d = 0`. A negative multiplier means
2090    // descent exists by moving away from that boundary into the cone; equality
2091    // projection erases exactly that direction and can mistake a wrong working
2092    // face for stationarity. The strictly convex primal cone QP projects the
2093    // gradient onto the nonnegative normal cone, so negating the residual is
2094    // the Euclidean projection of `-gradient` onto the feasible tangent cone
2095    // (Moreau).
2096    let tangent_direction = if working_constraints.a.nrows() == 0 {
2097        -gradient
2098    } else {
2099        let Some((stationarity_residual, _multipliers)) =
2100            project_stationarity_residual_on_constraint_cone(gradient, &working_constraints.a)
2101        else {
2102            return Ok(None);
2103        };
2104        -stationarity_residual
2105    };
2106
2107    if !array_is_finite(&tangent_direction) {
2108        return Ok(None);
2109    }
2110
2111    let step_inf = tangent_direction
2112        .iter()
2113        .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
2114    if step_inf <= 1e-12 {
2115        // The projected-gradient tangent step has collapsed to ~0: the working
2116        // set holds the gradient in its nonnegative normal cone, so no descent
2117        // direction remains in the feasible tangent cone. Returning `d_total` as-is is ONLY correct
2118        // when the iterate `x = beta_start + d_total` is itself feasible. When
2119        // `x` is infeasible (an inactive row was violated by an earlier
2120        // `alpha`-clipped step and never repaired — the KKT solve only closes
2121        // residuals on ACTIVE rows), returning `d_total` leaks an infeasible
2122        // iterate that the downstream `check_linear_feasibility` gate rejects
2123        // as an "infeasible iterate" (#1108: the interval-censored survival
2124        // surrogate landed here at scaled-violation 5.7e-3..0.12 while the
2125        // exact projection of the SAME point reaches ~0 violation). Project the
2126        // stationary iterate onto the feasible cone and return the corresponding
2127        // direction so the solve returns a feasible point. The returned result
2128        // is `beta_start + dir`, and `x = beta_start + d_total`, so to return a
2129        // feasible `p` the direction is `dir = d_total + (p - x)`.
2130        let (worst, _) = max_linear_constraint_violation(x, constraints);
2131        if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2132            let projected = project_point_strictly_into_feasible_cone(x, constraints)
2133                .or_else(|| {
2134                    let identity = Array2::<f64>::eye(p);
2135                    solve_quadratic_with_linear_constraints(&identity, x, x, constraints, None)
2136                        .ok()
2137                        .map(|(beta, _active)| beta)
2138                })
2139                .filter(|p_candidate| {
2140                    max_linear_constraint_violation(p_candidate, constraints).0
2141                        <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2142                });
2143            let Some(projected) = projected else {
2144                // No feasible repair available — let the caller report honestly
2145                // rather than returning an infeasible direction.
2146                return Ok(None);
2147            };
2148            let repair = &projected - x;
2149            let new_direction = d_total + &repair;
2150            // Certify the point the caller will reconstruct (`beta + dir`), not
2151            // the projection itself: the two differ by the rounding of the
2152            // x/d_total cancellation, and the caller's gate is what must pass.
2153            let candidate = beta + &new_direction;
2154            if max_linear_constraint_violation(&candidate, constraints).0
2155                > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2156            {
2157                return Ok(None);
2158            }
2159            let active = canonicalize_active_constraint_ids(&candidate, constraints, &[])?;
2160            return Ok(Some((new_direction, active)));
2161        }
2162        let active = canonicalize_active_constraint_ids(x, constraints, &[])?;
2163        return Ok(Some((d_total.clone(), active)));
2164    }
2165
2166    let directional_derivative = gradient.dot(&tangent_direction);
2167    if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
2168        return Ok(None);
2169    }
2170
2171    let mut alpha = 1.0_f64;
2172    for i in 0..constraints.a.nrows() {
2173        // Scale the step-fraction test by 1/‖a_i‖ so it matches the geometric
2174        // activation tolerance used elsewhere (see in-loop alpha computation in
2175        // `solve_newton_direction_with_linear_constraints_impl`).
2176        let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
2177        let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
2178        let slack = (constraints.a.row(i).dot(x) - constraints.b[i]) * inv;
2179        let ai_d = constraints.a.row(i).dot(&tangent_direction) * inv;
2180        if let Some(candidate) = active_set_boundary_hit_step_fraction(slack, ai_d, alpha) {
2181            alpha = candidate;
2182        }
2183    }
2184    if !alpha.is_finite() || alpha <= 0.0 {
2185        return Ok(None);
2186    }
2187
2188    let fallback_step = tangent_direction * alpha;
2189    let new_direction = d_total + &fallback_step;
2190    // Evaluate feasibility on the caller's reconstruction `beta + dir` so the
2191    // acceptance here and the caller's final gate see the same bits.
2192    let new_x = beta + &new_direction;
2193    // Per-row-scaled feasibility, matching ACTIVE_SET_PRIMAL_FEASIBILITY_TOL.
2194    let (worst, _) = max_linear_constraint_violation(&new_x, constraints);
2195    if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2196        return Ok(None);
2197    }
2198    let active = (0..constraints.a.nrows())
2199        .filter(|&i| scaled_constraint_slack(&new_x, constraints, i) <= 1e-10)
2200        .collect::<Vec<_>>();
2201    let active = canonicalize_active_constraint_ids(&new_x, constraints, &active)?;
2202    Ok(Some((new_direction, active)))
2203}
2204
2205fn log_active_set_transition(
2206    event: &str,
2207    iteration: usize,
2208    active_len: usize,
2209    constraint: Option<usize>,
2210) {
2211    log::debug!(
2212        "[active-set/QP] iter={} event={} active={} constraint={}",
2213        iteration,
2214        event,
2215        active_len,
2216        constraint
2217            .map(|idx| idx.to_string())
2218            .unwrap_or_else(|| "NA".to_string()),
2219    );
2220}
2221
2222/// Record the complete active-set state; returns `false` only when both the
2223/// canonical row ids and the primal point are bit-identical to a prior state.
2224/// A working set may legitimately recur after the primal iterate moved (leave
2225/// a face, descend, then re-enter it), so row ids alone are not a cycle witness.
2226/// The former row-only key sent such productive revisits to the projected-
2227/// gradient escape, which is exactly the issue-979 CTN trace: the 91-row face
2228/// recurred at a different coefficient point and the main QP was abandoned.
2229/// An identical `(face, x)` state is a genuine tolerance-band cycle and still
2230/// routes to the post-loop KKT gate. `to_bits` makes the decision deterministic
2231/// and admits no approximate/wall-clock notion of repetition.
2232fn record_active_working_set(
2233    visited: &mut HashSet<(Vec<usize>, Vec<u64>)>,
2234    active: &[usize],
2235    x: &Array1<f64>,
2236    iteration: usize,
2237) -> bool {
2238    let mut active_key = active.to_vec();
2239    active_key.sort_unstable();
2240    let point_key = x.iter().map(|value| value.to_bits()).collect::<Vec<_>>();
2241    if visited.insert((active_key.clone(), point_key)) {
2242        return true;
2243    }
2244    log::debug!(
2245        "[active-set/QP] iter={iteration} repeated working set at the identical primal point ({} rows); \
2246         deferring to the post-loop KKT exit gate",
2247        active_key.len()
2248    );
2249    false
2250}
2251
2252fn solve_newton_direction_with_linear_constraints_impl(
2253    hessian: &Array2<f64>,
2254    gradient: &Array1<f64>,
2255    beta: &Array1<f64>,
2256    constraints: &LinearInequalityConstraints,
2257    direction_out: &mut Array1<f64>,
2258    mut active_hint: Option<&mut Vec<usize>>,
2259    max_iterations: usize,
2260    allow_projected_gradient_fallback: bool,
2261) -> Result<(), EstimationError> {
2262    let p = gradient.len();
2263    if direction_out.len() != p {
2264        *direction_out = Array1::zeros(p);
2265    }
2266    let m = constraints.a.nrows();
2267    if constraints.a.ncols() != p || constraints.b.len() != m || beta.len() != p {
2268        crate::bail_invalid_estim!(
2269            "linear constraint shape mismatch: A={}x{}, b={}, p={}",
2270            constraints.a.nrows(),
2271            constraints.a.ncols(),
2272            constraints.b.len(),
2273            p
2274        );
2275    }
2276
2277    let tol_active = ACTIVE_SET_WORKING_FACE_TOL;
2278    let tol_step = 1e-12;
2279    let tol_dual = 1e-10;
2280    let mut x = beta.to_owned();
2281    let mut d_total = Array1::<f64>::zeros(p);
2282    let mut g_cur = gradient.to_owned();
2283
2284    // A warm working set describes the face at the point that produced it.
2285    // Trust-region globalization may accept only a strict subsegment of that
2286    // point's QP chord, in which case rows that bind at the QP endpoint are
2287    // slack at the accepted iterate. Treating those stale row ids as equality
2288    // constraints pins the next Newton solve to a face it has not reached and
2289    // turns a full Newton step into geometric boundary chasing (#979). A row
2290    // can enter the working set only if it is actually tight at this solve's
2291    // primal point; the ordinary ratio test rediscovers it when reached.
2292    if let Some(hint) = active_hint.as_mut() {
2293        hint.retain(|&idx| idx < m && scaled_constraint_slack(&x, constraints, idx) <= tol_active);
2294    }
2295
2296    let has_active_hint = active_hint
2297        .as_ref()
2298        .map(|hint| !hint.is_empty())
2299        .unwrap_or(false);
2300    if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
2301        let candidate = beta + &*direction_out;
2302        let mut feasible = true;
2303        for i in 0..m {
2304            // Scaled (geometric) slack — matches `tol_active`'s semantics and
2305            // the public solver contract on `ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`.
2306            let slack = scaled_constraint_slack(&candidate, constraints, i);
2307            if slack < -tol_active {
2308                feasible = false;
2309                break;
2310            }
2311        }
2312        if feasible {
2313            // Face provenance of the RETURNED point: the unconstrained optimum
2314            // can land exactly on a boundary (the warm filter above may have
2315            // just emptied the hint because the START was interior). Report the
2316            // rows tight at the answer so the next warm start carries the face
2317            // instead of an empty hint — but RANK-REDUCED to one representative
2318            // per independent direction, the same contract every other return
2319            // path honors via `canonicalize_active_constraint_ids`. The prior
2320            // code stored the RAW tight set here, so a degenerate face leaked all
2321            // K co-tight rows (e.g. K near-parallel `x >= 0` rows returned all K
2322            // instead of a single representative), re-seeding the next solve with
2323            // redundant rows and the phantom-dual release/re-add they drive.
2324            if let Some(hint) = active_hint.as_mut() {
2325                let mut tight: Vec<usize> = Vec::new();
2326                for i in 0..m {
2327                    if scaled_constraint_slack(&candidate, constraints, i) <= tol_active {
2328                        tight.push(i);
2329                    }
2330                }
2331                hint.clear();
2332                hint.extend(canonicalize_active_constraint_ids(
2333                    &candidate,
2334                    constraints,
2335                    &tight,
2336                )?);
2337            }
2338            return Ok(());
2339        }
2340    }
2341
2342    let mut active: Vec<usize> = Vec::new();
2343    let mut is_active = vec![false; m];
2344    if let Some(hint) = active_hint.as_ref() {
2345        for &idx in hint.iter() {
2346            if idx < m && !is_active[idx] {
2347                active.push(idx);
2348                is_active[idx] = true;
2349                log_active_set_transition("warm-add", 0, active.len(), Some(idx));
2350            }
2351        }
2352    }
2353    for i in 0..m {
2354        // Scaled (geometric) slack: a row with ‖a_i‖ ≫ 1 (e.g. a B-spline
2355        // endpoint-derivative clamp at k = 12, ‖a_i‖ ≈ 38) would otherwise
2356        // require a raw slack of `tol_active·‖a_i‖` ≈ 4e-9 to activate, which
2357        // is below LBLT-solve precision on the KKT system and starves the
2358        // active set of rows that genuinely belong on the boundary.
2359        let slack = scaled_constraint_slack(&x, constraints, i);
2360        if slack <= tol_active && !is_active[i] {
2361            active.push(i);
2362            is_active[i] = true;
2363            log_active_set_transition("initial-boundary-add", 0, active.len(), Some(i));
2364        }
2365    }
2366    let mut visited_working_sets: HashSet<(Vec<usize>, Vec<u64>)> = HashSet::new();
2367    record_active_working_set(&mut visited_working_sets, &active, &x, 0);
2368
2369    // See the operator loop's `face_minimized` doc: an unblocked full step
2370    // lands on the working-face minimizer, so the following iteration must
2371    // adjudicate multipliers rather than re-measure KKT-solve noise against
2372    // the absolute `tol_step` (#979 noise-chatter starvation).
2373    let mut face_minimized = false;
2374
2375    for iteration in 0..max_iterations {
2376        let adjudicate_face = face_minimized;
2377        face_minimized = false;
2378        let compressed_working = compress_active_working_set(&x, constraints, &active)?;
2379        let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
2380        for r in 0..compressed_working.constraints.a.nrows() {
2381            residualw[r] = compressed_working.constraints.b[r]
2382                - compressed_working.constraints.a.row(r).dot(&x);
2383        }
2384        let (d, lambdaw) = solve_kkt_direction(
2385            hessian,
2386            &g_cur,
2387            &compressed_working.constraints.a,
2388            Some(&residualw),
2389        )?;
2390        let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
2391        if step_norm <= tol_step || adjudicate_face {
2392            // A "stationary" iterate is only a genuine KKT point if it is
2393            // also primal-feasible on the FULL constraint set. The
2394            // "blocking-add" loop further down only catches rows that
2395            // become tight DURING a non-degenerate step, so a tiny step
2396            // entered with a residual violation on an inactive row would
2397            // otherwise leak an infeasible direction through this
2398            // short-circuit unchecked. Grow the active set with the
2399            // most-violated inactive row and re-solve; `residualw` for that
2400            // row will be non-zero on the next KKT solve, so the resulting
2401            // step is non-degenerate.
2402            let (worst, worst_row) = max_linear_constraint_violation(&x, constraints);
2403            if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
2404                active.push(worst_row);
2405                is_active[worst_row] = true;
2406                log_active_set_transition(
2407                    "stationary-infeasible-add",
2408                    iteration,
2409                    active.len(),
2410                    Some(worst_row),
2411                );
2412                if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
2413                    break;
2414                }
2415                continue;
2416            }
2417            if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2418                // The worst-violating row is already active. Distinguish two
2419                // cases by whether it is ENFORCED by the compressed face:
2420                let worst_pos = active.iter().position(|&idx| idx == worst_row);
2421                let enforced = worst_pos.is_some_and(|pos| compressed_working.position_enforced(pos));
2422                if !enforced {
2423                    // Over-complete face (#2378): the row is active but was
2424                    // rank-reduced out of the enforced representative face (it is
2425                    // linearly dependent on the reps). It can be neither re-added
2426                    // (already active) nor released (not a representative), so the
2427                    // plain add/release loop dead-ends here. Adjudicate by an
2428                    // active-set EXCHANGE — release the representative it is most
2429                    // aligned with so it becomes independent and binds next
2430                    // iteration — instead of truncating it.
2431                    if let Some(mut group) = compressed_working
2432                        .over_complete_release_group(constraints.a.row(worst_row), &active)
2433                    {
2434                        group.sort_unstable_by(|a, b| b.cmp(a));
2435                        let mut released = None;
2436                        for active_pos in group {
2437                            let idx = active.remove(active_pos);
2438                            is_active[idx] = false;
2439                            released = Some(idx);
2440                        }
2441                        log_active_set_transition(
2442                            "release-over-complete-face",
2443                            iteration,
2444                            active.len(),
2445                            released,
2446                        );
2447                        if !record_active_working_set(
2448                            &mut visited_working_sets,
2449                            &active,
2450                            &x,
2451                            iteration,
2452                        ) {
2453                            break;
2454                        }
2455                        continue;
2456                    }
2457                }
2458                // Enforced-but-unclosed (an `alpha`-clip prevented the full
2459                // residual closure) or no positively-aligned representative to
2460                // exchange: fall through to the post-loop exit gate, which
2461                // inspects the iterate's full KKT residuals and either tries the
2462                // projected-gradient fallback or returns an explicit error. Both
2463                // are correct outcomes; silently returning the infeasible
2464                // direction is not.
2465                break;
2466            }
2467            if compressed_working.groups.is_empty() {
2468                direction_out.assign(&d_total);
2469                return Ok(());
2470            }
2471            let remove_group =
2472                compressed_working.negative_representative_group(&lambdaw, tol_dual, &active);
2473            if let Some(mut group) = remove_group {
2474                // Release the whole independent direction. Remove positions in
2475                // DESCENDING order so each `active.remove` does not shift a
2476                // not-yet-removed position in the same group.
2477                group.sort_unstable_by(|a, b| b.cmp(a));
2478                let mut released = None;
2479                for active_pos in group {
2480                    let idx = active.remove(active_pos);
2481                    is_active[idx] = false;
2482                    released = Some(idx);
2483                }
2484                log_active_set_transition(
2485                    "release-negative-representative",
2486                    iteration,
2487                    active.len(),
2488                    released,
2489                );
2490                if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
2491                    break;
2492                }
2493                continue;
2494            }
2495            if let Some(hint) = active_hint {
2496                hint.clear();
2497                hint.extend(canonicalize_active_constraint_ids(
2498                    &x,
2499                    constraints,
2500                    &active,
2501                )?);
2502            }
2503            direction_out.assign(&d_total);
2504            return Ok(());
2505        }
2506
2507        let mut alpha = 1.0_f64;
2508        for i in 0..m {
2509            if is_active[i] {
2510                continue;
2511            }
2512            // boundary_hit_step_fraction is scale-equivariant in `(slack, ai_d)`:
2513            // scaling both by 1/‖a_i‖ leaves `step = slack / -ai_d` unchanged,
2514            // and its directional-tol/finite checks operate on a max-magnitude
2515            // scale of the same triple. Doing the geometric rescale here keeps
2516            // the step-fraction's "moving toward the boundary" test in the same
2517            // scaled coordinates the activation tests use.
2518            let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
2519            let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
2520            let slack = (constraints.a.row(i).dot(&x) - constraints.b[i]) * inv;
2521            let ai_d = constraints.a.row(i).dot(&d) * inv;
2522            if let Some(cand) = active_set_boundary_hit_step_fraction(slack, ai_d, alpha) {
2523                alpha = cand;
2524            }
2525        }
2526
2527        ndarray::Zip::from(&mut d_total)
2528            .and(&d)
2529            .for_each(|dt_i, &d_i| {
2530                *dt_i += alpha * d_i;
2531            });
2532        // The public contract certifies `beta + d_total`, so the iterate must BE
2533        // that sum bitwise. Accumulating `x` by its own `+= alpha*d` walks a
2534        // different rounding path: a boundary-landing step (ratio test targets
2535        // scaled slack −TOL) then certifies feasible on the loop's `x` while the
2536        // caller's freshly summed candidate reads TOL + O(eps·scale) and the
2537        // solve is rejected as an "infeasible iterate" (#979 CTN cycle 86).
2538        x = beta + &d_total;
2539        g_cur = gradient + &hessian.dot(&d_total);
2540
2541        let mut added_new_active = false;
2542        let mut working_set_repeated = false;
2543        for i in 0..m {
2544            if is_active[i] {
2545                continue;
2546            }
2547            let slack = scaled_constraint_slack(&x, constraints, i);
2548            if slack <= tol_active {
2549                active.push(i);
2550                is_active[i] = true;
2551                added_new_active = true;
2552                log_active_set_transition("blocking-add", iteration, active.len(), Some(i));
2553                working_set_repeated =
2554                    !record_active_working_set(&mut visited_working_sets, &active, &x, iteration);
2555                break;
2556            }
2557        }
2558        if !added_new_active {
2559            // Unblocked full step onto the working-face minimizer: adjudicate
2560            // multipliers next iteration instead of re-measuring solve noise.
2561            face_minimized = true;
2562        }
2563        if working_set_repeated {
2564            break;
2565        }
2566
2567        if active.is_empty() && !added_new_active {
2568            if let Some(hint) = active_hint {
2569                hint.clear();
2570            }
2571            direction_out.assign(&d_total);
2572            return Ok(());
2573        }
2574    }
2575
2576    let compressed_working = compress_active_working_set(&x, constraints, &active)?;
2577    let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
2578    for r in 0..compressed_working.constraints.a.nrows() {
2579        residualw[r] =
2580            compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
2581    }
2582    let (_, lambdaw) = solve_kkt_direction(
2583        hessian,
2584        &g_cur,
2585        &compressed_working.constraints.a,
2586        Some(&residualw),
2587    )?;
2588    let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
2589    let (worst, row) = max_linear_constraint_violation(&x, constraints);
2590    let working_kkt = working_set_kkt_diagnostics_from_multipliers(
2591        &x,
2592        &g_cur,
2593        &compressed_working.constraints,
2594        &lambda_true,
2595        m,
2596    )?;
2597    let grad_inf = gradient_inf_norm(&g_cur);
2598    let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
2599    let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2600    let hd_total = hessian.dot(&d_total);
2601    let predicted_delta = gradient.dot(&d_total)
2602        + 0.5
2603            * d_total
2604                .iter()
2605                .zip(hd_total.iter())
2606                .map(|(a, b)| a * b)
2607                .sum::<f64>();
2608    let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
2609        || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
2610        && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
2611    let model_descent_ok =
2612        predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
2613    let degenerate_boundary_ok = compressed_working.is_degenerate_face()
2614        && worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2615        && working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2616        && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
2617        && (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
2618            || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
2619    // Existence-form KKT certificate over the rows tight at the terminal
2620    // iterate. The working-set multipliers above are one particular
2621    // reconstruction; on a degenerate face they can report `dual ≫ 0` while a
2622    // different λ ≥ 0 closes stationarity exactly (#2298 monotonicity faces).
2623    // NNLS over the tight rows has exact complementarity by construction
2624    // (only tight rows enter) and exact dual feasibility, so stationarity
2625    // closure of its projected residual is the entire remaining KKT question.
2626    // Skip it only when the strong path already ACCEPTS (stationarity AND dual
2627    // both certified): a strong-stationary point whose particular multiplier
2628    // reconstruction has phantom negative duals is exactly the case this
2629    // certificate exists for (#979 CTN cycle-95: stat=1.0e-8, comp=4.2e-8,
2630    // dual=7.6e4 on a 41-row degenerate face — refused with the old
2631    // `!kkt_strong_ok` gate although an NNLS λ ≥ 0 closes the point).
2632    let strong_path_accepts =
2633        kkt_strong_ok && working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
2634    let nnls_certified = worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !strong_path_accepts && {
2635        let tight: Vec<usize> = (0..m)
2636            .filter(|&i| scaled_constraint_slack(&x, constraints, i) <= tol_active)
2637            .collect();
2638        match gather_linear_constraint_rows(constraints, &tight) {
2639            Ok(gathered) => nonnegative_cone_multipliers(&gathered.a, &g_cur)
2640                .map(|(_, projected)| {
2641                    let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2642                    closure <= ACTIVE_SET_KKT_STATIONARITY_TOL
2643                        || closure / grad_inf.max(1.0) <= ACTIVE_SET_KKT_STATIONARITY_TOL
2644                })
2645                .unwrap_or(false),
2646            Err(_) => false,
2647        }
2648    };
2649    if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2650        && ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
2651            && (kkt_strong_ok || (allow_projected_gradient_fallback && model_descent_ok)))
2652            || degenerate_boundary_ok
2653            || nnls_certified)
2654    {
2655        if let Some(hint) = active_hint {
2656            hint.clear();
2657            hint.extend(canonicalize_active_constraint_ids(
2658                &x,
2659                constraints,
2660                &active,
2661            )?);
2662        }
2663        direction_out.assign(&d_total);
2664        return Ok(());
2665    }
2666    if !allow_projected_gradient_fallback {
2667        return Err(EstimationError::ParameterConstraintViolation(format!(
2668            "linear-constrained Newton active-set did not certify the strict-convex projection QP; max(Aβ-b violation)={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]",
2669            working_kkt.primal_feasibility,
2670            working_kkt.dual_feasibility,
2671            working_kkt.complementarity,
2672            working_kkt.stationarity,
2673            working_kkt.n_active,
2674            working_kkt.n_constraints,
2675        )));
2676    }
2677    let kkt = compute_constraint_kkt_diagnostics(&x, &g_cur, constraints);
2678    let fallback_working = gather_linear_constraint_rows(constraints, &active)?;
2679    if let Some((fallback_direction, fallback_active)) = fallback_projected_gradient_direction(
2680        beta,
2681        &x,
2682        &d_total,
2683        &g_cur,
2684        &fallback_working,
2685        constraints,
2686    )? {
2687        if let Some(hint) = active_hint {
2688            hint.clear();
2689            hint.extend(fallback_active);
2690        }
2691        direction_out.assign(&fallback_direction);
2692        return Ok(());
2693    }
2694    Err(EstimationError::ParameterConstraintViolation(format!(
2695        "linear-constrained Newton active-set failed to converge; max(Aβ-b violation)={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]; diagnostic-reconstruction[dual={:.3e}, stat={:.3e}]",
2696        working_kkt.primal_feasibility,
2697        working_kkt.dual_feasibility,
2698        working_kkt.complementarity,
2699        working_kkt.stationarity,
2700        working_kkt.n_active,
2701        working_kkt.n_constraints,
2702        kkt.dual_feasibility,
2703        kkt.stationarity
2704    )))
2705}
2706
2707// ============================================================================
2708// Operator (ConstraintSet) active-set solver — gam#2306
2709//
2710// The factored Khatri-Rao monotonicity cone has `n · p_shape` rows over
2711// `p_resp · p_cov` coefficients; its dense materialization is gigabytes while
2712// every operation the primal active-set method performs factors through the
2713// `n × p_cov` covariate design. This section is the operator sibling of
2714// `solve_newton_direction_with_linear_constraints_impl`: identical
2715// per-row-scaled (geometric) tolerance semantics, identical KKT core
2716// (`solve_kkt_direction` on the rank-reduced working set) — but every
2717// full-row-set sweep (activation scan, ratio test, violation gate) runs on
2718// batched constraint values, never on explicit rows. Dense inputs delegate to
2719// the dense loop verbatim, so existing callers are byte-identical.
2720// ============================================================================
2721
2722/// Batched full-row-set geometry for a [`ConstraintSet`].
2723///
2724/// `scaled_margin` shifts every non-vacuous row inward by that amount in
2725/// scaled (geometric) units — `a_iᵀβ ≥ b_i + scaled_margin·‖a_i‖` — which is
2726/// exactly the uniform interior-seed shift the dense strict projection
2727/// applies. The main QP solve uses `scaled_margin = 0`.
2728struct ConstraintSetOps<'a> {
2729    set: &'a ConstraintSet,
2730    norms: Vec<f64>,
2731    bounds: Vec<f64>,
2732    scaled_margin: f64,
2733}
2734
2735impl<'a> ConstraintSetOps<'a> {
2736    fn new(set: &'a ConstraintSet, scaled_margin: f64) -> Result<Self, EstimationError> {
2737        let m = set.nrows();
2738        let mut norms = Vec::with_capacity(m);
2739        let mut bounds = Vec::with_capacity(m);
2740        for row in 0..m {
2741            norms.push(set.row_norm(row).map_err(|e| {
2742                EstimationError::ParameterConstraintViolation(format!(
2743                    "constraint-set row norm: {e}"
2744                ))
2745            })?);
2746            bounds.push(set.bound(row).map_err(|e| {
2747                EstimationError::ParameterConstraintViolation(format!(
2748                    "constraint-set row bound: {e}"
2749                ))
2750            })?);
2751        }
2752        Ok(Self {
2753            set,
2754            norms,
2755            bounds,
2756            scaled_margin,
2757        })
2758    }
2759
2760    /// Operator view of only the rows that are tight at `beta`. Inactive rows
2761    /// do not constrain the tangent cone, so make them vacuous by zeroing both
2762    /// their cached norm and bound while retaining the original row indexing.
2763    /// This avoids materializing the potentially enormous tight submatrix and
2764    /// keeps returned active ids in the parent [`ConstraintSet`] coordinates.
2765    fn tangent_face(set: &'a ConstraintSet, beta: &Array1<f64>) -> Result<Self, EstimationError> {
2766        let mut ops = Self::new(set, 0.0)?;
2767        let values = ops.values(beta)?;
2768        for row in 0..ops.nrows() {
2769            if ops.norms[row] <= 0.0 {
2770                if ops.bounds[row] > 0.0 {
2771                    crate::bail_invalid_estim!(
2772                        "infeasible zero-norm constraint row {} entered tangent-face projection",
2773                        row
2774                    );
2775                }
2776                ops.bounds[row] = 0.0;
2777                continue;
2778            }
2779            let is_tight = ops.scaled_slack(&values, row) <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
2780            // Tangent directions are homogeneous even when the original
2781            // feasible set is affine: a_i^T d >= 0 on a tight row.
2782            ops.bounds[row] = 0.0;
2783            if !is_tight {
2784                ops.norms[row] = 0.0;
2785            }
2786        }
2787        Ok(ops)
2788    }
2789
2790    fn nrows(&self) -> usize {
2791        self.norms.len()
2792    }
2793
2794    fn values(&self, x: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
2795        self.set.values(x.view()).map_err(|e| {
2796            EstimationError::ParameterConstraintViolation(format!("constraint-set values: {e}"))
2797        })
2798    }
2799
2800    /// Signed scaled slack of one row given the batched raw values, with the
2801    /// same ±∞ zero-norm semantics as [`scaled_constraint_slack`].
2802    #[inline]
2803    fn scaled_slack(&self, values: &Array1<f64>, row: usize) -> f64 {
2804        let norm = self.norms[row];
2805        if norm > 0.0 {
2806            (values[row] - self.bounds[row]) / norm - self.scaled_margin
2807        } else if self.bounds[row] > 0.0 {
2808            f64::NEG_INFINITY
2809        } else {
2810            f64::INFINITY
2811        }
2812    }
2813
2814    fn max_violation(&self, values: &Array1<f64>) -> (f64, usize) {
2815        let mut worst = 0.0_f64;
2816        let mut worst_row = 0usize;
2817        for row in 0..self.nrows() {
2818            let violation = (-self.scaled_slack(values, row)).max(0.0);
2819            if violation > worst {
2820                worst = violation;
2821                worst_row = row;
2822            }
2823        }
2824        (worst, worst_row)
2825    }
2826
2827    /// Gather the working rows as an explicit UNIT-normalized system (the
2828    /// per-row scale the dense path reaches via up-front canonicalization),
2829    /// with the margin shift folded into `b`. Zero-norm rows are rejected —
2830    /// they are vacuous and must never enter a working set.
2831    fn gather_unit_rows(
2832        &self,
2833        rows: &[usize],
2834    ) -> Result<LinearInequalityConstraints, EstimationError> {
2835        let mut gathered = self.set.gather_rows(rows).map_err(|e| {
2836            EstimationError::ParameterConstraintViolation(format!(
2837                "constraint-set working-row gather: {e}"
2838            ))
2839        })?;
2840        for (out_row, &row) in rows.iter().enumerate() {
2841            let norm = self.norms[row];
2842            if norm <= 0.0 {
2843                crate::bail_invalid_estim!(
2844                    "vacuous zero-norm constraint row {} entered the working set",
2845                    row
2846                );
2847            }
2848            let inv = 1.0 / norm;
2849            gathered.a.row_mut(out_row).mapv_inplace(|v| v * inv);
2850            gathered.b[out_row] = self.bounds[row] * inv + self.scaled_margin;
2851        }
2852        Ok(gathered)
2853    }
2854
2855    /// Rank-reduced compressed working set over the gathered unit rows —
2856    /// the operator analogue of [`compress_active_working_set`].
2857    fn compress_working(
2858        &self,
2859        active: &[usize],
2860    ) -> Result<CompressedActiveWorkingSet, EstimationError> {
2861        let gathered = self.gather_unit_rows(active)?;
2862        let groups: Vec<Vec<usize>> = (0..active.len()).map(|pos| vec![pos]).collect();
2863        // 4th return (parallel-dependent map) is consumed only by the shared
2864        // `ReducedFace` op; whole-group release does not need it here.
2865        let (a_out, b_out, groups_out, _) =
2866            rank_reduce_rows_pivoted_qr_with_dependence(gathered.a, gathered.b, groups);
2867        Ok(CompressedActiveWorkingSet {
2868            constraints: LinearInequalityConstraints::new(a_out, b_out)
2869                .expect("compressed operator working-set shape invariant"),
2870            groups: groups_out,
2871            original_active_count: active.len(),
2872        })
2873    }
2874}
2875
2876/// Retain only candidate row ids that are genuinely tight at `beta`.
2877///
2878/// Active-face provenance is point-local. A constrained QP reports its full
2879/// endpoint face, while trust-region globalization may accept a strict
2880/// subsegment whose endpoint-only rows are still slack. This helper is the
2881/// shared handoff for warm starts and terminal tangent-space evidence: it uses
2882/// the carrier's exact row scaling, preserves canonical input order, and never
2883/// scans rows outside the sparse candidate face.
2884pub fn constraint_set_rows_tight_at_point(
2885    set: &ConstraintSet,
2886    beta: &Array1<f64>,
2887    candidate_rows: &[usize],
2888) -> Result<Vec<usize>, EstimationError> {
2889    if set.ncols() != beta.len() {
2890        crate::bail_invalid_estim!(
2891            "active-face point dimension mismatch: set has {} columns, beta has {}",
2892            set.ncols(),
2893            beta.len()
2894        );
2895    }
2896    let mut seen = HashSet::with_capacity(candidate_rows.len());
2897    let mut unique = Vec::with_capacity(candidate_rows.len());
2898    for &row in candidate_rows {
2899        if row < set.nrows() && seen.insert(row) {
2900            unique.push(row);
2901        }
2902    }
2903    if unique.is_empty() {
2904        return Ok(Vec::new());
2905    }
2906    let gathered = set.gather_rows(&unique).map_err(|error| {
2907        EstimationError::ParameterConstraintViolation(format!(
2908            "active-face candidate-row gather failed: {error}"
2909        ))
2910    })?;
2911    let mut tight = Vec::with_capacity(unique.len());
2912    for (position, &row) in unique.iter().enumerate() {
2913        let constraint_row = gathered.a.row(position);
2914        let norm = constraint_row.dot(&constraint_row).sqrt();
2915        if norm > 0.0 {
2916            let scaled_slack = (constraint_row.dot(beta) - gathered.b[position]) / norm;
2917            if scaled_slack <= ACTIVE_SET_WORKING_FACE_TOL {
2918                tight.push(row);
2919            }
2920        }
2921    }
2922    Ok(tight)
2923}
2924
2925/// Project a stationarity residual onto the normal cone of an operator-carried
2926/// constraint set without materializing its complete tight face.
2927///
2928/// `seed_active` is the QP's sparse face. The negative projected residual is a
2929/// candidate feasible-tangent direction. One operator-native active-set solve
2930/// discovers any omitted tight blocker with its full-set ratio test, while
2931/// gathering only rows that carry necessary cone geometry.
2932pub fn project_stationarity_residual_on_constraint_set(
2933    residual: &Array1<f64>,
2934    beta: &Array1<f64>,
2935    set: &ConstraintSet,
2936    seed_active: &[usize],
2937) -> Option<(Array1<f64>, Vec<usize>)> {
2938    let p = residual.len();
2939    if beta.len() != p || set.ncols() != p {
2940        return None;
2941    }
2942    match set {
2943        ConstraintSet::KhatriRaoCone(cone) if cone.p_left() != 1 || cone.coupled_rows() != &[0] => {
2944            // Each coupled response row occupies a disjoint `p_cov` slice,
2945            // and the projection Hessian is identity. The global projection is
2946            // therefore the exact direct sum of small row projections. Solving
2947            // all response rows in one `p_left*p_cov` KKT system needlessly
2948            // pays cubic global algebra at the all-tight CTN vertex.
2949            let p_cov = cone.factor().ncols();
2950            let n = cone.factor().nrows();
2951            let mut projected = residual.clone();
2952            let mut active = Vec::new();
2953            for (slot, &coefficient_row) in cone.coupled_rows().iter().enumerate() {
2954                let start = coefficient_row * p_cov;
2955                let end = start + p_cov;
2956                let local_residual = residual.slice(s![start..end]).to_owned();
2957                let local_beta = beta.slice(s![start..end]).to_owned();
2958                let local_set = ConstraintSet::KhatriRaoCone(cone.single_coupled_slot(slot).ok()?);
2959                let row_start = slot * n;
2960                let row_end = row_start + n;
2961                let local_seed: Vec<usize> = seed_active
2962                    .iter()
2963                    .copied()
2964                    .filter(|&row| row >= row_start && row < row_end)
2965                    .map(|row| row - row_start)
2966                    .collect();
2967                let (local_projected, local_active) =
2968                    project_stationarity_residual_on_constraint_set(
2969                        &local_residual,
2970                        &local_beta,
2971                        &local_set,
2972                        &local_seed,
2973                    )?;
2974                projected.slice_mut(s![start..end]).assign(&local_projected);
2975                active.extend(local_active.into_iter().map(|row| row_start + row));
2976            }
2977            Some((projected, active))
2978        }
2979        ConstraintSet::BlockDiagonal { blocks, .. } => {
2980            // The same direct-sum identity applies to explicitly placed blocks;
2981            // columns outside all blocks are unconstrained and retain their
2982            // original residual components.
2983            let mut projected = residual.clone();
2984            let mut active = Vec::new();
2985            let mut row_offset = 0usize;
2986            for block in blocks {
2987                let width = block.set.ncols();
2988                let start = block.col_start;
2989                let end = start + width;
2990                let local_residual = residual.slice(s![start..end]).to_owned();
2991                let local_beta = beta.slice(s![start..end]).to_owned();
2992                let row_end = row_offset + block.set.nrows();
2993                let local_seed: Vec<usize> = seed_active
2994                    .iter()
2995                    .copied()
2996                    .filter(|&row| row >= row_offset && row < row_end)
2997                    .map(|row| row - row_offset)
2998                    .collect();
2999                let (local_projected, local_active) =
3000                    project_stationarity_residual_on_constraint_set(
3001                        &local_residual,
3002                        &local_beta,
3003                        &block.set,
3004                        &local_seed,
3005                    )?;
3006                projected.slice_mut(s![start..end]).assign(&local_projected);
3007                active.extend(local_active.into_iter().map(|row| row_offset + row));
3008                row_offset = row_end;
3009            }
3010            Some((projected, active))
3011        }
3012        _ => project_stationarity_residual_on_constraint_set_undivided(
3013            residual,
3014            beta,
3015            set,
3016            seed_active,
3017        ),
3018    }
3019}
3020
3021fn project_stationarity_residual_on_constraint_set_undivided(
3022    residual: &Array1<f64>,
3023    beta: &Array1<f64>,
3024    set: &ConstraintSet,
3025    seed_active: &[usize],
3026) -> Option<(Array1<f64>, Vec<usize>)> {
3027    let p = residual.len();
3028    let ops = ConstraintSetOps::tangent_face(set, beta).ok()?;
3029    let mut active = Vec::with_capacity(seed_active.len().min(p));
3030    for &row in seed_active {
3031        if row < ops.nrows() && ops.norms[row] > 0.0 && !active.contains(&row) {
3032            active.push(row);
3033        }
3034    }
3035
3036    // Solve the Moreau tangent projection once in the operator-native primal
3037    // active set:
3038    //
3039    //   min_d  1/2 ||d + residual||^2    s.t. A d >= 0.
3040    //
3041    // The former separator gathered one extra factored row, invoked a complete
3042    // dense cone optimizer from the origin, and repeated. That nested a QP per
3043    // cut and discarded its face every time. The operator solver already owns
3044    // the same full-set ratio test without materializing A; carry `active`
3045    // through that single solve instead. Disable its projected-gradient escape
3046    // because that escape calls this projection oracle.
3047    let identity = Array2::<f64>::eye(p);
3048    let origin = Array1::<f64>::zeros(p);
3049    let mut tangent_direction = Array1::<f64>::zeros(p);
3050    let max_iterations = (p + active.len() + 8) * 4;
3051    if let Err(error) = solve_newton_direction_with_constraint_set_impl(
3052        &identity,
3053        residual,
3054        &origin,
3055        &ops,
3056        &mut tangent_direction,
3057        Some(&mut active),
3058        max_iterations,
3059        false,
3060    ) {
3061        log::warn!(
3062            "factored tangent-cone projection QP failed \
3063             (p={p}, rows={}, seed_active_rows={}, residual_inf={:.6e}): {error}; \
3064             attempting the direct Lawson-Hanson Moreau fallback on the final working face",
3065            ops.nrows(),
3066            seed_active.len(),
3067            residual
3068                .iter()
3069                .fold(0.0_f64, |scale, value| scale.max(value.abs())),
3070        );
3071        return nnls_tangent_cone_projection_fallback(residual, beta, set, &active, seed_active);
3072    }
3073    if !array_is_finite(&tangent_direction) {
3074        return nnls_tangent_cone_projection_fallback(residual, beta, set, &active, seed_active);
3075    }
3076    Some((-tangent_direction, active))
3077}
3078
3079/// Exact Moreau fallback for the operator tangent-cone projection when the
3080/// strict-convex primal QP refuses to certify. Degenerate faces defeat that
3081/// QP's exit ritual: on the measured #979 CTN shape the projection pins a
3082/// fully determined 24-row vertex in R^24 and the single-face multiplier
3083/// reconstruction reports a phantom negative dual (KKT dual=1.2 with primal,
3084/// complementarity, and stationarity all at round-off), so the caller fell
3085/// back to the UNPROJECTED residual (1.447e3) and the convergence certificate
3086/// could never fire. Projection onto a finitely generated cone IS
3087/// nonnegative least squares (Moreau), so run Lawson-Hanson directly on the
3088/// rows of the QP's final working face that are genuinely tight at `beta`:
3089/// `projected = residual − Aᵀλ*` with `λ* = argmin_{λ≥0} ‖residual − Aᵀλ‖`.
3090///
3091/// Two properties make this sound as a certificate input:
3092/// - Restricting the generators to a subset of the true tangent-cone rows can
3093///   only ENLARGE the projected residual (the minimum runs over a smaller
3094///   λ-support), so a lost row biases toward refusal, never acceptance.
3095/// - Every generator is verified tight at `beta` before entering, so
3096///   `projected ≈ 0` states exactly `residual = Aᵀλ*, λ* ≥ 0` over active
3097///   rows — the existence form of the KKT stationarity condition.
3098fn nnls_tangent_cone_projection_fallback(
3099    residual: &Array1<f64>,
3100    beta: &Array1<f64>,
3101    set: &ConstraintSet,
3102    face_rows: &[usize],
3103    seed_active: &[usize],
3104) -> Option<(Array1<f64>, Vec<usize>)> {
3105    let mut candidates: Vec<usize> = Vec::with_capacity(face_rows.len() + seed_active.len());
3106    for &row in face_rows.iter().chain(seed_active.iter()) {
3107        if row < set.nrows() && !candidates.contains(&row) {
3108            candidates.push(row);
3109        }
3110    }
3111    if candidates.is_empty() {
3112        // No tight generators: the local tangent cone is the whole space and
3113        // the projected stationarity residual is the raw residual, exactly as
3114        // the primal QP would report for an interior point.
3115        return Some((residual.clone(), Vec::new()));
3116    }
3117    let candidate_rows = set.gather_rows(&candidates).ok()?;
3118    // Same tightness band the tangent-face carrier itself uses, so the
3119    // generator set matches the cone geometry the refused QP was solving.
3120    let mut tight = Vec::with_capacity(candidates.len());
3121    let mut tight_a = Vec::with_capacity(candidates.len());
3122    for (position, &row) in candidates.iter().enumerate() {
3123        let constraint_row = candidate_rows.a.row(position);
3124        let norm = constraint_row.dot(&constraint_row).sqrt();
3125        if norm > 0.0
3126            && (constraint_row.dot(beta) - candidate_rows.b[position]) / norm
3127                <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3128        {
3129            tight.push(row);
3130            tight_a.push(position);
3131        }
3132    }
3133    if tight.is_empty() {
3134        return Some((residual.clone(), Vec::new()));
3135    }
3136    let mut generators = Array2::<f64>::zeros((tight.len(), residual.len()));
3137    for (out_row, &position) in tight_a.iter().enumerate() {
3138        generators
3139            .row_mut(out_row)
3140            .assign(&candidate_rows.a.row(position));
3141    }
3142    let (lambda, projected) = nonnegative_cone_multipliers(&generators, residual)?;
3143    let active: Vec<usize> = tight
3144        .iter()
3145        .zip(lambda.iter())
3146        .filter(|&(_, &multiplier)| multiplier > 0.0)
3147        .map(|(&row, _)| row)
3148        .collect();
3149    log::info!(
3150        "tangent-cone Moreau fallback certified the projection the primal QP refused: \
3151         face_rows={} tight_rows={} supported_rows={} projected_inf={:.6e}",
3152        face_rows.len(),
3153        tight.len(),
3154        active.len(),
3155        projected
3156            .iter()
3157            .fold(0.0_f64, |scale, value| scale.max(value.abs())),
3158    );
3159    Some((projected, active))
3160}
3161
3162/// First-order escape from a tolerance-band working-set cycle for an operator
3163/// constraint carrier. This is the factored equivalent of
3164/// [`fallback_projected_gradient_direction`]: project `-gradient` into the
3165/// current face's tangent space, clip it at the first constraint boundary, and
3166/// accept only a finite, descending, fully feasible direction.
3167///
3168/// Keep the returned face sparse. A single coefficient row at zero can make
3169/// thousands of Khatri-Rao observation rows tight; rediscovering every tight
3170/// row here would recreate the all-face materialization this operator path is
3171/// specifically meant to avoid. The old working rows remain tight under the
3172/// tangent step. If a currently omitted tight row blocks at zero step, add
3173/// that separator and recompute the cone projection; this cutting-plane loop
3174/// discovers only the rows needed to describe a feasible tangent direction.
3175fn fallback_projected_gradient_direction_with_constraint_set(
3176    beta: &Array1<f64>,
3177    x: &Array1<f64>,
3178    d_total: &Array1<f64>,
3179    gradient: &Array1<f64>,
3180    active: &[usize],
3181    ops: &ConstraintSetOps<'_>,
3182) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
3183    let p = gradient.len();
3184    if x.len() != p || d_total.len() != p || beta.len() != p || ops.set.ncols() != p {
3185        crate::bail_invalid_estim!("operator projected-gradient fallback dimension mismatch");
3186    }
3187
3188    let values_x = ops.values(x)?;
3189    let Some((stationarity_residual, mut tangent_active)) =
3190        project_stationarity_residual_on_constraint_set(gradient, x, ops.set, active)
3191    else {
3192        return Ok(None);
3193    };
3194    let tangent_direction = -stationarity_residual;
3195    let step_inf = tangent_direction
3196        .iter()
3197        .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
3198    if step_inf <= 1e-12 {
3199        let (worst, _) = ops.max_violation(&values_x);
3200        if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3201            let Some(projected) = project_point_strictly_into_feasible_constraint_set(x, ops.set)
3202                .ok()
3203                .filter(|candidate| {
3204                    ops.values(candidate)
3205                        .map(|candidate_values| {
3206                            ops.max_violation(&candidate_values).0
3207                                <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3208                        })
3209                        .unwrap_or(false)
3210                })
3211            else {
3212                return Ok(None);
3213            };
3214            let repair = &projected - x;
3215            let new_direction = d_total + &repair;
3216            // Certify the caller's reconstruction `beta + dir`, not the
3217            // projection itself (they differ by the x/d_total cancellation
3218            // rounding, and the caller's gate is what must pass).
3219            let candidate = beta + &new_direction;
3220            let candidate_values = ops.values(&candidate)?;
3221            if ops.max_violation(&candidate_values).0 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3222                return Ok(None);
3223            }
3224            return Ok(Some((new_direction, Vec::new())));
3225        }
3226        return Ok(Some((d_total.clone(), tangent_active)));
3227    }
3228
3229    let directional_derivative = gradient.dot(&tangent_direction);
3230    if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
3231        return Ok(None);
3232    }
3233    let values_direction = ops.values(&tangent_direction)?;
3234    let mut alpha = 1.0_f64;
3235    let mut blocking_row = None;
3236    for row in 0..ops.nrows() {
3237        if ops.norms[row] <= 0.0 {
3238            continue;
3239        }
3240        let slack = ops.scaled_slack(&values_x, row);
3241        let rate = values_direction[row] / ops.norms[row];
3242        if let Some(candidate) = active_set_boundary_hit_step_fraction(slack, rate, alpha) {
3243            alpha = candidate;
3244            blocking_row = Some(row);
3245        }
3246    }
3247    if !alpha.is_finite() || alpha <= 0.0 {
3248        return Ok(None);
3249    }
3250    let fallback_step = tangent_direction * alpha;
3251    let new_direction = d_total + &fallback_step;
3252    // Evaluate feasibility on the caller's reconstruction `beta + dir` so the
3253    // acceptance here and the caller's final gate see the same bits.
3254    let new_x = beta + &new_direction;
3255    let new_values = ops.values(&new_x)?;
3256    if ops.max_violation(&new_values).0 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3257        return Ok(None);
3258    }
3259    if let Some(row) = blocking_row
3260        && !tangent_active.contains(&row)
3261    {
3262        tangent_active.push(row);
3263    }
3264    tangent_active.retain(|&row| ops.scaled_slack(&new_values, row) <= 1e-10);
3265    Ok(Some((new_direction, tangent_active)))
3266}
3267
3268fn solve_newton_direction_with_constraint_set_impl(
3269    hessian: &Array2<f64>,
3270    gradient: &Array1<f64>,
3271    beta: &Array1<f64>,
3272    ops: &ConstraintSetOps<'_>,
3273    direction_out: &mut Array1<f64>,
3274    mut active_hint: Option<&mut Vec<usize>>,
3275    max_iterations: usize,
3276    allow_projected_gradient_fallback: bool,
3277) -> Result<(), EstimationError> {
3278    let p = gradient.len();
3279    if direction_out.len() != p {
3280        *direction_out = Array1::zeros(p);
3281    }
3282    let m = ops.nrows();
3283    if ops.set.ncols() != p || beta.len() != p {
3284        crate::bail_invalid_estim!(
3285            "constraint-set shape mismatch: set={}x{}, p={}",
3286            m,
3287            ops.set.ncols(),
3288            p
3289        );
3290    }
3291
3292    let tol_active = ACTIVE_SET_WORKING_FACE_TOL;
3293    let tol_step = 1e-12;
3294    let tol_dual = 1e-10;
3295    let mut x = beta.to_owned();
3296    let mut d_total = Array1::<f64>::zeros(p);
3297    let mut g_cur = gradient.to_owned();
3298    let mut values_x = ops.values(&x)?;
3299
3300    // Face provenance is point-local. If globalization accepted only part of
3301    // the previous QP chord, its endpoint rows are not active at the accepted
3302    // point. Retaining them as warm equalities makes the next operator solve
3303    // chase a still-slack face by the same small trust fraction every cycle.
3304    // Keep only rows tight at the current beta; the full-set ratio test adds
3305    // any discarded row exactly when the iterate actually reaches it.
3306    if let Some(hint) = active_hint.as_mut() {
3307        hint.retain(|&idx| {
3308            idx < m && ops.norms[idx] > 0.0 && ops.scaled_slack(&values_x, idx) <= tol_active
3309        });
3310    }
3311
3312    let has_active_hint = active_hint
3313        .as_ref()
3314        .map(|hint| !hint.is_empty())
3315        .unwrap_or(false);
3316    if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
3317        let candidate = beta + &*direction_out;
3318        let candidate_values = ops.values(&candidate)?;
3319        let feasible = (0..m).all(|row| ops.scaled_slack(&candidate_values, row) >= -tol_active);
3320        if feasible {
3321            // Unlike the dense loop's fast path, the hint deliberately stays
3322            // empty here: a factored cone can have thousands of duplicate
3323            // geometrically-tight rows at a boundary landing, and eagerly
3324            // reporting them all would poison the next warm start with the
3325            // materialized face this operator path exists to avoid. The ratio
3326            // test rediscovers the one blocking row when it matters.
3327            return Ok(());
3328        }
3329    }
3330
3331    let mut active: Vec<usize> = Vec::new();
3332    let mut is_active = vec![false; m];
3333    if let Some(hint) = active_hint.as_ref() {
3334        for &idx in hint.iter() {
3335            if idx < m && !is_active[idx] && ops.norms[idx] > 0.0 {
3336                active.push(idx);
3337                is_active[idx] = true;
3338                log_active_set_transition("warm-add", 0, active.len(), Some(idx));
3339            }
3340        }
3341    }
3342    // Do NOT eagerly classify every tight operator row as active.  A factored
3343    // cone can have tens of thousands of geometrically tight rows at a low-
3344    // dimensional face: for CTN, one coefficient row becoming zero makes all
3345    // `n` observation rows tight although their span has dimension at most
3346    // `p_cov`.  Gathering that entire face and rank-reducing it on every QP
3347    // cycle turns a 144-variable solve into minutes of redundant QR work.
3348    //
3349    // An active-set method only needs tight rows that block the proposed
3350    // direction.  Start from the warm working set (possibly empty); the ratio
3351    // test below adds the first boundary row whose directional rate would
3352    // leave the cone, and the negative-dual test releases rows normally.  This
3353    // is the standard feasible active-set invariant and changes neither the
3354    // feasible region nor the QP optimum.  Dense constraints retain their
3355    // existing eager initialization in the dense solver.
3356    let mut visited_working_sets: HashSet<(Vec<usize>, Vec<u64>)> = HashSet::new();
3357    record_active_working_set(&mut visited_working_sets, &active, &x, 0);
3358
3359    // Terminal-diagnosis counters: dep-crate debug logs are filtered out by
3360    // CLI consumers, so a budget-exhausted refusal must name its own churn
3361    // mechanism (blocking-adds vs release cycling vs working-set repetition)
3362    // in the typed error text.
3363    let mut count_blocking_add = 0usize;
3364    let mut count_stationary_add = 0usize;
3365    let mut count_release = 0usize;
3366    let mut ws_repeat_break = false;
3367    let mut iterations_used = 0usize;
3368    // After an UNBLOCKED full step the iterate is the working-face minimizer
3369    // up to KKT-solve rounding, so the next iteration's direction is solver
3370    // noise, not progress. Measuring that noise against the absolute
3371    // `tol_step` starves the stationary branch (where releases and the
3372    // terminal acceptance live) on large-scale problems: the CTN cycle-95
3373    // witness spent 3146 of 3152 iterations re-solving noise steps with only
3374    // 6 transitions. An unblocked full step must be followed by multiplier
3375    // adjudication.
3376    let mut face_minimized = false;
3377
3378    for iteration in 0..max_iterations {
3379        iterations_used = iteration + 1;
3380        let adjudicate_face = face_minimized;
3381        face_minimized = false;
3382        let compressed_working = ops.compress_working(&active)?;
3383        let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
3384        for r in 0..compressed_working.constraints.a.nrows() {
3385            residualw[r] = compressed_working.constraints.b[r]
3386                - compressed_working.constraints.a.row(r).dot(&x);
3387        }
3388        let (d, lambdaw) = solve_kkt_direction(
3389            hessian,
3390            &g_cur,
3391            &compressed_working.constraints.a,
3392            Some(&residualw),
3393        )?;
3394        let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
3395        if step_norm <= tol_step || adjudicate_face {
3396            let (worst, worst_row) = ops.max_violation(&values_x);
3397            if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
3398                active.push(worst_row);
3399                is_active[worst_row] = true;
3400                count_stationary_add += 1;
3401                log_active_set_transition(
3402                    "stationary-infeasible-add",
3403                    iteration,
3404                    active.len(),
3405                    Some(worst_row),
3406                );
3407                if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
3408                    ws_repeat_break = true;
3409                    break;
3410                }
3411                continue;
3412            }
3413            if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3414                // The worst-violating row is already active. If it is not
3415                // ENFORCED by the compressed face it was rank-reduced out as
3416                // linearly dependent on the representatives — an over-complete
3417                // face (#2378): three coupled-block normals bind but only two
3418                // are independent, so the third is neither re-addable (already
3419                // active) nor releasable (not a representative) and the loop
3420                // dead-ends. Adjudicate it by an active-set EXCHANGE: release the
3421                // representative it is most aligned with so it becomes an
3422                // independent representative and binds next iteration.
3423                let worst_pos = active.iter().position(|&idx| idx == worst_row);
3424                let enforced =
3425                    worst_pos.is_some_and(|pos| compressed_working.position_enforced(pos));
3426                if !enforced {
3427                    let violated_unit = ops.gather_unit_rows(&[worst_row])?;
3428                    if let Some(mut group) = compressed_working
3429                        .over_complete_release_group(violated_unit.a.row(0), &active)
3430                    {
3431                        group.sort_unstable_by(|a, b| b.cmp(a));
3432                        let mut released = None;
3433                        for active_pos in group {
3434                            let idx = active.remove(active_pos);
3435                            is_active[idx] = false;
3436                            count_release += 1;
3437                            released = Some(idx);
3438                        }
3439                        log_active_set_transition(
3440                            "release-over-complete-face",
3441                            iteration,
3442                            active.len(),
3443                            released,
3444                        );
3445                        if !record_active_working_set(
3446                            &mut visited_working_sets,
3447                            &active,
3448                            &x,
3449                            iteration,
3450                        ) {
3451                            ws_repeat_break = true;
3452                            break;
3453                        }
3454                        continue;
3455                    }
3456                }
3457                break;
3458            }
3459            if compressed_working.groups.is_empty() {
3460                direction_out.assign(&d_total);
3461                return Ok(());
3462            }
3463            let remove_group =
3464                compressed_working.negative_representative_group(&lambdaw, tol_dual, &active);
3465            if let Some(mut group) = remove_group {
3466                // Release the whole independent direction (representative +
3467                // exactly-parallel dependents). Descending removal so each
3468                // `active.remove` does not shift a not-yet-removed position.
3469                group.sort_unstable_by(|a, b| b.cmp(a));
3470                let mut released = None;
3471                for active_pos in group {
3472                    let idx = active.remove(active_pos);
3473                    is_active[idx] = false;
3474                    count_release += 1;
3475                    released = Some(idx);
3476                }
3477                log_active_set_transition(
3478                    "release-negative-representative",
3479                    iteration,
3480                    active.len(),
3481                    released,
3482                );
3483                if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
3484                    ws_repeat_break = true;
3485                    break;
3486                }
3487                continue;
3488            }
3489            if let Some(hint) = active_hint.as_mut() {
3490                hint.clear();
3491                let compressed = ops.compress_working(&active)?;
3492                for group in &compressed.groups {
3493                    if let Some(&active_pos) = group.first() {
3494                        hint.push(active[active_pos]);
3495                    }
3496                }
3497            }
3498            direction_out.assign(&d_total);
3499            return Ok(());
3500        }
3501
3502        let values_d = ops.values(&d)?;
3503        let mut alpha = 1.0_f64;
3504        let mut blocking_row: Option<usize> = None;
3505        for row in 0..m {
3506            if is_active[row] || ops.norms[row] <= 0.0 {
3507                continue;
3508            }
3509            let slack = ops.scaled_slack(&values_x, row);
3510            let rate = values_d[row] / ops.norms[row];
3511            if let Some(cand) = active_set_boundary_hit_step_fraction(slack, rate, alpha) {
3512                alpha = cand;
3513                blocking_row = Some(row);
3514            }
3515        }
3516
3517        ndarray::Zip::from(&mut d_total)
3518            .and(&d)
3519            .for_each(|dt_i, &d_i| {
3520                *dt_i += alpha * d_i;
3521            });
3522        // Same bitwise-identity requirement as the dense loop: the caller
3523        // certifies `beta + d_total`, so evaluate feasibility on exactly that
3524        // sum (#979 CTN cycle 86: boundary-landing iterate feasible in the
3525        // loop's arithmetic, 1.000e-8 > TOL in the wrapper's).
3526        x = beta + &d_total;
3527        g_cur = gradient + &hessian.dot(&d_total);
3528        values_x = ops.values(&x)?;
3529
3530        let mut added_new_active = false;
3531        let mut working_set_repeated = false;
3532        if let Some(row) = blocking_row {
3533            active.push(row);
3534            is_active[row] = true;
3535            added_new_active = true;
3536            count_blocking_add += 1;
3537            log_active_set_transition("blocking-add", iteration, active.len(), Some(row));
3538            working_set_repeated =
3539                !record_active_working_set(&mut visited_working_sets, &active, &x, iteration);
3540        } else {
3541            // Unblocked full step: the iterate is now the minimizer of the
3542            // current working face — the next iteration must adjudicate
3543            // multipliers instead of re-measuring KKT-solve noise.
3544            face_minimized = true;
3545        }
3546        if working_set_repeated {
3547            ws_repeat_break = true;
3548            break;
3549        }
3550
3551        // A zero-length blocking step changes only the row representation of
3552        // this active face; the primal point and its quadratic gradient have not
3553        // moved. A factored cone can have thousands of observation rows carrying
3554        // the same low-dimensional normal geometry, so continuing the add/drop
3555        // loop enumerates distinct row-ID combinations without primal progress.
3556        // The issue-979 CTN witness held a 92/93-row face and performed hundreds
3557        // of these swaps immediately; its row-count-derived ceiling permitted
3558        // roughly 96,000.
3559        //
3560        // This event is exactly a normal-cone identification problem at the
3561        // current x. Ask the shared factored separator for the tangent-cone
3562        // projection now. It adds only geometrically necessary omitted rows and
3563        // returns a full-set-feasible strict descent direction, or declines and
3564        // leaves the ordinary active-set loop in control. The trigger is a
3565        // mathematical no-progress event, not an iteration or wall-clock budget.
3566        let primal_step_norm = alpha.abs() * step_norm;
3567        if allow_projected_gradient_fallback && added_new_active && primal_step_norm <= tol_step {
3568            if let Some((fallback_direction, fallback_active)) =
3569                fallback_projected_gradient_direction_with_constraint_set(
3570                    beta, &x, &d_total, &g_cur, &active, ops,
3571                )?
3572            {
3573                if let Some(hint) = active_hint.as_mut() {
3574                    hint.clear();
3575                    hint.extend(fallback_active);
3576                }
3577                direction_out.assign(&fallback_direction);
3578                return Ok(());
3579            }
3580        }
3581
3582        if active.is_empty() && !added_new_active {
3583            if let Some(hint) = active_hint.as_mut() {
3584                hint.clear();
3585            }
3586            direction_out.assign(&d_total);
3587            return Ok(());
3588        }
3589    }
3590
3591    // Exit gate: same acceptance structure as the dense loop — primal
3592    // feasibility on the full set plus working-set KKT residuals on the
3593    // rank-reduced unit-row system.
3594    let compressed_working = ops.compress_working(&active)?;
3595    let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
3596    for r in 0..compressed_working.constraints.a.nrows() {
3597        residualw[r] =
3598            compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
3599    }
3600    let (_, lambdaw) = solve_kkt_direction(
3601        hessian,
3602        &g_cur,
3603        &compressed_working.constraints.a,
3604        Some(&residualw),
3605    )?;
3606    let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
3607    let (worst, row) = ops.max_violation(&values_x);
3608    let working_kkt = working_set_kkt_diagnostics_from_multipliers(
3609        &x,
3610        &g_cur,
3611        &compressed_working.constraints,
3612        &lambda_true,
3613        m,
3614    )?;
3615    let grad_inf = gradient_inf_norm(&g_cur);
3616    let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
3617    let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3618    let hd_total = hessian.dot(&d_total);
3619    let predicted_delta = gradient.dot(&d_total)
3620        + 0.5
3621            * d_total
3622                .iter()
3623                .zip(hd_total.iter())
3624                .map(|(a, b)| a * b)
3625                .sum::<f64>();
3626    let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
3627        || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
3628        && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
3629    let model_descent_ok =
3630        predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
3631    let degenerate_boundary_ok = compressed_working.is_degenerate_face()
3632        && worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3633        && working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3634        && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
3635        && (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
3636            || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
3637    // Existence-form KKT certificate over the tight rows — the operator twin
3638    // of the dense loop's `nnls_certified` gate (see there for the full
3639    // rationale, including why it must run whenever the strong path does not
3640    // ACCEPT — strong stationarity with phantom negative duals is the target
3641    // case, not an exemption). Tightness is read off the batched values; only
3642    // the tight rows are gathered densely, so the factored cone never
3643    // materializes.
3644    let strong_path_accepts =
3645        kkt_strong_ok && working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
3646    let mut nnls_closure: Option<(f64, usize)> = None;
3647    let nnls_certified = worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !strong_path_accepts && {
3648        let tight: Vec<usize> = (0..m)
3649            .filter(|&i| {
3650                ops.norms[i] > 0.0 && (values_x[i] - ops.bounds[i]) / ops.norms[i] <= tol_active
3651            })
3652            .collect();
3653        let tight_len = tight.len();
3654        match ops.set.gather_rows(&tight) {
3655            Ok(gathered) => nonnegative_cone_multipliers(&gathered.a, &g_cur)
3656                .map(|(_, projected)| {
3657                    let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3658                    nnls_closure = Some((closure, tight_len));
3659                    closure <= ACTIVE_SET_KKT_STATIONARITY_TOL
3660                        || closure / grad_inf.max(1.0) <= ACTIVE_SET_KKT_STATIONARITY_TOL
3661                })
3662                .unwrap_or(false),
3663            Err(_) => false,
3664        }
3665    };
3666    if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3667        && ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
3668            && (kkt_strong_ok || (allow_projected_gradient_fallback && model_descent_ok)))
3669            || degenerate_boundary_ok
3670            || nnls_certified)
3671    {
3672        if let Some(hint) = active_hint.as_mut() {
3673            hint.clear();
3674            for group in &compressed_working.groups {
3675                if let Some(&active_pos) = group.first() {
3676                    hint.push(active[active_pos]);
3677                }
3678            }
3679        }
3680        direction_out.assign(&d_total);
3681        return Ok(());
3682    }
3683    let nnls_diag = match nnls_closure {
3684        Some((closure, tight_len)) => format!(
3685            "nnls_closure={closure:.3e} (tol={ACTIVE_SET_KKT_STATIONARITY_TOL:.1e}) over {tight_len} tight rows"
3686        ),
3687        None => "nnls_closure=not-evaluated".to_string(),
3688    };
3689    let churn_diag = format!(
3690        "iterations={iterations_used}/{max_iterations} transitions[blocking-add={count_blocking_add} stationary-add={count_stationary_add} release={count_release}] ws_repeat_break={ws_repeat_break}"
3691    );
3692    if !allow_projected_gradient_fallback {
3693        return Err(EstimationError::ParameterConstraintViolation(format!(
3694            "operator-constrained active-set did not certify the strict-convex projection QP; max scaled violation={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]; {nnls_diag}; {churn_diag}",
3695            working_kkt.primal_feasibility,
3696            working_kkt.dual_feasibility,
3697            working_kkt.complementarity,
3698            working_kkt.stationarity,
3699            working_kkt.n_active,
3700            working_kkt.n_constraints,
3701        )));
3702    }
3703    if let Some((fallback_direction, fallback_active)) =
3704        fallback_projected_gradient_direction_with_constraint_set(
3705            beta, &x, &d_total, &g_cur, &active, ops,
3706        )?
3707    {
3708        if let Some(hint) = active_hint.as_mut() {
3709            hint.clear();
3710            hint.extend(fallback_active);
3711        }
3712        direction_out.assign(&fallback_direction);
3713        return Ok(());
3714    }
3715    Err(EstimationError::ParameterConstraintViolation(format!(
3716        "operator-constrained Newton active-set failed to converge; max scaled violation={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]; {nnls_diag}; {churn_diag}; projected-gradient fallback declined",
3717        working_kkt.primal_feasibility,
3718        working_kkt.dual_feasibility,
3719        working_kkt.complementarity,
3720        working_kkt.stationarity,
3721        working_kkt.n_active,
3722        working_kkt.n_constraints,
3723    )))
3724}
3725
3726/// Strictly-interior projection onto a [`ConstraintSet`]: the operator
3727/// analogue of [`project_point_strictly_into_feasible_cone`]. Dense sets
3728/// delegate to the dense projection (including its anti-parallel equality
3729/// lift); the factored cone is homogeneous and one-sided, so the projection
3730/// is a single identity-Hessian QP against the margin-shifted rows.
3731///
3732/// A refusal is a typed [`EstimationError::ParameterConstraintViolation`]
3733/// naming the failing condition (dimension mismatch, non-finite iterate, or
3734/// the specific row whose half-margin the projection could not clear), never a
3735/// bare `None`: the caller decides whether that refusal is fatal or a soft
3736/// fallback, but it is never a silent one.
3737pub fn project_point_strictly_into_feasible_constraint_set(
3738    point: &Array1<f64>,
3739    set: &ConstraintSet,
3740) -> Result<Array1<f64>, EstimationError> {
3741    match set {
3742        ConstraintSet::Dense(dense) => {
3743            // The dense arm keeps its `Option` contract (it has other callers);
3744            // its refusal is retyped here so this seam carries a diagnostic
3745            // rather than a bare `None`.
3746            project_point_strictly_into_feasible_cone(point, dense).ok_or_else(|| {
3747                EstimationError::ParameterConstraintViolation(
3748                    "dense strict-interior projection could not certify a feasible point"
3749                        .to_string(),
3750                )
3751            })
3752        }
3753        _ => {
3754            let repair_guard = FeasibilityRepairGuard::enter().ok_or_else(|| {
3755                EstimationError::ParameterConstraintViolation(format!(
3756                    "strict-interior projection exceeded feasibility-repair depth {MAX_FEASIBILITY_REPAIR_DEPTH}"
3757                ))
3758            })?;
3759            let p = point.len();
3760            if set.ncols() != p {
3761                return Err(EstimationError::ParameterConstraintViolation(format!(
3762                    "strict-interior projection dimension mismatch: point length {p} != constraint columns {}",
3763                    set.ncols()
3764                )));
3765            }
3766            let ops = ConstraintSetOps::new(set, ACTIVE_SET_INTERIOR_SEED_MARGIN)?;
3767            let identity = Array2::<f64>::eye(p);
3768            // min ½‖β − point‖² ⇒ Hessian = I, gradient at `point` = 0;
3769            // the margin-shifted rows carry the strict-interior shift.
3770            let mut direction = Array1::<f64>::zeros(p);
3771            let gradient = Array1::<f64>::zeros(p);
3772            let max_iterations = (p + set.nrows() + 8) * 4;
3773            solve_newton_direction_with_constraint_set_impl(
3774                &identity,
3775                &gradient,
3776                point,
3777                &ops,
3778                &mut direction,
3779                None,
3780                max_iterations,
3781                true,
3782            )?;
3783            let beta = point + &direction;
3784            if beta.iter().any(|v| !v.is_finite()) {
3785                return Err(EstimationError::ParameterConstraintViolation(
3786                    "strict-interior projection produced a non-finite iterate".to_string(),
3787                ));
3788            }
3789            // Certify against the ORIGINAL (unshifted) rows with half-margin
3790            // clearance, mirroring the dense projection's exit contract.
3791            const SEED_FEASIBILITY_TOL: f64 = 1e-9;
3792            let unshifted = ConstraintSetOps::new(set, 0.0)?;
3793            let values = unshifted.values(&beta)?;
3794            let half_margin = 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - SEED_FEASIBILITY_TOL;
3795            for row in 0..unshifted.nrows() {
3796                if unshifted.norms[row] <= 0.0 {
3797                    continue;
3798                }
3799                let slack = unshifted.scaled_slack(&values, row);
3800                if slack < half_margin {
3801                    return Err(EstimationError::ParameterConstraintViolation(format!(
3802                        "strict-interior projection could not clear the half-margin at row {row}: \
3803                         scaled slack {slack:.3e} < {half_margin:.3e}"
3804                    )));
3805                }
3806            }
3807            drop(repair_guard);
3808            Ok(beta)
3809        }
3810    }
3811}
3812
3813/// Operator-carrier constrained quadratic solve: minimize
3814/// `½ βᵀHβ − rhsᵀβ` subject to the [`ConstraintSet`]. Dense sets take the
3815/// existing dense path byte-identically; the factored cone runs the operator
3816/// active-set loop. Same public feasibility contract as
3817/// [`solve_quadratic_with_linear_constraints`]: the returned point is
3818/// feasible to [`ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`] or the solve errors.
3819pub fn solve_quadratic_with_constraint_set(
3820    hessian: &Array2<f64>,
3821    rhs: &Array1<f64>,
3822    beta_start: &Array1<f64>,
3823    set: &ConstraintSet,
3824    warm_active_set: Option<&[usize]>,
3825) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3826    match set {
3827        ConstraintSet::Dense(dense) => solve_quadratic_with_linear_constraints(
3828            hessian,
3829            rhs,
3830            beta_start,
3831            dense,
3832            warm_active_set,
3833        ),
3834        _ => {
3835            if hessian.ncols() != hessian.nrows()
3836                || rhs.len() != hessian.nrows()
3837                || beta_start.len() != hessian.nrows()
3838                || set.ncols() != hessian.nrows()
3839            {
3840                crate::bail_invalid_estim!(
3841                    "operator-constrained quadratic solve: system dimension mismatch"
3842                );
3843            }
3844            let ops = ConstraintSetOps::new(set, 0.0)?;
3845            let gradient = hessian.dot(beta_start) - rhs;
3846            let mut delta = Array1::<f64>::zeros(beta_start.len());
3847            let mut active_hint = warm_active_set.map_or_else(Vec::new, |active| active.to_vec());
3848            let max_iterations = (beta_start.len() + set.nrows() + 8) * 4;
3849            solve_newton_direction_with_constraint_set_impl(
3850                hessian,
3851                &gradient,
3852                beta_start,
3853                &ops,
3854                &mut delta,
3855                Some(&mut active_hint),
3856                max_iterations,
3857                true,
3858            )?;
3859            let candidate = beta_start + &delta;
3860            let candidate_values = ops.values(&candidate)?;
3861            let (worst, _) = ops.max_violation(&candidate_values);
3862            if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3863                return Ok((candidate, active_hint));
3864            }
3865            let repaired = project_point_strictly_into_feasible_constraint_set(&candidate, set)
3866                .ok()
3867                .filter(|repaired_point| {
3868                    ops.values(repaired_point)
3869                        .map(|values| ops.max_violation(&values).0)
3870                        .map(|violation| violation <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL)
3871                        .unwrap_or(false)
3872                });
3873            match repaired {
3874                Some(feasible) => {
3875                    let feasible_values = ops.values(&feasible)?;
3876                    let active: Vec<usize> = (0..ops.nrows())
3877                        .filter(|&row| {
3878                            ops.norms[row] > 0.0
3879                                && ops.scaled_slack(&feasible_values, row)
3880                                    <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3881                        })
3882                        .collect();
3883                    Ok((feasible, active))
3884                }
3885                None => Err(EstimationError::ParameterConstraintViolation(format!(
3886                    "operator-constrained quadratic solve returned an infeasible iterate \
3887                     (max scaled violation {worst:.3e}) and no feasible projection could be \
3888                     certified onto the constraint cone",
3889                ))),
3890            }
3891        }
3892    }
3893}
3894
3895pub(crate) fn solve_newton_direction_with_linear_constraints(
3896    hessian: &Array2<f64>,
3897    gradient: &Array1<f64>,
3898    beta: &Array1<f64>,
3899    constraints: &LinearInequalityConstraints,
3900    direction_out: &mut Array1<f64>,
3901    active_hint: Option<&mut Vec<usize>>,
3902) -> Result<(), EstimationError> {
3903    let max_iterations = (gradient.len() + constraints.a.nrows() + 8) * 4;
3904    solve_newton_direction_with_linear_constraints_impl(
3905        hessian,
3906        gradient,
3907        beta,
3908        constraints,
3909        direction_out,
3910        active_hint,
3911        max_iterations,
3912        true,
3913    )
3914}
3915
3916pub fn solve_quadratic_with_linear_constraints(
3917    hessian: &Array2<f64>,
3918    rhs: &Array1<f64>,
3919    beta_start: &Array1<f64>,
3920    constraints: &LinearInequalityConstraints,
3921    warm_active_set: Option<&[usize]>,
3922) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3923    if hessian.ncols() != hessian.nrows()
3924        || rhs.len() != hessian.nrows()
3925        || beta_start.len() != hessian.nrows()
3926        || constraints.a.ncols() != hessian.nrows()
3927    {
3928        crate::bail_invalid_estim!("constrained quadratic solve: system dimension mismatch");
3929    }
3930    // Canonicalize at the chokepoint: reject non-finite / infeasible-zero rows
3931    // and unit-normalize every row, so all downstream slack, activation, and
3932    // rank tolerances are geometric (scale-free) regardless of the units the
3933    // caller expressed the constraints in. Row order is preserved, so
3934    // `warm_active_set` indices and the returned active ids stay valid.
3935    let constraints = constraints.canonicalized().map_err(|e| {
3936        EstimationError::ParameterConstraintViolation(format!(
3937            "constrained quadratic solve: invalid constraint system: {e}"
3938        ))
3939    })?;
3940    let constraints = &constraints;
3941    let gradient = hessian.dot(beta_start) - rhs;
3942    let mut delta = Array1::<f64>::zeros(beta_start.len());
3943    let mut active_hint = warm_active_set.map_or_else(Vec::new, |active| active.to_vec());
3944    solve_newton_direction_with_linear_constraints(
3945        hessian,
3946        &gradient,
3947        beta_start,
3948        constraints,
3949        &mut delta,
3950        Some(&mut active_hint),
3951    )?;
3952    let candidate = beta_start + &delta;
3953    // FINAL FEASIBILITY CONTRACT (#1108). The active-set inner step computes its
3954    // Newton direction only against the ACTIVE working rows and line-searches
3955    // `alpha` against the inactive rows; a row whose approach rate falls inside
3956    // `boundary_hit_step_fraction`'s directional tolerance is not clipped, so the
3957    // returned `candidate` can overshoot a currently-inactive row and land
3958    // outside the cone by a small-but-gate-failing amount (the interval-censored
3959    // survival surrogate leaked 5.5e-3..2.2e-2 raw at cycles 3/9 here, accepted
3960    // into `states`, then rejected by the next cycle's `check_linear_feasibility`
3961    // — `infeasible iterate`). The solver's PUBLIC CONTRACT is a feasible point;
3962    // enforce it at the single chokepoint every caller flows through. A genuinely
3963    // converged feasible solve has `worst ~ 0`, so this is a no-op there and does
3964    // not perturb a well-conditioned constrained fit. When the step did leak,
3965    // project the iterate onto the feasible cone (the exact projection the #1108
3966    // diag proves reaches ~0 violation: the nearest strictly-interior point) and
3967    // return THAT. If no feasible repair is achievable, surface the active-set
3968    // error rather than returning an infeasible point.
3969    let (worst, _) = max_linear_constraint_violation(&candidate, constraints);
3970    if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3971        return Ok((candidate, active_hint));
3972    }
3973    let repaired = project_point_strictly_into_feasible_cone(&candidate, constraints).filter(|p| {
3974        max_linear_constraint_violation(p, constraints).0 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3975    });
3976    match repaired {
3977        Some(feasible) => {
3978            let active = canonicalize_active_constraint_ids(&feasible, constraints, &[])?;
3979            Ok((feasible, active))
3980        }
3981        None => Err(EstimationError::ParameterConstraintViolation(format!(
3982            "constrained quadratic solve returned an infeasible iterate \
3983             (max scaled violation {worst:.3e}) and no feasible projection could be \
3984             certified onto the constraint cone",
3985        ))),
3986    }
3987}
3988
3989#[cfg(test)]
3990mod tests {
3991    use super::{
3992        ACTIVE_SET_INTERIOR_SEED_MARGIN, ACTIVE_SET_PRIMAL_FEASIBILITY_TOL, ConstraintSet,
3993        ConstraintRowId, ConstraintSetOps, ConstraintSetReducedFace, LinearInequalityConstraints,
3994        active_set_boundary_hit_step_fraction, compute_constraint_kkt_diagnostics,
3995        constraint_set_rows_tight_at_point, fallback_projected_gradient_direction,
3996        khatri_rao_cone_reduced_face,
3997        fallback_projected_gradient_direction_with_constraint_set, moreau_projection_via_primal_qp,
3998        nnls_tangent_cone_projection_fallback, nonnegative_cone_multipliers,
3999        project_point_strictly_into_feasible_cone,
4000        project_point_strictly_into_feasible_constraint_set,
4001        project_stationarity_residual_on_constraint_cone,
4002        project_stationarity_residual_on_constraint_set,
4003        rank_reduce_rows_pivoted_qr_with_dependence, record_active_working_set,
4004        scaled_constraint_slack, solve_newton_direction_with_linear_constraints_impl,
4005        solve_quadratic_with_constraint_set, solve_quadratic_with_linear_constraints,
4006    };
4007    use approx::assert_relative_eq;
4008    use gam_problem::KhatriRaoConeConstraints;
4009    use ndarray::{Array1, Array2, array};
4010
4011    #[test]
4012    fn working_set_cycle_detection_requires_the_same_primal_point() {
4013        let mut visited = std::collections::HashSet::new();
4014        let x0 = array![0.0_f64, 1.0];
4015        let x1 = array![0.5_f64, 1.0];
4016
4017        assert!(record_active_working_set(&mut visited, &[3, 1], &x0, 0));
4018        assert!(record_active_working_set(&mut visited, &[1, 3], &x1, 1));
4019        assert!(!record_active_working_set(&mut visited, &[3, 1], &x1, 2));
4020    }
4021
4022    #[test]
4023    fn boundary_ratio_lands_on_the_exact_boundary_and_blocks_at_it() {
4024        // Strictly feasible row: the clipped step lands ON the boundary, not
4025        // TOL past it — the exit gate then re-derives the same zero slack
4026        // instead of coin-flipping on band-edge rounding.
4027        let alpha = active_set_boundary_hit_step_fraction(0.1, -1.0, 1.0)
4028            .expect("a strictly feasible row moving toward its boundary must clip");
4029        assert_relative_eq!(alpha, 0.1, epsilon = 0.0);
4030        assert_relative_eq!(0.1 + alpha * -1.0, 0.0, epsilon = 0.0);
4031
4032        // A row at (or a rounding hair past) its boundary and moving outward
4033        // clips the step to zero: it becomes blocking, and the escape is
4034        // adjudicated structurally (projected-gradient tangent fallback plus
4035        // post-full-step multiplier adjudication) rather than by overshooting
4036        // into the certified tolerance band.
4037        let blocked = active_set_boundary_hit_step_fraction(-2.5e-15, -1.0, 1.0)
4038            .expect("an at-boundary outward-moving row must block");
4039        assert_eq!(blocked, 0.0);
4040    }
4041
4042    #[test]
4043    fn warm_face_rows_are_point_local_for_dense_and_operator_constraints() {
4044        // The previous QP endpoint was x=0, where x>=0 binds, but a trust
4045        // step accepted only an interior point x=1. Reusing row 0 as an
4046        // equality at x=1 would solve the wrong problem and drive x back to
4047        // the stale boundary. The actual quadratic has its feasible minimizer
4048        // at x=2, so both carriers must discard the slack warm row and return
4049        // the unconstrained interior minimizer with an empty face.
4050        let hessian = array![[1.0_f64]];
4051        let rhs = array![2.0_f64];
4052        let interior = array![1.0_f64];
4053        let dense = LinearInequalityConstraints::new(array![[1.0]], array![0.0])
4054            .expect("one-dimensional half-line");
4055        let (dense_solution, dense_active) =
4056            solve_quadratic_with_linear_constraints(&hessian, &rhs, &interior, &dense, Some(&[0]))
4057                .expect("dense stale-face solve");
4058        assert_relative_eq!(dense_solution[0], 2.0, epsilon = 1e-12);
4059        assert!(dense_active.is_empty());
4060
4061        let factor = std::sync::Arc::new(array![[1.0_f64]]);
4062        let cone = KhatriRaoConeConstraints::new(factor, vec![0], 1)
4063            .expect("one-dimensional factored half-line");
4064        let operator = ConstraintSet::KhatriRaoCone(cone);
4065        let stale_terminal_face = constraint_set_rows_tight_at_point(&operator, &interior, &[0])
4066            .expect("terminal face classification");
4067        assert!(stale_terminal_face.is_empty());
4068        let (operator_solution, operator_active) =
4069            solve_quadratic_with_constraint_set(&hessian, &rhs, &interior, &operator, Some(&[0]))
4070                .expect("operator stale-face solve");
4071        assert_relative_eq!(operator_solution[0], 2.0, epsilon = 1e-12);
4072        assert!(operator_active.is_empty());
4073    }
4074
4075    /// A `β = 0` seed sits on the boundary of EVERY row of a homogeneous
4076    /// (`b = 0`) convex/concave second-difference cone — it is the cone vertex.
4077    /// The strict-interior projection must move it to a point with a strictly
4078    /// positive scaled slack on every row, so the inner active-set QP starts
4079    /// from an EMPTY working set rather than an all-rows-active degenerate face
4080    /// (the #873 cache-dependence root cause). The zero seed is the worst case:
4081    /// the nearest interior point is unique up to the margin, and a buggy
4082    /// "min-norm" feasibility fallback would return `0` again.
4083    #[test]
4084    fn strict_interior_projection_lifts_vertex_seed_off_every_constraint_row() {
4085        // Signed second-difference rows of a 5-coefficient concave smooth:
4086        // -(β_{i+2} - 2β_{i+1} + β_i) ≥ 0 for i = 0..3.
4087        let p = 5usize;
4088        let rows = p - 2;
4089        let mut a = Array2::<f64>::zeros((rows, p));
4090        for i in 0..rows {
4091            a[[i, i]] = -1.0;
4092            a[[i, i + 1]] = 2.0;
4093            a[[i, i + 2]] = -1.0;
4094        }
4095        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4096            .expect("test constraint shape invariant");
4097
4098        let vertex = Array1::<f64>::zeros(p);
4099        // The vertex is feasible (all rows exactly tight) but on every boundary.
4100        for i in 0..rows {
4101            assert!(
4102                scaled_constraint_slack(&vertex, &constraints, i).abs() < 1e-12,
4103                "vertex seed should sit exactly on row {i}"
4104            );
4105        }
4106
4107        let interior = project_point_strictly_into_feasible_cone(&vertex, &constraints)
4108            .expect("strict-interior projection of the vertex must succeed");
4109        let min_slack = (0..rows)
4110            .map(|i| scaled_constraint_slack(&interior, &constraints, i))
4111            .fold(f64::INFINITY, f64::min);
4112        assert!(
4113            min_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4114            "projected seed must be strictly interior on every row; min scaled slack = {min_slack:.3e}"
4115        );
4116    }
4117
4118    /// Mirrors `s(x, shape=concave, bc=clamped)`: shape curvature reparameterized
4119    /// to independent coordinate lower bounds `γ_j ≥ 0` (genuine one-sided rows),
4120    /// MERGED with a boundary condition encoded as an anti-parallel inequality
4121    /// PAIR `{r·β ≥ t, −r·β ≥ −t}` (an equality `r·β = t`). A naive
4122    /// shift-every-row-inward projection turns that pair into the empty set
4123    /// `t+δ ≤ r·β ≤ t−δ`, fails, and the caller falls back to the cone vertex —
4124    /// silently reintroducing the #873 seed for the combined case. The
4125    /// anti-parallel-aware margin must leave the equality pair tight while still
4126    /// pushing the genuine shape rows strictly interior.
4127    #[test]
4128    fn strict_interior_projection_keeps_equality_pairs_tight_with_shape_bounds() {
4129        let p = 5usize;
4130        // Rows 0..3: shape lower bounds γ_2,γ_3,γ_4 ≥ 0 (homogeneous, b = 0).
4131        // Rows 3,4: endpoint equality β_0 = 0 as {e_0·β ≥ 0, −e_0·β ≥ 0}.
4132        let m = 3 + 2;
4133        let mut a = Array2::<f64>::zeros((m, p));
4134        a[[0, 2]] = 1.0;
4135        a[[1, 3]] = 1.0;
4136        a[[2, 4]] = 1.0;
4137        a[[3, 0]] = 1.0;
4138        a[[4, 0]] = -1.0;
4139        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(m))
4140            .expect("test constraint shape invariant");
4141
4142        // A seed that violates the shape bounds (negative curvature coords) and
4143        // the equality (β_0 ≠ 0).
4144        let point = Array1::from_vec(vec![0.7, -0.2, -0.5, -0.3, -0.1]);
4145        let seed = project_point_strictly_into_feasible_cone(&point, &constraints).expect(
4146            "strict-interior projection must succeed when an equality pair is present, \
4147             not collapse to the empty set and fall back to the vertex",
4148        );
4149
4150        // Genuine one-sided shape rows are pushed strictly interior.
4151        for i in 0..3 {
4152            assert!(
4153                scaled_constraint_slack(&seed, &constraints, i)
4154                    >= 0.4 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4155                "shape row {i} not strictly interior: scaled slack = {:.3e}",
4156                scaled_constraint_slack(&seed, &constraints, i)
4157            );
4158        }
4159        // The equality pair stays tight (β_0 ≈ 0), i.e. the seed is projected
4160        // onto the boundary hyperplane rather than shifted off it.
4161        assert!(
4162            seed[0].abs() <= 1e-6,
4163            "boundary equality must be enforced, got β_0 = {:.3e}",
4164            seed[0]
4165        );
4166    }
4167
4168    /// A seed that already carries genuine (concave) curvature and clears the
4169    /// interior margin is returned essentially unchanged — the projection only
4170    /// nudges boundary/violating seeds, it does not discard usable curvature.
4171    #[test]
4172    fn strict_interior_projection_preserves_a_curvature_carrying_seed() {
4173        let p = 5usize;
4174        let rows = p - 2;
4175        let mut a = Array2::<f64>::zeros((rows, p));
4176        for i in 0..rows {
4177            a[[i, i]] = -1.0;
4178            a[[i, i + 1]] = 2.0;
4179            a[[i, i + 2]] = -1.0;
4180        }
4181        let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4182            .expect("test constraint shape invariant");
4183        // A strictly concave coefficient profile (-(j-2)^2): every second
4184        // difference is -(-2) = +2 > 0 after the concave sign flip, well above
4185        // the interior margin.
4186        let seed = Array1::from_iter((0..p).map(|j| -((j as f64 - 2.0).powi(2))));
4187        let projected = project_point_strictly_into_feasible_cone(&seed, &constraints)
4188            .expect("already-interior seed must project");
4189        let max_move = seed
4190            .iter()
4191            .zip(projected.iter())
4192            .map(|(a, b)| (a - b).abs())
4193            .fold(0.0_f64, f64::max);
4194        assert!(
4195            max_move < 1e-3,
4196            "strictly-interior curvature-carrying seed should be preserved; max move = {max_move:.3e}"
4197        );
4198    }
4199
4200    #[test]
4201    fn maxiter_accepts_current_boundary_solution() {
4202        let hessian = array![[1.0]];
4203        let gradient = array![-1.0];
4204        let beta = array![0.0];
4205        let constraints = LinearInequalityConstraints {
4206            a: array![[-1.0]],
4207            b: array![-0.1],
4208        };
4209        let mut direction = Array1::zeros(1);
4210        let mut active_hint = Vec::new();
4211
4212        solve_newton_direction_with_linear_constraints_impl(
4213            &hessian,
4214            &gradient,
4215            &beta,
4216            &constraints,
4217            &mut direction,
4218            Some(&mut active_hint),
4219            1,
4220            true,
4221        )
4222        .expect("solver should accept the current boundary solution at the iteration limit");
4223
4224        assert_relative_eq!(direction[0], 0.1, epsilon = 1e-12);
4225        assert_eq!(active_hint, vec![0]);
4226    }
4227
4228    #[test]
4229    fn projected_gradient_releases_a_boundary_with_negative_multiplier() {
4230        // At x=0 under x>=0, gradient=-1 points toward increasing x: the
4231        // boundary multiplier from an equality-only KKT projection is negative
4232        // and the correct feasible descent direction leaves the face. The old
4233        // equality-tangent projection erased this direction and could falsely
4234        // call the wrong active face stationary.
4235        let x = array![0.0_f64];
4236        let d_total = array![0.0_f64];
4237        let gradient = array![-1.0_f64];
4238        let constraints =
4239            LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("one-sided bound");
4240
4241        let (direction, active) = fallback_projected_gradient_direction(
4242            &x,
4243            &x,
4244            &d_total,
4245            &gradient,
4246            &constraints,
4247            &constraints,
4248        )
4249        .expect("fallback evaluation")
4250        .expect("negative-multiplier face must have a feasible descent escape");
4251
4252        assert_relative_eq!(direction[0], 1.0, epsilon = 1e-12);
4253        assert!(gradient.dot(&direction) < 0.0);
4254        assert!(active.is_empty(), "descent moves strictly into the cone");
4255    }
4256
4257    #[test]
4258    fn rank_reduce_zero_rows_returns_empty_working_set() {
4259        let a = array![[0.0, 0.0], [0.0, 0.0],];
4260        let b = array![0.0, 0.0];
4261        let groups = vec![vec![0], vec![1]];
4262
4263        let (a_out, b_out, groups_out, _) =
4264            rank_reduce_rows_pivoted_qr_with_dependence(a, b, groups);
4265
4266        assert_eq!(a_out.nrows(), 0);
4267        assert_eq!(a_out.ncols(), 2);
4268        assert_eq!(b_out.len(), 0);
4269        assert!(groups_out.is_empty());
4270    }
4271
4272    #[test]
4273    fn cone_projection_solves_nonnegative_least_squares_not_one_way_pruning() {
4274        let active_a = array![
4275            [0.85258593, -0.77270261],
4276            [-1.22152485, 2.05129351],
4277            [0.22794844, 1.56987265],
4278        ];
4279        let residual = array![-0.50524761, -1.10104911];
4280
4281        let (projected, multipliers) =
4282            project_stationarity_residual_on_constraint_cone(&residual, &active_a)
4283                .expect("cone projection should solve");
4284
4285        let row0 = active_a.row(0);
4286        let expected_mu0 = row0.dot(&residual) / row0.dot(&row0);
4287        assert_relative_eq!(multipliers[0], expected_mu0, epsilon = 1e-8);
4288        assert_relative_eq!(multipliers[1], 0.0, epsilon = 1e-10);
4289        assert_relative_eq!(multipliers[2], 0.0, epsilon = 1e-10);
4290
4291        let raw_norm2 = residual.dot(&residual);
4292        let projected_norm2 = projected.dot(&projected);
4293        assert!(
4294            projected_norm2 < raw_norm2 - 0.1,
4295            "NNLS projection should keep the improving active row: raw={raw_norm2:.6e}, projected={projected_norm2:.6e}"
4296        );
4297        let dual = active_a.dot(&projected);
4298        for (idx, (&mu, &w)) in multipliers.iter().zip(dual.iter()).enumerate() {
4299            if mu <= 1e-10 {
4300                assert!(
4301                    w <= 1e-8,
4302                    "inactive cone generator {idx} has positive reduced gradient {w:.3e}"
4303                );
4304            }
4305        }
4306    }
4307
4308    /// The direct Lawson–Hanson route must agree with the primal-QP Moreau
4309    /// projection wherever the latter succeeds: both compute the projection
4310    /// of `residual` onto the polar of the generated cone.
4311    #[test]
4312    fn nnls_moreau_projection_matches_primal_qp_route() {
4313        let cases: Vec<(Array2<f64>, Array1<f64>)> = vec![
4314            (
4315                array![
4316                    [0.85258593, -0.77270261],
4317                    [-1.22152485, 2.05129351],
4318                    [0.22794844, 1.56987265],
4319                ],
4320                array![-0.50524761, -1.10104911],
4321            ),
4322            (array![[1.0, 0.0], [0.0, 1.0]], array![3.0, -2.0]),
4323            (
4324                array![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [2.0, 2.0, 0.0]],
4325                array![1.5, 0.25, -0.75],
4326            ),
4327        ];
4328        for (rows, target) in cases {
4329            let qp = moreau_projection_via_primal_qp(&target, &rows)
4330                .expect("primal QP route must solve these well-posed instances");
4331            let (lambda, projected) = nonnegative_cone_multipliers(&rows, &target)
4332                .expect("LH route must solve the same instances");
4333            for (left, right) in qp.0.iter().zip(projected.iter()) {
4334                assert_relative_eq!(left, right, epsilon = 1e-8);
4335            }
4336            // λ ≥ 0 and exact reconstruction by construction.
4337            assert!(lambda.iter().all(|&v| v >= 0.0));
4338            let reconstructed = &target - &rows.t().dot(&lambda);
4339            for (left, right) in reconstructed.iter().zip(projected.iter()) {
4340                assert_relative_eq!(left, right, epsilon = 1e-12);
4341            }
4342        }
4343    }
4344
4345    #[test]
4346    fn nnls_projects_axis_cone_exactly() {
4347        let rows = array![[1.0, 0.0], [0.0, 1.0]];
4348        let target = array![3.0, -2.0];
4349        let (lambda, projected) =
4350            nonnegative_cone_multipliers(&rows, &target).expect("axis cone NNLS");
4351        assert_relative_eq!(lambda[0], 3.0, epsilon = 1e-10);
4352        assert_relative_eq!(lambda[1], 0.0, epsilon = 1e-10);
4353        assert_relative_eq!(projected[0], 0.0, epsilon = 1e-10);
4354        assert_relative_eq!(projected[1], -2.0, epsilon = 1e-10);
4355    }
4356
4357    /// A dependent active row that is weakly aligned with every kept row
4358    /// individually (`a3 = (a1 + a2)/(2ε)`, pairwise alignment ≈ ε) breaks the
4359    /// single-target multiplier attribution: `λ/coeff` explodes by `1/ε` and
4360    /// manufactures phantom huge duals. The existence-form certificate sees the
4361    /// exact nonnegative closure `g = 1·a3` and must certify stationarity.
4362    #[test]
4363    fn nnls_closes_stationarity_on_weakly_aligned_dependent_face() {
4364        let eps = 1e-8_f64;
4365        let rows = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4366        let target = array![0.0, 1.0];
4367        let (lambda, projected) =
4368            nonnegative_cone_multipliers(&rows, &target).expect("dependent-face NNLS");
4369        let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4370        assert!(
4371            closure <= 1e-10,
4372            "λ = e3 closes stationarity exactly; got closure {closure:.3e}"
4373        );
4374        assert!(lambda.iter().all(|&v| v >= 0.0));
4375    }
4376
4377    /// End-to-end: the constrained Newton solve on the same weakly-aligned
4378    /// degenerate face must certify the vertex instead of chasing phantom
4379    /// negative duals into a working-set cycle and refusing (#2298 survival
4380    /// monotonicity faces, #979 CTN faces).
4381    #[test]
4382    fn degenerate_face_with_weak_alignment_certifies_instead_of_cycling() {
4383        let eps = 1e-8_f64;
4384        let a = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4385        let b = array![0.0, 0.0, 0.0];
4386        let constraints = LinearInequalityConstraints::new(a.clone(), b).expect("constraints");
4387        let hessian = Array2::<f64>::eye(2);
4388        // KKT at d* = 0 with λ = e3 ≥ 0: gradient = A^T e3 = a3.
4389        let gradient = array![0.0, 1.0];
4390        let beta = array![0.0, 0.0];
4391        let mut direction = Array1::<f64>::zeros(2);
4392        solve_newton_direction_with_linear_constraints_impl(
4393            &hessian,
4394            &gradient,
4395            &beta,
4396            &constraints,
4397            &mut direction,
4398            None,
4399            64,
4400            false,
4401        )
4402        .expect("the vertex is a certified KKT point; refusal is the #2298 defect");
4403        let step = direction.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4404        assert!(
4405            step <= 1e-8,
4406            "optimum is the vertex itself; got |d|∞ = {step:.3e}"
4407        );
4408    }
4409
4410    /// #979 CTN plateau regression: when the operator-path primal projection
4411    /// QP refuses a degenerate fully-pinned vertex (its single-face multiplier
4412    /// attribution reports a phantom negative dual), the Lawson-Hanson Moreau
4413    /// fallback must certify the projection instead of surrendering to the
4414    /// unprojected residual — the measured 1.447e3 eternal plateau was exactly
4415    /// `residual` returned raw because this fallback did not exist.
4416    #[test]
4417    fn nnls_fallback_certifies_pinned_degenerate_vertex_projection_979() {
4418        // Four generators in R^3 (degenerate: a4 = a1 + a2), all tight at the
4419        // origin. The stationarity residual is a nonnegative combination, so
4420        // the projected residual is exactly zero.
4421        let a = array![
4422            [1.0_f64, 0.0, 0.0],
4423            [0.0, 1.0, 0.0],
4424            [0.0, 0.0, 1.0],
4425            [1.0, 1.0, 0.0],
4426        ];
4427        let b = array![0.0_f64, 0.0, 0.0, 0.0];
4428        let set = ConstraintSet::Dense(
4429            LinearInequalityConstraints::new(a, b).expect("degenerate vertex cone"),
4430        );
4431        let beta = array![0.0_f64, 0.0, 0.0];
4432        let residual = array![3.0_f64, 2.0, 0.0]; // = a1 + 2·a4
4433        let (projected, active) =
4434            nnls_tangent_cone_projection_fallback(&residual, &beta, &set, &[0, 1, 2, 3], &[0, 1])
4435                .expect("fallback must solve the degenerate vertex");
4436        let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4437        assert!(
4438            closure <= 1e-9,
4439            "residual is in the cone; projection must close to zero, got {closure:.3e}"
4440        );
4441        assert!(!active.is_empty(), "a supported face must be reported");
4442
4443        // A component outside the cone must survive the projection exactly.
4444        let outside = array![1.0_f64, 0.0, -1.0];
4445        let (projected_outside, _) =
4446            nnls_tangent_cone_projection_fallback(&outside, &beta, &set, &[0, 1, 2, 3], &[])
4447                .expect("fallback must solve the outside-component case");
4448        assert_relative_eq!(projected_outside[0], 0.0, epsilon = 1e-9);
4449        assert_relative_eq!(projected_outside[1], 0.0, epsilon = 1e-9);
4450        assert_relative_eq!(projected_outside[2], -1.0, epsilon = 1e-9);
4451    }
4452
4453    /// The fallback is a KKT certificate input, so a working-set row that is
4454    /// NOT tight at `beta` must never enter the generator set: a residual
4455    /// aligned with a slack row must stay unprojected rather than be absorbed
4456    /// by a constraint that is not active at the iterate.
4457    #[test]
4458    fn nnls_fallback_excludes_rows_not_tight_at_beta() {
4459        let a = array![[1.0_f64, 0.0], [0.0, 1.0]];
4460        let b = array![0.0_f64, -1.0]; // row 1 has slack 1 at the origin
4461        let set = ConstraintSet::Dense(
4462            LinearInequalityConstraints::new(a, b).expect("half-tight system"),
4463        );
4464        let beta = array![0.0_f64, 0.0];
4465        let residual = array![0.0_f64, 1.0];
4466        let (projected, active) =
4467            nnls_tangent_cone_projection_fallback(&residual, &beta, &set, &[0, 1], &[])
4468                .expect("fallback must solve the half-tight system");
4469        assert_relative_eq!(projected[1], 1.0, epsilon = 1e-12);
4470        assert!(
4471            !active.contains(&1),
4472            "slack row 1 must not appear in the certified face"
4473        );
4474    }
4475
4476    #[test]
4477    fn cone_projection_preserves_original_multiplier_units_after_row_canonicalization() {
4478        let residual = array![2.0, -1.0];
4479        let unit_row = array![[1.0, 0.0]];
4480        let scaled_row = array![[4.0, 0.0]];
4481
4482        let (projected_unit, multiplier_unit) =
4483            project_stationarity_residual_on_constraint_cone(&residual, &unit_row)
4484                .expect("unit-row cone projection should solve");
4485        let (projected_scaled, multiplier_scaled) =
4486            project_stationarity_residual_on_constraint_cone(&residual, &scaled_row)
4487                .expect("scaled-row cone projection should solve");
4488
4489        assert_relative_eq!(projected_unit[0], 0.0, epsilon = 1e-12);
4490        assert_relative_eq!(projected_unit[1], -1.0, epsilon = 1e-12);
4491        assert_relative_eq!(projected_scaled[0], projected_unit[0], epsilon = 1e-12);
4492        assert_relative_eq!(projected_scaled[1], projected_unit[1], epsilon = 1e-12);
4493        assert_relative_eq!(multiplier_unit[0], 2.0, epsilon = 1e-12);
4494        assert_relative_eq!(multiplier_scaled[0], 0.5, epsilon = 1e-12);
4495
4496        let reconstructed_unit = &residual - &unit_row.t().dot(&multiplier_unit);
4497        let reconstructed_scaled = &residual - &scaled_row.t().dot(&multiplier_scaled);
4498        assert_relative_eq!(reconstructed_unit[0], projected_unit[0], epsilon = 1e-12);
4499        assert_relative_eq!(
4500            reconstructed_scaled[0],
4501            projected_scaled[0],
4502            epsilon = 1e-12
4503        );
4504    }
4505
4506    // #500: the KKT primal residual must be the *geometric* distance to the
4507    // constraint hyperplane — invariant to how the constraint row is scaled.
4508    // A B-spline endpoint-derivative clamp carries a large row norm, so the
4509    // raw slack `a·β − b` of a near-feasible iterate is inflated by ‖a‖ and a
4510    // downstream raw primal gate would spuriously refuse it. The same geometry
4511    // expressed with a unit-norm row must yield the same primal.
4512    #[test]
4513    fn kkt_primal_is_per_row_scale_invariant() {
4514        // β sits 2.071e-8 on the infeasible side of the hyperplane `row·β ≥ 0`
4515        // (the exact geometric residual reported in #500's startup abort).
4516        let geometric_violation = 2.071e-8_f64;
4517        let gradient = Array1::<f64>::zeros(2);
4518
4519        // Unit-norm row: raw slack == geometric distance.
4520        let beta_unit = array![-geometric_violation, 0.0];
4521        let unit = LinearInequalityConstraints {
4522            a: array![[1.0, 0.0]],
4523            b: array![0.0],
4524        };
4525        let diag_unit = compute_constraint_kkt_diagnostics(&beta_unit, &gradient, &unit);
4526
4527        // Same hyperplane, row scaled ×1000: raw slack would be 2.071e-5, but
4528        // the *scaled* primal must still equal the geometric distance.
4529        let beta_big = array![-geometric_violation, 0.0];
4530        let big = LinearInequalityConstraints {
4531            a: array![[1000.0, 0.0]],
4532            b: array![0.0],
4533        };
4534        let diag_big = compute_constraint_kkt_diagnostics(&beta_big, &gradient, &big);
4535
4536        assert_relative_eq!(
4537            diag_unit.primal_feasibility,
4538            geometric_violation,
4539            epsilon = 1e-14
4540        );
4541        assert_relative_eq!(
4542            diag_big.primal_feasibility,
4543            geometric_violation,
4544            epsilon = 1e-14
4545        );
4546        // The scaled diagnostic must NOT report the ‖a‖-inflated raw slack.
4547        assert!(
4548            diag_big.primal_feasibility < 1e-7,
4549            "scaled primal {:.3e} should pass a 1e-7 gate; raw slack would be {:.3e}",
4550            diag_big.primal_feasibility,
4551            1000.0 * geometric_violation
4552        );
4553    }
4554
4555    // A B-spline `bc=clamped`/`bc=anchored` constraint is an EQUALITY
4556    // `a·β = b` encoded as two opposing inequalities `a·β ≥ b` and
4557    // `−a·β ≥ −b`. The active-set solver must drive the unconstrained
4558    // optimum back onto the hyperplane `a·β = b`. This is the isolated
4559    // analogue of the `bc=clamped` startup-validation abort: the exact
4560    // validation solve left `a·β ≈ 7.76` instead of 0, so the KKT primal
4561    // residual blew past tolerance and every seed was refused.
4562    #[test]
4563    fn opposing_inequality_pair_pins_equality_to_target() {
4564        // Minimize ½‖β‖² − rhs·β  (H = I) ⇒ unconstrained optimum β* = rhs.
4565        // rhs = [5,5,0,0] ⇒ a·β* = 10 with a = [1,1,0,0].
4566        // The opposing pair must pull a·β back to the target 0.
4567        let hessian = array![
4568            [1.0, 0.0, 0.0, 0.0],
4569            [0.0, 1.0, 0.0, 0.0],
4570            [0.0, 0.0, 1.0, 0.0],
4571            [0.0, 0.0, 0.0, 1.0],
4572        ];
4573        let rhs = array![5.0, 5.0, 0.0, 0.0];
4574        let beta_start = Array1::<f64>::zeros(4);
4575        let constraints = LinearInequalityConstraints {
4576            a: array![[1.0, 1.0, 0.0, 0.0], [-1.0, -1.0, 0.0, 0.0]],
4577            b: array![0.0, 0.0],
4578        };
4579
4580        let (beta, _active) = solve_quadratic_with_linear_constraints(
4581            &hessian,
4582            &rhs,
4583            &beta_start,
4584            &constraints,
4585            None,
4586        )
4587        .expect("opposing-inequality equality QP must solve");
4588
4589        let a_dot_beta = beta[0] + beta[1];
4590        assert!(
4591            a_dot_beta.abs() < 1e-8,
4592            "opposing inequalities must pin a·β to 0, got {a_dot_beta:.6e} (β = {beta:?})"
4593        );
4594    }
4595
4596    // Same as above but with a non-zero target and a large row norm — the
4597    // exact shape of a B-spline endpoint-derivative clamp, whose rows carry
4598    // ‖a‖ ≫ 1. The equality must still be pinned in geometric coordinates.
4599    #[test]
4600    fn opposing_inequality_pair_pins_scaled_equality_to_nonzero_target() {
4601        let hessian = array![
4602            [1.0, 0.0, 0.0, 0.0],
4603            [0.0, 1.0, 0.0, 0.0],
4604            [0.0, 0.0, 1.0, 0.0],
4605            [0.0, 0.0, 0.0, 1.0],
4606        ];
4607        let rhs = array![5.0, 5.0, 0.0, 0.0];
4608        let beta_start = Array1::<f64>::zeros(4);
4609        // Row scaled ×1000 (mimics a derivative-clamp row norm) with target 3000
4610        // ⇒ geometric target a·β = 3.0 in unit coordinates.
4611        let constraints = LinearInequalityConstraints {
4612            a: array![[1000.0, 1000.0, 0.0, 0.0], [-1000.0, -1000.0, 0.0, 0.0]],
4613            b: array![3000.0, -3000.0],
4614        };
4615
4616        let (beta, _active) = solve_quadratic_with_linear_constraints(
4617            &hessian,
4618            &rhs,
4619            &beta_start,
4620            &constraints,
4621            None,
4622        )
4623        .expect("scaled opposing-inequality equality QP must solve");
4624
4625        let a_dot_beta = 1000.0 * (beta[0] + beta[1]);
4626        assert!(
4627            (a_dot_beta - 3000.0).abs() < 1e-5,
4628            "opposing inequalities must pin a·β to 3000, got {a_dot_beta:.6e} (β = {beta:?})"
4629        );
4630    }
4631
4632    // `bc=clamped` at BOTH ends produces TWO opposing-inequality equalities
4633    // (4 rows total). The real abort reports `active=2/4` — only ONE of the
4634    // two equalities is being pinned. Reproduce two independent equalities
4635    // and require BOTH to be driven to their targets.
4636    #[test]
4637    fn two_opposing_inequality_equalities_both_pinned() {
4638        let hessian = array![
4639            [1.0, 0.0, 0.0, 0.0],
4640            [0.0, 1.0, 0.0, 0.0],
4641            [0.0, 0.0, 1.0, 0.0],
4642            [0.0, 0.0, 0.0, 1.0],
4643        ];
4644        let rhs = array![5.0, 5.0, 5.0, 5.0];
4645        let beta_start = Array1::<f64>::zeros(4);
4646        // Equality A: β0 + β1 = 0 (rows 0,1). Equality B: β2 + β3 = 0 (rows 2,3).
4647        let constraints = LinearInequalityConstraints {
4648            a: array![
4649                [1.0, 1.0, 0.0, 0.0],
4650                [-1.0, -1.0, 0.0, 0.0],
4651                [0.0, 0.0, 1.0, 1.0],
4652                [0.0, 0.0, -1.0, -1.0],
4653            ],
4654            b: array![0.0, 0.0, 0.0, 0.0],
4655        };
4656
4657        let (beta, _active) = solve_quadratic_with_linear_constraints(
4658            &hessian,
4659            &rhs,
4660            &beta_start,
4661            &constraints,
4662            None,
4663        )
4664        .expect("two-equality QP must solve");
4665
4666        assert!(
4667            (beta[0] + beta[1]).abs() < 1e-8,
4668            "equality A not pinned: β0+β1 = {:.6e}",
4669            beta[0] + beta[1]
4670        );
4671        assert!(
4672            (beta[2] + beta[3]).abs() < 1e-8,
4673            "equality B not pinned: β2+β3 = {:.6e}",
4674            beta[2] + beta[3]
4675        );
4676    }
4677
4678    // Faithful to the failing fit: the penalized IRLS Hessian `X'WX + λS`
4679    // with λ at the over-smoothing ceiling is severely ill-conditioned — the
4680    // penalty `S` is rank-deficient (null space = the unpenalized polynomial
4681    // part), so directions in null(S) are governed by a tiny `X'WX` block
4682    // while penalized directions carry a huge λ. The opposing-inequality
4683    // equalities must STILL be pinned under this conditioning.
4684    #[test]
4685    fn opposing_inequality_equalities_pinned_under_ill_conditioned_penalty() {
4686        // H = diag(1, 1, λ, λ) with λ = 1e8 — penalized directions 2,3 are
4687        // ~1e8 stiffer than the data directions 0,1.
4688        let lam = 1.0e8_f64;
4689        let hessian = array![
4690            [1.0, 0.0, 0.0, 0.0],
4691            [0.0, 1.0, 0.0, 0.0],
4692            [0.0, 0.0, lam, 0.0],
4693            [0.0, 0.0, 0.0, lam],
4694        ];
4695        let rhs = array![5.0, 5.0, 5.0, 5.0];
4696        let beta_start = Array1::<f64>::zeros(4);
4697        // Two equalities that COUPLE a stiff and a soft coordinate, like a
4698        // B-spline derivative row spanning penalized and unpenalized parts:
4699        // A: β0 + β2 = 0, B: β1 + β3 = 0.
4700        let constraints = LinearInequalityConstraints {
4701            a: array![
4702                [1.0, 0.0, 1.0, 0.0],
4703                [-1.0, 0.0, -1.0, 0.0],
4704                [0.0, 1.0, 0.0, 1.0],
4705                [0.0, -1.0, 0.0, -1.0],
4706            ],
4707            b: array![0.0, 0.0, 0.0, 0.0],
4708        };
4709
4710        let (beta, _active) = solve_quadratic_with_linear_constraints(
4711            &hessian,
4712            &rhs,
4713            &beta_start,
4714            &constraints,
4715            None,
4716        )
4717        .expect("ill-conditioned two-equality QP must solve");
4718
4719        assert!(
4720            (beta[0] + beta[2]).abs() < 1e-6,
4721            "equality A not pinned under ill-conditioning: β0+β2 = {:.6e}",
4722            beta[0] + beta[2]
4723        );
4724        assert!(
4725            (beta[1] + beta[3]).abs() < 1e-6,
4726            "equality B not pinned under ill-conditioning: β1+β3 = {:.6e}",
4727            beta[1] + beta[3]
4728        );
4729    }
4730
4731    // ==== gam#2306: operator (ConstraintSet) solver vs dense oracle ====
4732
4733    /// Small Khatri-Rao cone whose dense materialization is exact: Ψ is
4734    /// 4 × 2, coefficient block is 3 × 2 (row 0 unconstrained location,
4735    /// rows 1–2 coupled), so p = 6 and the cone has 8 rows.
4736    fn small_cone() -> KhatriRaoConeConstraints {
4737        let psi = array![[1.0_f64, 0.2], [1.0, -0.4], [1.0, 1.3], [1.0, 0.8],];
4738        KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3).expect("small cone")
4739    }
4740
4741    /// Parallel tight rows collapse to the lowest-index representative, and the
4742    /// duplicate is recorded in the dependence map with its scalar ratio.
4743    #[test]
4744    fn cone_reduced_face_collapses_parallel_rows_to_lowest_index() {
4745        // ψ_2 = 2·ψ_0 (parallel); ψ_1 independent. p_cov=2, one coupled row.
4746        let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
4747        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4748            .expect("parallel cone");
4749        // β = 0 ⇒ every Γ = 0 ⇒ every row tight.
4750        let beta = Array1::<f64>::zeros(2 * 2);
4751        let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4752        assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
4753        // Rank 2: reps are the two independent directions at their lowest obs.
4754        assert_eq!(face.representatives, rows(&[0, 1]));
4755        assert_eq!(face.dependence.len(), 2);
4756        // ψ_2 (flat id 2) is parallel to representative ψ_0 (rep index 0), coeff 2.
4757        assert_eq!(face.dependence[0].len(), 1);
4758        assert_eq!(face.dependence[0][0].row.index(), 2);
4759        assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
4760        assert!(face.dependence[1].is_empty());
4761    }
4762
4763    /// A full-rank tight face keeps every row and records no dependence.
4764    #[test]
4765    fn cone_reduced_face_full_rank_has_no_dependence() {
4766        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
4767        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4768            .expect("full-rank cone");
4769        let beta = Array1::<f64>::zeros(2 * 2);
4770        let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4771        assert_eq!(face.representatives, rows(&[0, 1]));
4772        assert!(face.dependence.iter().all(|d| d.is_empty()));
4773        assert_eq!(face.tight_rows, rows(&[0, 1]));
4774    }
4775
4776    /// A general-position dependent (in the span but parallel to no single rep)
4777    /// is dropped from the working set (full rank cut) but gets NO dependence
4778    /// entry — the (A)-strict contract that avoids a phantom distributed dual.
4779    #[test]
4780    fn cone_reduced_face_general_combination_gets_no_dependence_entry() {
4781        // ψ_2 = ψ_0 + ψ_1: in the span, but cos with each rep is 1/√2 < 1.
4782        let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [1.0, 1.0]];
4783        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4784            .expect("general-combo cone");
4785        let beta = Array1::<f64>::zeros(2 * 2);
4786        let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4787        assert_eq!(face.representatives, rows(&[0, 1])); // ψ_2 dropped
4788        assert_eq!(face.tight_rows, rows(&[0, 1, 2])); // but still in the tight set
4789        assert!(
4790            face.dependence.iter().all(|d| d.is_empty()),
4791            "a general-position drop must carry no distributed multiplier"
4792        );
4793    }
4794
4795    /// Cross-block cone rows are automatically orthogonal (e_k ⊥ e_{k'}), so each
4796    /// shape block reduces independently — no cross-block dependence, and flat
4797    /// ids stay in the slot*n+obs space.
4798    #[test]
4799    fn cone_reduced_face_reduces_each_shape_block_independently() {
4800        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
4801        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3)
4802            .expect("two-block cone");
4803        let beta = Array1::<f64>::zeros(3 * 2);
4804        let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4805        // Block 0 → flat 0,1; block 1 → flat 2,3 (slot*n+obs, n=2). All independent.
4806        assert_eq!(face.representatives, rows(&[0, 1, 2, 3]));
4807        assert!(face.dependence.iter().all(|d| d.is_empty()));
4808        assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
4809    }
4810
4811    /// The Dense arm of the `ConstraintSet::reduced_face` dispatcher matches the
4812    /// cone arm's contract: parallel tight rows collapse to the lowest-index
4813    /// representative with the scalar ratio recorded; flat id = the row index.
4814    #[test]
4815    fn dense_reduced_face_via_dispatcher_collapses_parallel_rows() {
4816        // Row 2 = 2·row 0 (parallel); row 1 independent. b = 0 ⇒ every row tight
4817        // at β = 0 (scaled slack 0).
4818        let a = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
4819        let set = ConstraintSet::Dense(
4820            LinearInequalityConstraints::new(a, Array1::<f64>::zeros(3)).expect("dense"),
4821        );
4822        let beta = Array1::<f64>::zeros(2);
4823        let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
4824        assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
4825        assert_eq!(face.representatives, rows(&[0, 1]));
4826        assert_eq!(face.dependence[0].len(), 1);
4827        assert_eq!(face.dependence[0][0].row.index(), 2);
4828        assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
4829        assert!(face.dependence[1].is_empty());
4830    }
4831
4832    /// Constraint-row ids for the `ReducedFace` assertions below.
4833    fn rows(ids: &[usize]) -> Vec<ConstraintRowId> {
4834        ids.iter().copied().map(ConstraintRowId).collect()
4835    }
4836
4837    /// A block-diagonal set whose FIRST member constrains fewer rows than it has
4838    /// coefficients — one `β₀ ≥ 0` row over a 3-wide block whose remaining two
4839    /// coordinates are unconstrained (intercept / covariate columns) — followed
4840    /// by a square 2×2 member at `col_start = 3`. This is the configuration that
4841    /// separates the constraint-row offset (`nrows`: 1) from the coefficient
4842    /// offset (`col_start`: 3); every pre-existing multi-block test used square
4843    /// members, where the two coincide and nothing can be distinguished.
4844    fn mixed_width_block_diagonal() -> ConstraintSet {
4845        let narrow = gam_problem::PlacedConstraintBlock {
4846            col_start: 0,
4847            set: ConstraintSet::Dense(
4848                LinearInequalityConstraints::new(
4849                    array![[1.0_f64, 0.0, 0.0]],
4850                    Array1::<f64>::zeros(1),
4851                )
4852                .expect("narrow block"),
4853            ),
4854        };
4855        let square = gam_problem::PlacedConstraintBlock {
4856            col_start: 3,
4857            set: ConstraintSet::Dense(
4858                LinearInequalityConstraints::new(
4859                    array![[1.0_f64, 0.0], [2.0, 0.0]],
4860                    Array1::<f64>::zeros(2),
4861                )
4862                .expect("square block"),
4863            ),
4864        };
4865        ConstraintSet::block_diagonal(vec![narrow, square], 5).expect("block-diagonal")
4866    }
4867
4868    /// Every id a mixed-width block-diagonal reduced face emits addresses the
4869    /// JOINT CONSTRAINT-ROW space: it indexes `values()` and resolves through
4870    /// `bound()` / `row_norm()` to the member row it came from, and the tight
4871    /// rows really are tight there. This pins the id space that #2368 questioned
4872    /// — the running-`nrows()` shift is the one consistent with the rest of the
4873    /// `ConstraintSet` row API (`values` layout, `block_for_row` decoding).
4874    #[test]
4875    fn block_diagonal_reduced_face_row_ids_address_the_joint_constraint_row_space() {
4876        let set = mixed_width_block_diagonal();
4877        let beta = Array1::<f64>::zeros(5);
4878        let values = set.values(beta.view()).expect("values");
4879        let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
4880
4881        // Joint rows: block 0 contributes row 0; block 1 contributes rows 1, 2.
4882        assert_eq!(set.nrows(), 3);
4883        assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
4884        // Block 1's row 1 = 2·row 0, so it collapses onto joint representative 1.
4885        assert_eq!(face.representatives, rows(&[0, 1]));
4886        assert_eq!(face.dependence[1][0].row.index(), 2);
4887
4888        for id in &face.tight_rows {
4889            let row = id.index();
4890            assert!(row < set.nrows(), "id {row} outside the joint row space");
4891            let norm = set.row_norm(row).expect("row norm resolves");
4892            let bound = set.bound(row).expect("bound resolves");
4893            assert!(
4894                (values[row] - bound) / norm <= 1e-8,
4895                "row {row} reported tight but has slack {}",
4896                (values[row] - bound) / norm
4897            );
4898        }
4899    }
4900
4901    /// The same face, read as COEFFICIENT positions, is wrong — which is exactly
4902    /// why the ids are typed and why `row_column_support` exists.
4903    ///
4904    /// Block 1's representative is joint row 1, but it acts on β coordinate 3.
4905    /// Coordinate 1 is block 0's second column: an UNCONSTRAINED coefficient
4906    /// owned by a different block. A consumer that identified row ids with β
4907    /// positions (to build a free/pinned mask) would pin the wrong coordinate in
4908    /// the wrong block; the conversion recovers the right one.
4909    #[test]
4910    fn block_diagonal_reduced_face_row_ids_are_not_beta_coordinates() {
4911        let set = mixed_width_block_diagonal();
4912        let beta = Array1::<f64>::zeros(5);
4913        let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
4914
4915        let block1_rep = face.representatives[1];
4916        assert_eq!(block1_rep.index(), 1);
4917        assert_eq!(
4918            set.row_column_support(block1_rep).expect("support"),
4919            vec![3],
4920            "block 1's row acts on the joint column 3 (col_start 3 + local 0)"
4921        );
4922        // The naive identity map would have named coordinate 1, which lies in
4923        // block 0's column range [0, 3) — a different block entirely.
4924        assert!(block1_rep.index() < 3, "id 1 falls inside block 0's columns");
4925
4926        // Block 0's row is the one case where the two spaces agree; the
4927        // conversion must still be the thing that says so.
4928        assert_eq!(
4929            set.row_column_support(face.representatives[0])
4930                .expect("support"),
4931            vec![0]
4932        );
4933    }
4934
4935    /// The BlockDiagonal arm composes member reductions and concatenates their
4936    /// row ids in order (each member's flat ids shift by the running member row
4937    /// count), so a parallel dependent in the second block reports its global id.
4938    #[test]
4939    fn block_diagonal_reduced_face_concatenates_member_row_ids() {
4940        // Two Dense blocks over disjoint columns; each: row0 independent, row1 =
4941        // 2·row0. b = 0 ⇒ all tight. Block 1's rows shift by block 0's 2 rows.
4942        let make = |c0: usize| gam_problem::PlacedConstraintBlock {
4943            col_start: c0,
4944            set: ConstraintSet::Dense(
4945                LinearInequalityConstraints::new(
4946                    array![[1.0_f64, 0.0], [2.0, 0.0]],
4947                    Array1::<f64>::zeros(2),
4948                )
4949                .expect("dense block"),
4950            ),
4951        };
4952        let set = ConstraintSet::block_diagonal(vec![make(0), make(2)], 4).expect("block-diagonal");
4953        let beta = Array1::<f64>::zeros(4);
4954        let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
4955        assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
4956        assert_eq!(face.representatives, rows(&[0, 2]));
4957        assert_eq!(face.dependence[0][0].row.index(), 1);
4958        assert_eq!(face.dependence[1][0].row.index(), 3);
4959    }
4960
4961    /// Deterministic PD Hessian with off-diagonal coupling so active-set
4962    /// choices are not axis-trivial.
4963    fn coupled_pd_hessian(p: usize) -> Array2<f64> {
4964        let mut h = Array2::<f64>::eye(p) * 2.0;
4965        for i in 0..p {
4966            for j in 0..p {
4967                if i != j {
4968                    h[[i, j]] = 0.3 / (1.0 + (i as f64 - j as f64).abs());
4969                }
4970            }
4971        }
4972        h
4973    }
4974
4975    #[test]
4976    fn operator_cone_qp_matches_dense_oracle_when_constraints_bind() {
4977        let cone = small_cone();
4978        let set = ConstraintSet::KhatriRaoCone(cone.clone());
4979        let dense = cone.to_dense().expect("dense oracle");
4980        let p = set.ncols();
4981        let hessian = coupled_pd_hessian(p);
4982        // rhs pulls the coupled rows negative so the unconstrained optimum
4983        // violates the cone and several rows must bind.
4984        let rhs = array![0.5_f64, -0.3, -2.0, 1.0, -1.5, -0.7];
4985        // Feasible start: coupled coefficient rows give strictly positive
4986        // functionals under every Ψ row (constant 1 with small slope loads).
4987        let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
4988
4989        let (beta_op, mut active_op) =
4990            solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
4991                .expect("operator solve");
4992        let (beta_dense, mut active_dense) =
4993            solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
4994                .expect("dense solve");
4995
4996        for j in 0..p {
4997            assert!(
4998                (beta_op[j] - beta_dense[j]).abs() < 1e-7,
4999                "operator/dense coefficient {j} mismatch: {} vs {}",
5000                beta_op[j],
5001                beta_dense[j]
5002            );
5003        }
5004        // The binding face must agree GEOMETRICALLY: both carriers land on the
5005        // same point (asserted above), so every reported active row must be
5006        // tight there, and both must carry the same number of independent
5007        // rows. Exact row-id equality is too strong — the fixture's coupled
5008        // rows admit alternate representations of the same face, and which
5009        // redundant row a carrier keeps is a tie-break, not semantics.
5010        active_op.sort_unstable();
5011        active_dense.sort_unstable();
5012        let values_at_solution = set.values(beta_op.view()).expect("values at solution");
5013        let tight_at_solution: Vec<usize> = (0..set.nrows())
5014            .filter(|&row| {
5015                let norm = set.row_norm(row).expect("norm");
5016                norm > 0.0 && values_at_solution[row] / norm <= 1e-7
5017            })
5018            .collect();
5019        for &row in active_op.iter().chain(active_dense.iter()) {
5020            assert!(
5021                tight_at_solution.contains(&row),
5022                "reported active row {row} is not tight at the common solution \
5023                 (op face {active_op:?}, dense face {active_dense:?}, tight {tight_at_solution:?})"
5024            );
5025        }
5026        assert_eq!(
5027            active_op.len(),
5028            active_dense.len(),
5029            "carriers disagree on the face dimension: op {active_op:?} vs dense {active_dense:?}"
5030        );
5031        assert!(
5032            !active_op.is_empty(),
5033            "fixture must actually bind at least one cone row"
5034        );
5035        // And the operator answer must be feasible on the full cone.
5036        let values = set.values(beta_op.view()).expect("values");
5037        let (worst, _) = set.max_scaled_violation(beta_op.view()).expect("violation");
5038        assert!(worst <= 1e-8, "operator answer infeasible: {worst:.3e}");
5039        assert_eq!(values.len(), 8);
5040    }
5041
5042    #[test]
5043    fn separable_khatri_rao_tangent_projection_matches_dense_oracle() {
5044        let cone = small_cone();
5045        let set = ConstraintSet::KhatriRaoCone(cone.clone());
5046        let dense = cone.to_dense().expect("dense projection oracle");
5047        let beta = Array1::<f64>::zeros(set.ncols());
5048        let residual = array![0.4_f64, -0.2, 1.1, -0.7, -0.9, 0.8];
5049
5050        let (operator_projected, _) =
5051            project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5052                .expect("separable operator projection");
5053        let (dense_projected, _) =
5054            project_stationarity_residual_on_constraint_cone(&residual, &dense.a)
5055                .expect("dense cone projection");
5056
5057        for index in 0..residual.len() {
5058            assert_relative_eq!(
5059                operator_projected[index],
5060                dense_projected[index],
5061                epsilon = 1e-8
5062            );
5063        }
5064    }
5065
5066    #[test]
5067    fn operator_cone_qp_takes_unconstrained_path_when_interior() {
5068        let cone = small_cone();
5069        let set = ConstraintSet::KhatriRaoCone(cone);
5070        let p = set.ncols();
5071        let hessian = coupled_pd_hessian(p);
5072        // rhs pushing every coupled functional UP: unconstrained optimum is
5073        // strictly interior, so the operator path must equal the plain solve.
5074        let rhs = array![0.2_f64, 0.1, 3.0, 0.2, 2.5, 0.1];
5075        let beta_start = array![0.0_f64, 0.0, 1.0, 0.0, 1.0, 0.0];
5076        let (beta_op, active_op) =
5077            solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5078                .expect("operator solve");
5079        // Dense unconstrained oracle: H β = rhs.
5080        let mut beta_unconstrained = Array1::<f64>::zeros(p);
5081        super::solve_newton_direction_dense(
5082            &hessian,
5083            &(hessian.dot(&beta_start) - &rhs),
5084            &mut beta_unconstrained,
5085        )
5086        .expect("unconstrained newton");
5087        let beta_unconstrained = &beta_start + &beta_unconstrained;
5088        for j in 0..p {
5089            assert!(
5090                (beta_op[j] - beta_unconstrained[j]).abs() < 1e-8,
5091                "interior operator solve must match unconstrained optimum at {j}"
5092            );
5093        }
5094        assert!(
5095            active_op.is_empty(),
5096            "interior optimum must have empty face"
5097        );
5098    }
5099
5100    #[test]
5101    fn operator_projection_returns_strictly_interior_point() {
5102        let cone = small_cone();
5103        let set = ConstraintSet::KhatriRaoCone(cone);
5104        // Infeasible point: coupled row 1 loaded negative everywhere.
5105        let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5106        let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5107            .expect("projection must succeed on a one-sided homogeneous cone");
5108        let values = set.values(projected.view()).expect("values");
5109        for row in 0..set.nrows() {
5110            let norm = set.row_norm(row).expect("norm");
5111            if norm <= 0.0 {
5112                continue;
5113            }
5114            let slack = values[row] / norm;
5115            assert!(
5116                slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - 1e-9,
5117                "projected point not strictly interior on row {row}: slack {slack:.3e}"
5118            );
5119        }
5120        // The location coordinates (unconstrained) must be untouched by the
5121        // projection objective's optimum only if already optimal; at minimum
5122        // they must remain finite and close to the input (they carry no
5123        // constraint rows, and the identity-Hessian QP has no incentive to
5124        // move them).
5125        assert!((projected[0] - point[0]).abs() < 1e-8);
5126        assert!((projected[1] - point[1]).abs() < 1e-8);
5127    }
5128
5129    /// #2378 regression, independent oracle. The operator strict-interior
5130    /// projection onto an OVER-COMPLETE cone face (three of a 2-D block's four
5131    /// half-spaces try to bind — rank 2) must not merely land on *a* feasible
5132    /// point; it must be the correct Euclidean projection. The former loose
5133    /// rank-reduction truncated the true binding extreme (row 2) out of the
5134    /// enforced face and refused the fit; a regression in the over-complete-face
5135    /// exchange would release the wrong representative and land on a different
5136    /// feasible vertex. Both are caught by matching the dense oracle over the
5137    /// same materialized rows AND by pinning which pair binds ({1,2}, not {1,3}).
5138    #[test]
5139    fn operator_projection_adjudicates_the_over_complete_face_2378() {
5140        let cone = small_cone();
5141        let set = ConstraintSet::KhatriRaoCone(cone.clone());
5142        // The #2378 witness point: coupled block 1 = coords[2..4] = (-1, -0.5)
5143        // is over-complete; block 2 is left feasible.
5144        let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5145        let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5146            .expect("operator projection must certify the over-complete-face vertex");
5147
5148        // Ground-truth oracle: the SAME projection over the dense materialization
5149        // of the cone rows, through the independent dense arm.
5150        let dense = ConstraintSet::Dense(cone.to_dense().expect("dense oracle"));
5151        let dense_proj = project_point_strictly_into_feasible_constraint_set(&point, &dense)
5152            .expect("dense projection oracle");
5153        for j in 0..point.len() {
5154            assert!(
5155                (projected[j] - dense_proj[j]).abs() < 1e-7,
5156                "operator projection diverged from the dense oracle at {j}: \
5157                 op={:.9e} dense={:.9e}",
5158                projected[j],
5159                dense_proj[j]
5160            );
5161        }
5162
5163        // The correct binding pair is block-1 rows {1,2} (flat ids 1 and 2 in
5164        // slot 0). Row 2 — the extreme the old code truncated — must be TIGHT,
5165        // not violated. Rows are `slot*n + obs`, n = 4 Ψ rows, coupled slot 0.
5166        let values = set.values(projected.view()).expect("values");
5167        let scaled = |row: usize| values[row] / set.row_norm(row).expect("norm");
5168        // Rows 1 and 2 bind at (near) the strict-interior margin floor…
5169        for row in [1usize, 2] {
5170            assert!(
5171                scaled(row) < ACTIVE_SET_INTERIOR_SEED_MARGIN + 1e-7,
5172                "block-1 row {row} should bind, scaled slack {:.3e}",
5173                scaled(row)
5174            );
5175        }
5176        // …while rows 0 and 3 stay strictly slacker than the binding pair.
5177        for row in [0usize, 3] {
5178            assert!(
5179                scaled(row) > scaled(2) + 1e-9,
5180                "non-binding row {row} (slack {:.3e}) must exceed the binding \
5181                 row 2 (slack {:.3e})",
5182                scaled(row),
5183                scaled(2)
5184            );
5185        }
5186    }
5187
5188    /// #2378 regression at the QP level (non-identity Hessian): the operator
5189    /// active-set loop's over-complete-face exchange must reach the same
5190    /// constrained minimizer as the dense oracle when a coupled block is loaded
5191    /// so that three of its half-spaces contend at the optimum.
5192    #[test]
5193    fn operator_cone_qp_over_complete_face_matches_dense_oracle_2378() {
5194        let cone = small_cone();
5195        let set = ConstraintSet::KhatriRaoCone(cone.clone());
5196        let dense = cone.to_dense().expect("dense oracle");
5197        let p = set.ncols();
5198        let hessian = coupled_pd_hessian(p);
5199        // Drive block-1's unconstrained optimum deep into the infeasible corner
5200        // where the extreme Ψ rows 1 and 2 both contend (the over-complete face),
5201        // and pin block-2 with its own mild load.
5202        let rhs = array![0.3_f64, -0.1, -2.5, -1.2, -0.4, 0.2];
5203        let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
5204
5205        let (beta_op, _active_op) =
5206            solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5207                .expect("operator QP solve over an over-complete face");
5208        let (beta_dense, _active_dense) =
5209            solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
5210                .expect("dense QP oracle");
5211
5212        for j in 0..p {
5213            assert!(
5214                (beta_op[j] - beta_dense[j]).abs() < 1e-7,
5215                "operator/dense coefficient {j} mismatch: {} vs {}",
5216                beta_op[j],
5217                beta_dense[j]
5218            );
5219        }
5220        // The operator answer is feasible on the full factored cone.
5221        let values = set.values(beta_op.view()).expect("values");
5222        for row in 0..set.nrows() {
5223            let norm = set.row_norm(row).expect("norm");
5224            if norm > 0.0 {
5225                assert!(
5226                    values[row] / norm >= -ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5227                    "row {row} violated at the operator optimum: {:.3e}",
5228                    values[row] / norm
5229                );
5230            }
5231        }
5232    }
5233
5234    #[test]
5235    fn operator_cone_does_not_materialize_a_whole_tight_face() {
5236        // All 4,096 observation rows describe the same half-space.  At the
5237        // cone vertex every row is tight, but one warm row completely
5238        // describes the working face.  The operator solver must preserve that
5239        // compact working set instead of gathering/rank-reducing all 4,096
5240        // redundant rows before taking a step (the large-scale CTN cycle-2
5241        // stall from #979).
5242        let mut psi = Array2::<f64>::zeros((4096, 2));
5243        psi.column_mut(0).fill(1.0);
5244        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5245            .expect("repeated-row cone");
5246        let set = ConstraintSet::KhatriRaoCone(cone);
5247        let hessian = Array2::<f64>::eye(4);
5248        let rhs = array![0.3_f64, -0.2, -1.0, 0.0];
5249        let beta_start = Array1::<f64>::zeros(4);
5250
5251        let (beta, active) =
5252            solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, Some(&[0]))
5253                .expect("vertex solve");
5254
5255        assert_eq!(
5256            active,
5257            vec![0],
5258            "redundant tight rows entered the working set"
5259        );
5260        assert!(beta[2].abs() <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
5261        assert!((beta[0] - 0.3).abs() < 1e-10);
5262        assert!((beta[1] + 0.2).abs() < 1e-10);
5263    }
5264
5265    #[test]
5266    fn operator_cycle_escape_is_descending_feasible_and_sparse() {
5267        // A Khatri-Rao face can have several observation rows tight at the
5268        // same coefficient point even though only one row is needed to hold
5269        // the current tangent face. The dense solver already takes this
5270        // projected-gradient escape when tolerance-band add/drop transitions
5271        // revisit a working set; the operator solver must do the same without
5272        // expanding the returned hint to every currently-tight row.
5273        let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, 2.0]];
5274        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5275            .expect("cycle-escape cone");
5276        let set = ConstraintSet::KhatriRaoCone(cone);
5277        let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5278        let x = Array1::<f64>::zeros(4);
5279        let d_total = Array1::<f64>::zeros(4);
5280        // Row 0 pins the constant coefficient of the shaped response. The
5281        // remaining negative gradient points along its slope coefficient,
5282        // which lies in the face tangent and moves all other rows inward.
5283        let gradient = array![0.0_f64, 0.0, 0.0, -1.0];
5284        let (direction, active) = fallback_projected_gradient_direction_with_constraint_set(
5285            &x,
5286            &x,
5287            &d_total,
5288            &gradient,
5289            &[0],
5290            &ops,
5291        )
5292        .expect("operator fallback evaluation")
5293        .expect("a certified tangent descent direction must exist");
5294
5295        assert!(
5296            gradient.dot(&direction) < 0.0,
5297            "escape must be a strict descent direction"
5298        );
5299        let candidate = &x + &direction;
5300        let (worst, _) = set
5301            .max_scaled_violation(candidate.view())
5302            .expect("full-set feasibility");
5303        assert!(
5304            worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5305            "escape must remain feasible on every operator row: {worst:.3e}"
5306        );
5307        assert_eq!(
5308            active,
5309            vec![0],
5310            "operator escape expanded one sparse face row into all tight rows"
5311        );
5312    }
5313
5314    #[test]
5315    fn operator_tangent_projection_does_not_constrain_interior_rows() {
5316        let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
5317        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5318            .expect("interior tangent cone");
5319        let set = ConstraintSet::KhatriRaoCone(cone);
5320        // The shaped response row is strictly positive for every observation,
5321        // so its tangent cone is the complete coefficient space. A projection
5322        // against the original cone at the origin would incorrectly erase the
5323        // shaped constant component of this residual.
5324        let beta = array![0.0_f64, 0.0, 1.0, 0.0];
5325        let residual = array![0.0_f64, 0.0, 1.0, 0.0];
5326        let (projected, active) =
5327            project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5328                .expect("interior tangent projection");
5329
5330        for index in 0..residual.len() {
5331            assert_relative_eq!(projected[index], residual[index], epsilon = 1e-12);
5332        }
5333        assert!(active.is_empty(), "interior rows entered the tangent face");
5334    }
5335
5336    #[test]
5337    fn operator_tangent_projection_homogenizes_an_affine_boundary() {
5338        let set = ConstraintSet::Dense(
5339            LinearInequalityConstraints::new(array![[1.0_f64, 0.0]], array![2.0])
5340                .expect("affine half-space"),
5341        );
5342        let beta = array![2.0_f64, 0.0];
5343        let residual = array![1.0_f64, -1.0];
5344        let (projected, active) =
5345            project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[0])
5346                .expect("affine-boundary tangent projection");
5347
5348        assert_relative_eq!(projected[0], 0.0, epsilon = 1e-12);
5349        assert_relative_eq!(projected[1], -1.0, epsilon = 1e-12);
5350        assert_eq!(active, vec![0]);
5351    }
5352
5353    #[test]
5354    fn operator_cycle_escape_discovers_a_zero_step_tangent_separator() {
5355        // All three rows are tight at the vertex. Row 0 alone permits a pure
5356        // positive-slope direction, but row 2 (`constant - slope >= 0`) blocks
5357        // it at alpha=0. The operator escape must add that one separator and
5358        // re-project, not give up and not materialize every tight row.
5359        let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
5360        let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5361            .expect("separator cone");
5362        let set = ConstraintSet::KhatriRaoCone(cone);
5363        let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5364        let x = Array1::<f64>::zeros(4);
5365        let d_total = Array1::<f64>::zeros(4);
5366        let gradient = array![0.0_f64, 0.0, 0.0, -1.0];
5367        let (direction, active) = fallback_projected_gradient_direction_with_constraint_set(
5368            &x,
5369            &x,
5370            &d_total,
5371            &gradient,
5372            &[0],
5373            &ops,
5374        )
5375        .expect("operator separator evaluation")
5376        .expect("one omitted tight separator must not defeat the escape");
5377
5378        assert!(gradient.dot(&direction) < 0.0);
5379        let candidate = &x + &direction;
5380        let (worst, _) = set
5381            .max_scaled_violation(candidate.view())
5382            .expect("full-set feasibility");
5383        assert!(worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
5384        assert!(
5385            active.len() <= 2,
5386            "separator discovery expanded a three-row vertex: {active:?}"
5387        );
5388    }
5389}