Skip to main content

gam_solve/
active_set.rs

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