Skip to main content

gam_problem/
constraint_set.rs

1//! Typed structured constraint carriers for large factored coefficient blocks.
2//!
3//! The dense [`LinearInequalityConstraints`] system stores every row
4//! explicitly, which is exact and fine for the small monotone blocks (a
5//! `p × p` identity cone). A Khatri-Rao tensor block is different: the
6//! monotonicity cone of a conditional transformation `h(y|x) = Σ_k α_k(x)
7//! v_k(y)` is `α_k(x_i) ≥ 0` for every observation row `i` and every shape
8//! column `k` — `n · p_shape` rows over `p_resp · p_cov` coefficients whose
9//! dense materialization is gigabytes (gam#2306), while every operation an
10//! active-set method actually performs factors through the covariate design
11//! `Ψ` (`n × p_cov`):
12//!
13//! * constraint values are the columns of `Γ = Ψ Aᵀ` (one `n × p_cov` GEMM
14//!   per shape column),
15//! * a single row is `(e_k ⊗ ψ_i)ᵀ` — gathered densely only for the (small)
16//!   active set,
17//! * row norms are `‖ψ_i‖`, shared by every shape column.
18//!
19//! [`ConstraintSet`] is the closed union the solver plumbing carries: the
20//! dense system verbatim, or the factored cone. Semantics are IDENTICAL to
21//! canonicalizing the equivalent dense system: every slack / violation is
22//! measured on unit-normalized rows, so tolerances stay geometric.
23
24use crate::linear_constraints::LinearInequalityConstraints;
25use ndarray::{Array1, Array2, ArrayView1};
26use std::sync::Arc;
27
28/// Primal-feasibility tolerance of the inequality-constrained active-set Newton
29/// solver, measured in the unit-normalized row metric this module defines:
30/// a point `β` is feasible for a [`ConstraintSet`] exactly when
31///
32/// ```text
33/// max_r  (b_r − a_r·β) / ‖a_r‖  ≤  PRIMAL_FEASIBILITY_TOL
34/// ```
35///
36/// over the non-vacuous rows — the quantity [`ConstraintSet::max_scaled_violation`]
37/// returns. This is the ONE definition of "feasible" in the codebase; the solver
38/// certifies its returned iterate against it, every entry gate admits against it,
39/// and [`ConstraintSet::max_contract_feasible_step`] sizes steps against it.
40///
41/// It lives beside the metric rather than in the solver because the two are the
42/// same statement: the metric says what is measured, this says at what resolution.
43/// `gam_solve::active_set` re-exports it as `ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`.
44///
45/// Any consumer that re-derives a RAW (un-scaled) feasibility tolerance from a
46/// returned iterate must scale this value by the per-row normalization the
47/// constraint builder applied; demanding tighter feasibility than this is
48/// inconsistent with the solver contract and will spuriously reject valid
49/// boundary solutions (gam#2719: a step rule that demanded exact feasibility
50/// refused 314 steps that violated nothing at this tolerance).
51pub const PRIMAL_FEASIBILITY_TOL: f64 = 1e-8;
52
53/// Can this row's feasibility be DECIDED by comparison at all?
54///
55/// EVERY feasibility rule in this module decides with an ordering predicate on
56/// per-row quantities — `slack < −tol`, `drift ≥ 0`, `t < step`,
57/// `violation > worst` — and EVERY one of those is `false` for `NaN`. A row
58/// carrying a `NaN` therefore contributes NOTHING to any of those minima and
59/// maxima, and the rule answers with its neutral element: "take the whole
60/// step", "nothing is violated". That is the exact opposite of the truth, and
61/// it is gam#2721: a step with a `NaN` component was certified at `α = 1.0`,
62/// and its caller rejects only `!α.is_finite() || α ≤ 0.0`, neither of which
63/// `1.0` is.
64///
65/// The quantities are therefore tested BEFORE they are compared, and a row that
66/// cannot be decided is refused by name rather than skipped. The predicate is
67/// exported — rather than re-written at each site — because the defect WAS the
68/// rule existing in several copies and being repaired in one of them: the two
69/// fraction-to-boundary rules here, the violation sweep here, the saddle-escape
70/// chord truncation in `gam-custom-family`, and the Bernoulli marginal-slope
71/// segment cap in `gam-models` all decide with the same comparisons.
72///
73/// `NaN` is the value that cannot be compared, but it is not the only value
74/// that must be refused. An infinite drift passes `drift ≥ 0` and an infinite
75/// iterate value drives the violation to `−∞`, both of which read as "this row
76/// does not object" for an argument that is not a point. And the carrier's own
77/// constructor already holds the same line —
78/// `LinearInequalityConstraints::new` rejects a non-finite `A` or `b` with the
79/// identical reason — so requiring finiteness here keeps the row descriptors
80/// and the per-iterate quantities under ONE rule rather than two.
81///
82/// A `NaN` `row_norm` additionally defeats the `norm <= 0.0` vacuity test that
83/// would otherwise be the branch to catch it, which is why the norm is checked
84/// here and not left to that branch.
85///
86/// This does NOT collide with the legitimately-vacuous row: `‖a‖ = 0` with a
87/// bound at or below zero is finite, passes here, and keeps its own
88/// disposition in each rule.
89pub fn feasibility_quantities_are_finite(quantities: &[f64]) -> bool {
90    quantities.iter().all(|q| q.is_finite())
91}
92
93/// The contract-feasible ratio test itself, over already-evaluated constraint
94/// values, so every carrier — the dense system, the factored cone, the
95/// block-diagonal composition — runs the SAME arithmetic without any of them
96/// having to be materialized as another.
97///
98/// `values[r]` is `a_r·β`, `directional[r]` is `a_r·δ` (the constraint
99/// functional is linear, so its value at `δ` IS the directional derivative).
100/// The rule is documented on [`ConstraintSet::max_contract_feasible_step`].
101pub(crate) fn contract_feasible_step_over_rows<B, N>(
102    values: &Array1<f64>,
103    directional: &Array1<f64>,
104    bound: B,
105    row_norm: N,
106) -> Result<ContractFeasibleStep, ContractFeasibleStepError>
107where
108    B: Fn(usize) -> Result<f64, String>,
109    N: Fn(usize) -> Result<f64, String>,
110{
111    let tol = PRIMAL_FEASIBILITY_TOL;
112    let mut limit = ContractFeasibleStep::UNLIMITED;
113    for row in 0..values.len() {
114        let norm = row_norm(row).map_err(ContractFeasibleStepError::Carrier)?;
115        let bound = bound(row).map_err(ContractFeasibleStepError::Carrier)?;
116        // A ROW THAT CANNOT BE COMPARED IS NOT A FEASIBLE ROW (gam#2721) — see
117        // [`feasibility_quantities_are_finite`] for why skipping it certifies
118        // the whole step. Refuse instead, and name the condition: "this row is
119        // not a number" is a different condition from "the current iterate
120        // violates this row", so it gets its own variant rather than being
121        // reported through `InfeasibleIterate`.
122        if !feasibility_quantities_are_finite(&[norm, bound, values[row], directional[row]]) {
123            return Err(ContractFeasibleStepError::NonFinite {
124                row,
125                scaled_slack: (values[row] - bound) / norm,
126                scaled_drift: directional[row] / norm,
127            });
128        }
129        if !(norm.is_finite() && norm > 0.0) {
130            // A vacuous row constrains nothing unless its bound is positive,
131            // in which case the feasible set is empty and no step fraction
132            // exists. Same disposition as the solver's own violation scan.
133            if bound > 0.0 {
134                return Err(ContractFeasibleStepError::InfeasibleIterate {
135                    row,
136                    scaled_slack: f64::NEG_INFINITY,
137                });
138            }
139            continue;
140        }
141        let slack = (values[row] - bound) / norm;
142        let drift = directional[row] / norm;
143        if slack < -tol {
144            return Err(ContractFeasibleStepError::InfeasibleIterate {
145                row,
146                scaled_slack: slack,
147            });
148        }
149        if drift >= 0.0 {
150            continue;
151        }
152        if slack + drift >= -tol {
153            // The endpoint of the FULL step is feasible on this row to the
154            // contract. Nothing to limit.
155            continue;
156        }
157        // `slack ≥ −tol` and `slack + drift < −tol` give `slack < −drift`, so
158        // this ratio is strictly below 1 and non-negative.
159        let fraction = (slack.max(0.0) / -drift).clamp(0.0, 1.0);
160        if fraction < limit.fraction {
161            limit = ContractFeasibleStep {
162                fraction,
163                blocking_row: Some(row),
164                blocking_scaled_slack: slack,
165                blocking_scaled_drift: drift,
166            };
167        }
168    }
169    Ok(limit)
170}
171
172/// Result of the contract-feasible ratio test
173/// ([`ConstraintSet::max_contract_feasible_step`]).
174#[derive(Clone, Copy, Debug, PartialEq)]
175pub struct ContractFeasibleStep {
176    /// Largest fraction in `[0, 1]` such that `β + fraction·δ` is feasible at
177    /// [`PRIMAL_FEASIBILITY_TOL`]. `1.0` means no row limits the step.
178    ///
179    /// `0.0` is a legitimate, non-exceptional answer: it says a row is active
180    /// at `β` and `δ` points strictly out of it by more than round-off, so no
181    /// positive multiple of `δ` is admissible. The remedy is a projection onto
182    /// the active face, not a smaller `δ` — the ratio test is invariant under
183    /// `δ ↦ cδ` once the numerator is zero, so shrinking a trust radius against
184    /// it cannot converge (gam#2719).
185    pub fraction: f64,
186    /// Row that limited `fraction`, if any.
187    pub blocking_row: Option<usize>,
188    /// Scaled slack `(a·β − b)/‖a‖` of `blocking_row` at `β`.
189    pub blocking_scaled_slack: f64,
190    /// Scaled drift `(a·δ)/‖a‖` of `blocking_row` (strictly negative when a
191    /// row blocks).
192    pub blocking_scaled_drift: f64,
193}
194
195impl ContractFeasibleStep {
196    /// The unlimited answer: the whole direction is admissible.
197    pub const UNLIMITED: Self = Self {
198        fraction: 1.0,
199        blocking_row: None,
200        blocking_scaled_slack: f64::INFINITY,
201        blocking_scaled_drift: 0.0,
202    };
203
204    /// True when a row drove the fraction to exactly zero — the direction is
205    /// blocked by an active face and needs a projection, not a shorter step.
206    pub fn is_blocked_by_active_face(&self) -> bool {
207        self.fraction == 0.0
208    }
209}
210
211/// Why the contract-feasible ratio test could not answer.
212///
213/// Every variant is a violated PRECONDITION of the ratio test, never a small
214/// step: "no admissible step exists" is reported as
215/// [`ContractFeasibleStep::fraction`] `== 0.0`, not as an error.
216#[derive(Clone, Debug, PartialEq)]
217pub enum ContractFeasibleStepError {
218    /// `beta` / `direction` widths disagree with the constraint carrier.
219    Dimension {
220        beta: usize,
221        direction: usize,
222        expected: usize,
223    },
224    /// The CURRENT iterate violates a row by more than
225    /// [`PRIMAL_FEASIBILITY_TOL`], so the ratio test has no feasible origin to
226    /// step from. This is the genuine "infeasible iterate" condition and stays
227    /// loud.
228    InfeasibleIterate { row: usize, scaled_slack: f64 },
229    /// A row cannot be DECIDED by comparison: a non-finite row norm, bound,
230    /// `a·β` or `a·δ`. Reported rather than skipped: every comparison in the
231    /// rule is false for NaN, so a skipped row would silently certify a step
232    /// that is not a number as fully feasible (gam#2721). The reported slack
233    /// and drift are the scaled quantities as computed, so the offending one is
234    /// visible.
235    NonFinite {
236        row: usize,
237        scaled_slack: f64,
238        scaled_drift: f64,
239    },
240    /// The carrier could not evaluate `Aβ` / `Aδ` or a row descriptor.
241    Carrier(String),
242}
243
244impl std::fmt::Display for ContractFeasibleStepError {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        match self {
247            ContractFeasibleStepError::Dimension {
248                beta,
249                direction,
250                expected,
251            } => write!(
252                f,
253                "constraint step dimension mismatch: beta={beta}, direction={direction}, constraints={expected}"
254            ),
255            ContractFeasibleStepError::InfeasibleIterate { row, scaled_slack } => write!(
256                f,
257                "current iterate violates constraint row {row}: scaled slack={scaled_slack:.3e} \
258                 below the primal-feasibility contract {PRIMAL_FEASIBILITY_TOL:.3e}"
259            ),
260            ContractFeasibleStepError::NonFinite {
261                row,
262                scaled_slack,
263                scaled_drift,
264            } => write!(
265                f,
266                "constraint row {row} has a non-finite ratio test: scaled slack={scaled_slack:.3e}, \
267                 scaled drift={scaled_drift:.3e}"
268            ),
269            ContractFeasibleStepError::Carrier(reason) => write!(f, "{reason}"),
270        }
271    }
272}
273
274/// Nonnegativity cone `(e_k ⊗ ψ_i)ᵀ β ≥ 0` for a row-major Khatri-Rao block.
275///
276/// The coefficient block is `β = vec(A)` with `A` reshaped row-major as
277/// `p_left × p_cov` (coefficient `A[k, j] = β[k · p_cov + j]`). The cone
278/// constrains the factored linear functionals `α_k(x_i) = ψ_iᵀ A_{k,:}` to be
279/// non-negative for every observation row `i` of `factor` and every
280/// `k ∈ coupled_rows`.
281///
282/// Row identifiers are stable and dense: row `r = s · n + i` where `s` indexes
283/// into `coupled_rows` and `i` is the observation row. Active-set warm starts
284/// therefore survive across iterations exactly as with the dense system.
285#[derive(Clone, Debug)]
286pub struct KhatriRaoConeConstraints {
287    /// Covariate factor `Ψ` (`n × p_cov`).
288    factor: Arc<Array2<f64>>,
289    /// Euclidean norm of each `Ψ` row (unit-normalization denominators).
290    factor_row_norms: Array1<f64>,
291    /// Coefficient rows of `A` (indices into `0..p_left`) that carry the cone.
292    coupled_rows: Vec<usize>,
293    /// Total number of coefficient rows in the block reshape.
294    p_left: usize,
295    /// Per-row right-hand sides. The homogeneous cone has `b ≡ 0`; a
296    /// delta-coordinate solve (`β = β₀ + δ`) shifts them to `−(rowᵀβ₀)`.
297    /// Bounds are `O(nrows)` — cheap even when the matrix is not.
298    bounds: Option<Array1<f64>>,
299}
300
301impl KhatriRaoConeConstraints {
302    pub fn new(
303        factor: Arc<Array2<f64>>,
304        coupled_rows: Vec<usize>,
305        p_left: usize,
306    ) -> Result<Self, String> {
307        if factor.nrows() == 0 || factor.ncols() == 0 {
308            return Err("KhatriRaoConeConstraints: factor must be non-empty".to_string());
309        }
310        if factor.iter().any(|v| !v.is_finite()) {
311            return Err("KhatriRaoConeConstraints: factor must be finite".to_string());
312        }
313        if coupled_rows.is_empty() {
314            return Err(
315                "KhatriRaoConeConstraints: at least one coupled coefficient row is required"
316                    .to_string(),
317            );
318        }
319        let mut seen = vec![false; p_left];
320        for &k in &coupled_rows {
321            if k >= p_left {
322                return Err(format!(
323                    "KhatriRaoConeConstraints: coupled row {k} out of range (p_left = {p_left})"
324                ));
325            }
326            if seen[k] {
327                return Err(format!(
328                    "KhatriRaoConeConstraints: coupled row {k} is duplicated"
329                ));
330            }
331            seen[k] = true;
332        }
333        let factor_row_norms =
334            Array1::from_iter(factor.rows().into_iter().map(|row| row.dot(&row).sqrt()));
335        Ok(Self {
336            factor,
337            factor_row_norms,
338            coupled_rows,
339            p_left,
340            bounds: None,
341        })
342    }
343
344    pub fn factor(&self) -> &Array2<f64> {
345        self.factor.as_ref()
346    }
347
348    pub fn coupled_rows(&self) -> &[usize] {
349        &self.coupled_rows
350    }
351
352    pub fn p_left(&self) -> usize {
353        self.p_left
354    }
355
356    /// One coupled response-row slot as a standalone cone over a single
357    /// `p_cov` coefficient block. The covariate factor remains shared by
358    /// [`Arc`]; only the small row-norm vector and this slot's optional bounds
359    /// are copied. This is the exact block decomposition of an identity-Hessian
360    /// projection, not a reduced-data approximation.
361    pub fn single_coupled_slot(&self, slot: usize) -> Result<Self, String> {
362        if slot >= self.coupled_rows.len() {
363            return Err(format!(
364                "KhatriRaoConeConstraints: coupled slot {slot} out of range ({} slots)",
365                self.coupled_rows.len()
366            ));
367        }
368        let n = self.factor.nrows();
369        let bounds = self
370            .bounds
371            .as_ref()
372            .map(|all| all.slice(ndarray::s![slot * n..(slot + 1) * n]).to_owned());
373        Ok(Self {
374            factor: Arc::clone(&self.factor),
375            factor_row_norms: self.factor_row_norms.clone(),
376            coupled_rows: vec![0],
377            p_left: 1,
378            bounds,
379        })
380    }
381
382    pub fn nrows(&self) -> usize {
383        self.coupled_rows.len() * self.factor.nrows()
384    }
385
386    pub fn ncols(&self) -> usize {
387        self.p_left * self.factor.ncols()
388    }
389
390    /// Decompose a row id into `(coupled-row slot, observation row)`.
391    #[inline]
392    fn split_row_id(&self, row: usize) -> Result<(usize, usize), String> {
393        let n = self.factor.nrows();
394        let slot = row / n;
395        if slot >= self.coupled_rows.len() {
396            return Err(format!(
397                "KhatriRaoConeConstraints: row id {row} out of range ({} rows)",
398                self.nrows()
399            ));
400        }
401        Ok((slot, row % n))
402    }
403
404    /// Raw (un-normalized) constraint values `A β` for the full row set,
405    /// laid out slot-major (`r = s·n + i`).
406    ///
407    /// Cost: one `n × p_cov · p_cov` product per coupled row — never the
408    /// `nrows × ncols` dense system.
409    pub fn values(&self, beta: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
410        let p_cov = self.factor.ncols();
411        if beta.len() != self.ncols() {
412            return Err(format!(
413                "KhatriRaoConeConstraints: beta length {} != {}",
414                beta.len(),
415                self.ncols()
416            ));
417        }
418        let n = self.factor.nrows();
419        let mut out = Array1::<f64>::zeros(self.nrows());
420        for (slot, &k) in self.coupled_rows.iter().enumerate() {
421            let block = beta.slice(ndarray::s![k * p_cov..(k + 1) * p_cov]);
422            let alpha = self.factor.dot(&block);
423            out.slice_mut(ndarray::s![slot * n..(slot + 1) * n])
424                .assign(&alpha);
425        }
426        Ok(out)
427    }
428
429    /// Unit-normalization denominator of one row (`‖ψ_i‖`, shared across
430    /// coupled slots). Zero rows are vacuous (`0ᵀβ ≥ 0` always holds) exactly
431    /// like the canonicalized dense system keeps them inert.
432    pub fn row_norm(&self, row: usize) -> Result<f64, String> {
433        let (_, i) = self.split_row_id(row)?;
434        Ok(self.factor_row_norms[i])
435    }
436
437    /// The coefficient columns row `row` acts on, ascending.
438    ///
439    /// Row `(slot, i)` has normal `e_k ⊗ ψ_i` with `k = coupled_rows[slot]`, and
440    /// [`Self::values`] reads exactly the block `β[k·p_cov .. (k+1)·p_cov]`, so
441    /// the support is `k·p_cov + j` over the columns `j` where `ψ_{i,j} ≠ 0`.
442    /// Every other coefficient has a structurally zero coefficient in this row.
443    pub fn row_column_support(&self, row: usize) -> Result<Vec<usize>, String> {
444        let (slot, i) = self.split_row_id(row)?;
445        let p_cov = self.factor.ncols();
446        let base = self.coupled_rows[slot] * p_cov;
447        Ok((0..p_cov)
448            .filter(|&j| self.factor[[i, j]] != 0.0)
449            .map(|j| base + j)
450            .collect())
451    }
452
453    /// Per-row right-hand side (`0` for the homogeneous cone, shifted values
454    /// after [`ConstraintSet::shifted_to_delta`]).
455    pub fn bound(&self, row: usize) -> Result<f64, String> {
456        self.split_row_id(row)?;
457        Ok(self.bounds.as_ref().map_or(0.0, |bounds| bounds[row]))
458    }
459
460    /// Materialize the requested rows as a dense system (active-set KKT use;
461    /// the id order of `rows` is preserved). Rows come out RAW (un-normalized),
462    /// matching the raw dense construction path; callers that need geometric
463    /// tolerances canonicalize the gathered system.
464    pub fn gather_rows(&self, rows: &[usize]) -> Result<LinearInequalityConstraints, String> {
465        let p_cov = self.factor.ncols();
466        let mut a = Array2::<f64>::zeros((rows.len(), self.ncols()));
467        let mut b = Array1::<f64>::zeros(rows.len());
468        for (out_row, &row) in rows.iter().enumerate() {
469            let (slot, i) = self.split_row_id(row)?;
470            let k = self.coupled_rows[slot];
471            a.row_mut(out_row)
472                .slice_mut(ndarray::s![k * p_cov..(k + 1) * p_cov])
473                .assign(&self.factor.row(i));
474            b[out_row] = self.bound(row)?;
475        }
476        LinearInequalityConstraints::new(a, b)
477    }
478
479    /// Exact dense equivalent of the ENTIRE cone. Test/oracle use only — this
480    /// is the materialization the carrier exists to avoid.
481    pub fn to_dense(&self) -> Result<LinearInequalityConstraints, String> {
482        let all: Vec<usize> = (0..self.nrows()).collect();
483        self.gather_rows(&all)
484    }
485}
486
487/// A row index in a [`ConstraintSet`]'s OWN constraint-row space — the space
488/// addressed by [`ConstraintSet::values`], [`ConstraintSet::bound`] and
489/// [`ConstraintSet::row_norm`], i.e. `0..nrows()`.
490///
491/// This is NOT a coefficient (β) index. The two spaces have different sizes
492/// (`nrows()` vs `ncols()`) and different meanings, and they coincide only in
493/// the special case of a square carrier whose row `r` is exactly the box
494/// `β_r ≥ 0`. A block-diagonal composition breaks that coincidence: its row ids
495/// are the CONCATENATION of the member row counts while its columns are the
496/// concatenation of the member column ranges, so as soon as one member has
497/// `nrows() < ncols()` (a monotone sub-basis alongside unconstrained intercept /
498/// covariate columns) row id `r` of a later block names a β coordinate owned by
499/// an EARLIER block. The newtype exists so that mistake cannot be made silently;
500/// to go from a row to the coefficients it acts on, call
501/// [`ConstraintSet::row_column_support`].
502#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
503pub struct ConstraintRowId(pub usize);
504
505impl ConstraintRowId {
506    /// The raw index, for addressing a `values()` / `bound()` / `row_norm()`
507    /// result. Deliberately explicit: reach for this only when indexing
508    /// something that really is in constraint-row space.
509    #[inline]
510    pub fn index(self) -> usize {
511        self.0
512    }
513}
514
515/// One block of a [`ConstraintSet::BlockDiagonal`] composition: an inner set
516/// acting on the coefficient columns `[col_start, col_start + set.ncols())` of
517/// the joint vector.
518#[derive(Clone, Debug)]
519pub struct PlacedConstraintBlock {
520    pub col_start: usize,
521    pub set: ConstraintSet,
522}
523
524/// Closed union of the constraint carriers the blockwise solvers accept.
525#[derive(Clone, Debug)]
526pub enum ConstraintSet {
527    /// Explicit rows, exactly as today.
528    Dense(LinearInequalityConstraints),
529    /// Factored Khatri-Rao nonnegativity cone.
530    KhatriRaoCone(KhatriRaoConeConstraints),
531    /// Block-diagonal composition over disjoint column ranges of a joint
532    /// coefficient vector (the multi-block joint-Newton assembly). Row ids
533    /// are the concatenation of the member row ids in order.
534    BlockDiagonal {
535        blocks: Vec<PlacedConstraintBlock>,
536        total_cols: usize,
537    },
538}
539
540impl ConstraintSet {
541    /// Validated block-diagonal composition: member column ranges must lie
542    /// inside the joint width and must not overlap.
543    pub fn block_diagonal(
544        blocks: Vec<PlacedConstraintBlock>,
545        total_cols: usize,
546    ) -> Result<Self, String> {
547        let mut ranges: Vec<(usize, usize)> = Vec::with_capacity(blocks.len());
548        for block in &blocks {
549            let end = block.col_start + block.set.ncols();
550            if end > total_cols {
551                return Err(format!(
552                    "ConstraintSet::block_diagonal: block columns {}..{} exceed joint width {}",
553                    block.col_start, end, total_cols
554                ));
555            }
556            ranges.push((block.col_start, end));
557        }
558        ranges.sort_unstable();
559        for pair in ranges.windows(2) {
560            if pair[1].0 < pair[0].1 {
561                return Err(format!(
562                    "ConstraintSet::block_diagonal: overlapping column ranges {:?} and {:?}",
563                    pair[0], pair[1]
564                ));
565            }
566        }
567        Ok(ConstraintSet::BlockDiagonal { blocks, total_cols })
568    }
569
570    /// Locate the member block owning a joint row id.
571    fn block_for_row<'a>(
572        blocks: &'a [PlacedConstraintBlock],
573        row: usize,
574    ) -> Result<(&'a PlacedConstraintBlock, usize), String> {
575        let mut offset = 0usize;
576        for block in blocks {
577            let rows = block.set.nrows();
578            if row < offset + rows {
579                return Ok((block, row - offset));
580            }
581            offset += rows;
582        }
583        Err(format!(
584            "ConstraintSet: row {row} out of range ({offset} rows)"
585        ))
586    }
587
588    pub fn nrows(&self) -> usize {
589        match self {
590            ConstraintSet::Dense(dense) => dense.a.nrows(),
591            ConstraintSet::KhatriRaoCone(cone) => cone.nrows(),
592            ConstraintSet::BlockDiagonal { blocks, .. } => {
593                blocks.iter().map(|block| block.set.nrows()).sum()
594            }
595        }
596    }
597
598    pub fn ncols(&self) -> usize {
599        match self {
600            ConstraintSet::Dense(dense) => dense.a.ncols(),
601            ConstraintSet::KhatriRaoCone(cone) => cone.ncols(),
602            ConstraintSet::BlockDiagonal { total_cols, .. } => *total_cols,
603        }
604    }
605
606    /// Raw constraint values `Aβ` (dense) / factored functional values (cone).
607    pub fn values(&self, beta: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
608        match self {
609            ConstraintSet::Dense(dense) => {
610                if beta.len() != dense.a.ncols() {
611                    return Err(format!(
612                        "ConstraintSet: beta length {} != {}",
613                        beta.len(),
614                        dense.a.ncols()
615                    ));
616                }
617                Ok(dense.a.dot(&beta))
618            }
619            ConstraintSet::KhatriRaoCone(cone) => cone.values(beta),
620            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
621                if beta.len() != *total_cols {
622                    return Err(format!(
623                        "ConstraintSet: beta length {} != {}",
624                        beta.len(),
625                        total_cols
626                    ));
627                }
628                let mut out = Array1::<f64>::zeros(self.nrows());
629                let mut offset = 0usize;
630                for block in blocks {
631                    let width = block.set.ncols();
632                    let local = beta.slice(ndarray::s![block.col_start..block.col_start + width]);
633                    let values = block.set.values(local)?;
634                    let rows = values.len();
635                    out.slice_mut(ndarray::s![offset..offset + rows])
636                        .assign(&values);
637                    offset += rows;
638                }
639                Ok(out)
640            }
641        }
642    }
643
644    /// Right-hand sides (`b` dense; cone bounds are zero unless delta-shifted).
645    pub fn bound(&self, row: usize) -> Result<f64, String> {
646        match self {
647            ConstraintSet::Dense(dense) => dense.b.get(row).copied().ok_or_else(|| {
648                format!(
649                    "ConstraintSet: row {row} out of range ({} rows)",
650                    dense.b.len()
651                )
652            }),
653            ConstraintSet::KhatriRaoCone(cone) => cone.bound(row),
654            ConstraintSet::BlockDiagonal { blocks, .. } => {
655                let (block, local) = Self::block_for_row(blocks, row)?;
656                block.set.bound(local)
657            }
658        }
659    }
660
661    pub fn row_norm(&self, row: usize) -> Result<f64, String> {
662        match self {
663            ConstraintSet::Dense(dense) => {
664                if row >= dense.a.nrows() {
665                    return Err(format!(
666                        "ConstraintSet: row {row} out of range ({} rows)",
667                        dense.a.nrows()
668                    ));
669                }
670                let r = dense.a.row(row);
671                Ok(r.dot(&r).sqrt())
672            }
673            ConstraintSet::KhatriRaoCone(cone) => cone.row_norm(row),
674            ConstraintSet::BlockDiagonal { blocks, .. } => {
675                let (block, local) = Self::block_for_row(blocks, row)?;
676                block.set.row_norm(local)
677            }
678        }
679    }
680
681    /// The coefficient (β) columns that constraint row `row` acts on, ascending
682    /// and in the JOINT column space of this set — the one and only sanctioned
683    /// route from constraint-row space to coefficient space.
684    ///
685    /// Needed because the two spaces are genuinely different (see
686    /// [`ConstraintRowId`]): a consumer building a free/pinned β mask from a
687    /// reduced face has row ids in hand and coefficient positions to fill, and
688    /// the identity map between them is valid only for a square box carrier.
689    /// The block-diagonal arm is where it visibly fails — row ids advance by
690    /// each member's `nrows()` while columns advance by its `ncols()`, so the
691    /// two run at different rates the moment any member constrains fewer rows
692    /// than it has coefficients.
693    pub fn row_column_support(&self, row: ConstraintRowId) -> Result<Vec<usize>, String> {
694        let row = row.index();
695        match self {
696            ConstraintSet::Dense(dense) => {
697                if row >= dense.a.nrows() {
698                    return Err(format!(
699                        "ConstraintSet: row {row} out of range ({} rows)",
700                        dense.a.nrows()
701                    ));
702                }
703                Ok(dense
704                    .a
705                    .row(row)
706                    .iter()
707                    .enumerate()
708                    .filter(|(_, value)| **value != 0.0)
709                    .map(|(col, _)| col)
710                    .collect())
711            }
712            ConstraintSet::KhatriRaoCone(cone) => cone.row_column_support(row),
713            ConstraintSet::BlockDiagonal { blocks, .. } => {
714                let (block, local) = Self::block_for_row(blocks, row)?;
715                // The member reports support in ITS OWN column space; the joint
716                // offset is the block's `col_start`, which is independent of the
717                // row offset used to reach `local`.
718                let mut cols = block.set.row_column_support(ConstraintRowId(local))?;
719                for col in &mut cols {
720                    *col += block.col_start;
721                }
722                Ok(cols)
723            }
724        }
725    }
726
727    /// The same constraint system expressed in delta coordinates around
728    /// `beta`: `A(β + δ) ≥ b  ⇔  Aδ ≥ b − Aβ`. The matrix carrier is shared;
729    /// only the `O(nrows)` bounds change.
730    pub fn shifted_to_delta(&self, beta: ArrayView1<'_, f64>) -> Result<Self, String> {
731        let values = self.values(beta)?;
732        match self {
733            ConstraintSet::Dense(dense) => Ok(ConstraintSet::Dense(
734                LinearInequalityConstraints::new(dense.a.clone(), &dense.b - &values)?,
735            )),
736            ConstraintSet::KhatriRaoCone(cone) => {
737                let mut shifted = cone.clone();
738                let base = shifted
739                    .bounds
740                    .take()
741                    .unwrap_or_else(|| Array1::zeros(values.len()));
742                shifted.bounds = Some(&base - &values);
743                Ok(ConstraintSet::KhatriRaoCone(shifted))
744            }
745            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
746                let mut shifted_blocks = Vec::with_capacity(blocks.len());
747                for block in blocks {
748                    let width = block.set.ncols();
749                    let local = beta.slice(ndarray::s![block.col_start..block.col_start + width]);
750                    shifted_blocks.push(PlacedConstraintBlock {
751                        col_start: block.col_start,
752                        set: block.set.shifted_to_delta(local)?,
753                    });
754                }
755                Ok(ConstraintSet::BlockDiagonal {
756                    blocks: shifted_blocks,
757                    total_cols: *total_cols,
758                })
759            }
760        }
761    }
762
763    /// Scaled violation sweep: `max_r (b_r − (Aβ)_r) / ‖a_r‖` restricted to
764    /// non-vacuous rows, plus the arg-max row. Matches the canonicalized dense
765    /// geometry (unit rows) without materializing it.
766    ///
767    /// This is THE feasibility metric: `β` is feasible exactly when the value
768    /// returned here is at or below [`PRIMAL_FEASIBILITY_TOL`].
769    ///
770    /// A vacuous row (`‖a‖ = 0`) with a bound at or below zero is `0 ≥ b`, true
771    /// for every `β`, and contributes nothing. A vacuous row with a POSITIVE
772    /// bound is `0 ≥ b > 0`: no `β` satisfies it, so its violation is infinite
773    /// and the feasible set is empty. Reporting that as `+∞` — rather than
774    /// skipping the row — is what makes this metric agree with
775    /// `ConstraintSetOps::scaled_slack`, which already answers `−∞` for exactly
776    /// this row, and keeps a gate built on this metric from silently admitting
777    /// an unsatisfiable system.
778    ///
779    /// A row that cannot be decided by comparison — a non-finite row norm,
780    /// bound or `a·β` — is refused rather than skipped (gam#2721): feasibility
781    /// of an iterate that is not a number is undefined, and `violation > worst`
782    /// being false for `NaN` would report the neutral `0.0` — "nothing is
783    /// violated" — for exactly the iterate this metric exists to catch.
784    pub fn max_scaled_violation(
785        &self,
786        beta: ArrayView1<'_, f64>,
787    ) -> Result<(f64, Option<usize>), String> {
788        let values = self.values(beta)?;
789        let mut worst = 0.0_f64;
790        let mut worst_row = None;
791        for (row, &value) in values.iter().enumerate() {
792            let norm = self.row_norm(row)?;
793            let bound = self.bound(row)?;
794            // Decidability before comparison (gam#2721): `violation > worst` is
795            // FALSE for `NaN`, so an undecidable row would leave `worst` at
796            // `0.0` and this metric — THE feasibility verdict — would call an
797            // iterate that is not a number feasible. `norm <= 0.0` is false for
798            // a `NaN` norm too, so the vacuous-row branch below cannot be the
799            // one that catches it. Refuse, naming the row and the quantities.
800            if !feasibility_quantities_are_finite(&[norm, bound, value]) {
801                return Err(format!(
802                    "ConstraintSet::max_scaled_violation: row {row} cannot be decided \
803                     (row norm {norm:.3e}, bound {bound:.3e}, value {value:.3e}); \
804                     feasibility of a non-finite iterate is undefined and every \
805                     comparison in the sweep is false for NaN, so the row cannot \
806                     be skipped (gam#2721)"
807                ));
808            }
809            if norm <= 0.0 {
810                if bound > 0.0 {
811                    return Ok((f64::INFINITY, Some(row)));
812                }
813                continue;
814            }
815            let violation = (bound - value) / norm;
816            if violation > worst {
817                worst = violation;
818                worst_row = Some(row);
819            }
820        }
821        Ok((worst, worst_row))
822    }
823
824    /// Largest `t ∈ [0, 1]` with `β + t·δ` feasible for every row, together
825    /// with the first blocking row (the EXACT ratio test of a primal
826    /// active-set method — zero tolerance, raw slacks). Rows already violated
827    /// at `β` are reported as blocking at `t = 0`.
828    ///
829    /// This is the *pivot* rule: it answers "where does this chord cross a
830    /// hyperplane in exact arithmetic", and its consumers (the feasible-chord
831    /// clipper) want exactly that. It is NOT the rule for sizing a Newton step
832    /// — a globalization that demands exact feasibility rejects steps this
833    /// carrier's own contract calls feasible. Use
834    /// [`ConstraintSet::max_contract_feasible_step`] for that.
835    ///
836    /// Like the contract rule, this one is TOTAL (gam#2721): a row that cannot
837    /// be decided by comparison — a non-finite row norm, bound, `a·β` or `a·δ`
838    /// — and that was not explicitly skipped is refused, because every
839    /// comparison it would otherwise feed is false for `NaN` and the answer
840    /// would be an unlimited `t = 1`.
841    pub fn max_feasible_step(
842        &self,
843        beta: ArrayView1<'_, f64>,
844        delta: ArrayView1<'_, f64>,
845        skip_rows: &[usize],
846    ) -> Result<(f64, Option<usize>), String> {
847        let values = self.values(beta)?;
848        let directional = self.values(delta)?;
849        let mut skip = vec![false; values.len()];
850        for &row in skip_rows {
851            if row < skip.len() {
852                skip[row] = true;
853            }
854        }
855        let mut step = 1.0_f64;
856        let mut blocking = None;
857        for row in 0..values.len() {
858            if skip[row] {
859                continue;
860            }
861            let norm = self.row_norm(row)?;
862            let bound = self.bound(row)?;
863            let value = values[row];
864            let rate = directional[row];
865            // Same decidability requirement as the contract rule (gam#2721): a
866            // `NaN` fails `rate >= 0.0` AND `t < step`, so the row would be
867            // skipped twice over and this exact ratio test would answer
868            // `step = 1.0` — "the whole chord is feasible" — for a chord that
869            // is not a point. The clipper built on it would then accept the
870            // endpoint. Refuse before comparing.
871            if !feasibility_quantities_are_finite(&[norm, bound, value, rate]) {
872                return Err(format!(
873                    "ConstraintSet::max_feasible_step: row {row} cannot be decided \
874                     (row norm {norm:.3e}, bound {bound:.3e}, value {value:.3e}, \
875                     drift {rate:.3e}); every comparison in the ratio test is false \
876                     for NaN, so skipping the row would report the whole step \
877                     feasible (gam#2721)"
878                ));
879            }
880            if norm <= 0.0 {
881                continue;
882            }
883            if rate >= 0.0 {
884                continue;
885            }
886            let t = (value - bound) / (-rate);
887            if t < step {
888                step = t.max(0.0);
889                blocking = Some(row);
890            }
891        }
892        Ok((step, blocking))
893    }
894
895    /// Fraction-to-boundary limit denominated in the SAME metric and at the
896    /// SAME tolerance as the primal-feasibility contract
897    /// ([`PRIMAL_FEASIBILITY_TOL`]) — the globalization ratio test.
898    ///
899    /// The rule, per non-vacuous row, on scaled slack `s = (a·β − b)/‖a‖` and
900    /// scaled drift `d = (a·δ)/‖a‖`:
901    ///
902    /// * `s < −tol` — the current iterate is infeasible. There is no feasible
903    ///   origin to step from; report it
904    ///   ([`ContractFeasibleStepError::InfeasibleIterate`]) rather than
905    ///   returning a meaningless fraction.
906    /// * `d ≥ 0` — the row cannot block; a step along `δ` only increases slack.
907    /// * `s + d ≥ −tol` — the WHOLE step lands inside the feasibility band.
908    ///   The row does not limit it. This is the clause that
909    ///   [`max_feasible_step`](Self::max_feasible_step) lacks, and its absence
910    ///   is gam#2719: with `s == 0` the exact rule returns `0` for a drift of
911    ///   `−1e-15`, refusing a step whose endpoint the very same carrier calls
912    ///   feasible.
913    /// * otherwise — the row genuinely blocks. Limit at the TRUE boundary,
914    ///   `max(s, 0) / (−d)`, not at the band edge: aiming at `−tol` every step
915    ///   would walk the iterate to the edge of the contract and leave it there.
916    ///
917    /// The returned fraction is therefore never larger than the exact ratio
918    /// test's answer EXCEPT on steps whose whole excursion is sub-tolerance,
919    /// and the worst violation any accepted step can introduce is `tol` — the
920    /// contract, exactly.
921    ///
922    /// A fraction of `0.0` is an answer, not a failure: see
923    /// [`ContractFeasibleStep::is_blocked_by_active_face`].
924    pub fn max_contract_feasible_step(
925        &self,
926        beta: ArrayView1<'_, f64>,
927        direction: ArrayView1<'_, f64>,
928    ) -> Result<ContractFeasibleStep, ContractFeasibleStepError> {
929        if beta.len() != self.ncols() || direction.len() != self.ncols() {
930            return Err(ContractFeasibleStepError::Dimension {
931                beta: beta.len(),
932                direction: direction.len(),
933                expected: self.ncols(),
934            });
935        }
936        let values = self
937            .values(beta)
938            .map_err(ContractFeasibleStepError::Carrier)?;
939        // The constraint functional is linear, so its value at `δ` IS the
940        // directional derivative `Aδ`; the bounds do not enter.
941        let directional = self
942            .values(direction)
943            .map_err(ContractFeasibleStepError::Carrier)?;
944        contract_feasible_step_over_rows(
945            &values,
946            &directional,
947            |row| self.bound(row),
948            |row| self.row_norm(row),
949        )
950    }
951
952    /// Materialize the requested rows densely (KKT systems on the active set).
953    pub fn gather_rows(&self, rows: &[usize]) -> Result<LinearInequalityConstraints, String> {
954        match self {
955            ConstraintSet::Dense(dense) => {
956                let mut a = Array2::<f64>::zeros((rows.len(), dense.a.ncols()));
957                let mut b = Array1::<f64>::zeros(rows.len());
958                for (out_row, &row) in rows.iter().enumerate() {
959                    if row >= dense.a.nrows() {
960                        return Err(format!(
961                            "ConstraintSet: row {row} out of range ({} rows)",
962                            dense.a.nrows()
963                        ));
964                    }
965                    a.row_mut(out_row).assign(&dense.a.row(row));
966                    b[out_row] = dense.b[row];
967                }
968                LinearInequalityConstraints::new(a, b)
969            }
970            ConstraintSet::KhatriRaoCone(cone) => cone.gather_rows(rows),
971            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
972                let mut a = Array2::<f64>::zeros((rows.len(), *total_cols));
973                let mut b = Array1::<f64>::zeros(rows.len());
974                for (out_row, &row) in rows.iter().enumerate() {
975                    let (block, local) = Self::block_for_row(blocks, row)?;
976                    let gathered = block.set.gather_rows(&[local])?;
977                    a.row_mut(out_row)
978                        .slice_mut(ndarray::s![
979                            block.col_start..block.col_start + block.set.ncols()
980                        ])
981                        .assign(&gathered.a.row(0));
982                    b[out_row] = gathered.b[0];
983                }
984                LinearInequalityConstraints::new(a, b)
985            }
986        }
987    }
988
989    /// Exact dense equivalent of the whole set (tests / small systems only).
990    pub fn to_dense(&self) -> Result<LinearInequalityConstraints, String> {
991        match self {
992            ConstraintSet::Dense(dense) => Ok(dense.clone()),
993            _ => {
994                let all: Vec<usize> = (0..self.nrows()).collect();
995                self.gather_rows(&all)
996            }
997        }
998    }
999}
1000
1001impl From<LinearInequalityConstraints> for ConstraintSet {
1002    fn from(dense: LinearInequalityConstraints) -> Self {
1003        ConstraintSet::Dense(dense)
1004    }
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010    use ndarray::array;
1011
1012    fn cone_fixture() -> KhatriRaoConeConstraints {
1013        // Ψ: 3 observations × 2 covariate columns; A is 3 coefficient rows
1014        // (row 0 = location, rows 1..2 = shape) × 2 columns.
1015        let psi = array![[1.0_f64, 0.5], [2.0, -1.0], [0.0, 3.0]];
1016        KhatriRaoConeConstraints::new(Arc::new(psi), vec![1, 2], 3).expect("cone fixture")
1017    }
1018
1019    fn beta_fixture() -> Array1<f64> {
1020        // vec(A) row-major, A = [[9, -4], [1, 2], [0.5, -0.25]]
1021        array![9.0_f64, -4.0, 1.0, 2.0, 0.5, -0.25]
1022    }
1023
1024    #[test]
1025    fn cone_values_match_dense_system() {
1026        let cone = cone_fixture();
1027        let set = ConstraintSet::KhatriRaoCone(cone.clone());
1028        let dense = ConstraintSet::Dense(cone.to_dense().expect("dense"));
1029        let beta = beta_fixture();
1030        let via_cone = set.values(beta.view()).expect("cone values");
1031        let via_dense = dense.values(beta.view()).expect("dense values");
1032        assert_eq!(via_cone.len(), 6);
1033        for (a, b) in via_cone.iter().zip(via_dense.iter()) {
1034            assert!((a - b).abs() < 1e-14, "cone/dense mismatch: {a} vs {b}");
1035        }
1036        // Spot-check one functional exactly: slot 0 (A row 1), observation 1:
1037        // ψ = (2, −1), A_{1,:} = (1, 2) → 2·1 − 1·2 = 0.
1038        assert!((via_cone[1] - 0.0).abs() < 1e-15);
1039    }
1040
1041    /// `row_column_support` is the sanctioned row → β conversion, so it must
1042    /// agree with the explicit dense system row by row: the columns it names are
1043    /// exactly the structurally nonzero entries of that row of `A`.
1044    #[test]
1045    fn cone_row_column_support_matches_the_dense_row_nonzeros() {
1046        let cone = cone_fixture();
1047        let set = ConstraintSet::KhatriRaoCone(cone.clone());
1048        let dense = ConstraintSet::Dense(cone.to_dense().expect("dense"));
1049        for row in 0..set.nrows() {
1050            let via_cone = set
1051                .row_column_support(ConstraintRowId(row))
1052                .expect("cone support");
1053            let via_dense = dense
1054                .row_column_support(ConstraintRowId(row))
1055                .expect("dense support");
1056            assert_eq!(via_cone, via_dense, "row {row} support mismatch");
1057        }
1058        // Slot 0 carries coefficient row k = 1, so its columns are 1·p_cov + j.
1059        // Observation 2 has ψ = (0, 3): the zero factor entry drops column 2.
1060        assert_eq!(
1061            set.row_column_support(ConstraintRowId(0)).expect("r0"),
1062            vec![2, 3]
1063        );
1064        assert_eq!(
1065            set.row_column_support(ConstraintRowId(2)).expect("r2"),
1066            vec![3]
1067        );
1068        // Slot 1 carries coefficient row k = 2 → columns 4, 5.
1069        assert_eq!(
1070            set.row_column_support(ConstraintRowId(3)).expect("r3"),
1071            vec![4, 5]
1072        );
1073    }
1074
1075    /// The block-diagonal arm offsets support by `col_start` while it decodes
1076    /// the row by the running `nrows()`. When a member has `nrows() < ncols()`
1077    /// the two run at different rates, and only the conversion tracks columns
1078    /// correctly: joint row 1 belongs to the block starting at column 3.
1079    #[test]
1080    fn block_diagonal_row_column_support_uses_col_start_not_the_row_offset() {
1081        let narrow = PlacedConstraintBlock {
1082            col_start: 0,
1083            set: ConstraintSet::Dense(
1084                LinearInequalityConstraints::new(
1085                    array![[1.0_f64, 0.0, 0.0]],
1086                    Array1::<f64>::zeros(1),
1087                )
1088                .expect("narrow"),
1089            ),
1090        };
1091        let square = PlacedConstraintBlock {
1092            col_start: 3,
1093            set: ConstraintSet::Dense(
1094                LinearInequalityConstraints::new(
1095                    array![[1.0_f64, 0.0], [0.0, 1.0]],
1096                    Array1::<f64>::zeros(2),
1097                )
1098                .expect("square"),
1099            ),
1100        };
1101        let set = ConstraintSet::block_diagonal(vec![narrow, square], 5).expect("joint");
1102        assert_eq!(set.nrows(), 3);
1103        assert_eq!(set.ncols(), 5);
1104        assert_eq!(
1105            set.row_column_support(ConstraintRowId(0)).expect("r0"),
1106            vec![0]
1107        );
1108        // Row 1 is the second block's first row: column 3, NOT column 1.
1109        assert_eq!(
1110            set.row_column_support(ConstraintRowId(1)).expect("r1"),
1111            vec![3]
1112        );
1113        assert_eq!(
1114            set.row_column_support(ConstraintRowId(2)).expect("r2"),
1115            vec![4]
1116        );
1117        assert!(set.row_column_support(ConstraintRowId(3)).is_err());
1118    }
1119
1120    #[test]
1121    fn cone_row_norms_are_factor_row_norms_for_every_slot() {
1122        let cone = cone_fixture();
1123        let set = ConstraintSet::KhatriRaoCone(cone);
1124        let expected = [(1.0_f64 + 0.25).sqrt(), (4.0_f64 + 1.0).sqrt(), 3.0_f64];
1125        for slot in 0..2 {
1126            for i in 0..3 {
1127                let norm = set.row_norm(slot * 3 + i).expect("norm");
1128                assert!((norm - expected[i]).abs() < 1e-15);
1129            }
1130        }
1131    }
1132
1133    #[test]
1134    fn max_scaled_violation_agrees_with_canonicalized_dense() {
1135        let cone = cone_fixture();
1136        let set = ConstraintSet::KhatriRaoCone(cone.clone());
1137        let beta = beta_fixture();
1138        let (violation, row) = set.max_scaled_violation(beta.view()).expect("violation");
1139        // Dense oracle: canonicalize, then measure b − Aβ on unit rows.
1140        let dense = cone
1141            .to_dense()
1142            .expect("dense")
1143            .canonicalized()
1144            .expect("canon");
1145        let values = dense.a.dot(&beta);
1146        let mut worst = 0.0_f64;
1147        let mut worst_row = None;
1148        for r in 0..values.len() {
1149            let v = dense.b[r] - values[r];
1150            if v > worst {
1151                worst = v;
1152                worst_row = Some(r);
1153            }
1154        }
1155        assert!((violation - worst).abs() < 1e-14);
1156        assert_eq!(row, worst_row);
1157        assert!(violation > 0.0, "fixture must have a violated row");
1158    }
1159
1160    #[test]
1161    fn max_feasible_step_matches_scalar_ratio_test() {
1162        let cone = cone_fixture();
1163        let set = ConstraintSet::KhatriRaoCone(cone);
1164        // Feasible start: shape rows of A strictly positive functionals.
1165        // A = [[0, 0], [1, 0.1], [1, 0.1]] → α values Ψ·(1, 0.1):
1166        // (1.05, 1.9, 0.3) — all positive for both slots.
1167        let beta = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
1168        // Direction pushing slot 0 observation 2 down: δA_{1,:} = (0, −1) →
1169        // rate = ψ_2 · (0, −1) = −3; slack = 0.3 → t = 0.1. All other rows
1170        // untouched (rate 0 for slot 1, rates −0.5/1 for slot 0 rows 0/1:
1171        // row 0 rate = ψ_0·(0,−1) = −0.5, slack 1.05 → t = 2.1).
1172        let delta = array![0.0_f64, 0.0, 0.0, -1.0, 0.0, 0.0];
1173        let (step, blocking) = set
1174            .max_feasible_step(beta.view(), delta.view(), &[])
1175            .expect("step");
1176        assert!((step - 0.1).abs() < 1e-14, "expected 0.1, got {step}");
1177        assert_eq!(blocking, Some(2));
1178        // Skipping the blocking row exposes the next ratio (row 0, t = 2.1 → clamped to 1).
1179        let (step_skipped, blocking_skipped) = set
1180            .max_feasible_step(beta.view(), delta.view(), &[2])
1181            .expect("step skipped");
1182        assert!((step_skipped - 1.0).abs() < 1e-14);
1183        assert_eq!(blocking_skipped, None);
1184    }
1185
1186    #[test]
1187    fn gather_rows_places_factor_rows_in_the_coupled_slot() {
1188        let cone = cone_fixture();
1189        // Row id 4 = slot 1 (A row 2), observation 1 → ψ = (2, −1) in cols 4..6.
1190        let gathered = cone.gather_rows(&[4]).expect("gather");
1191        assert_eq!(gathered.a.nrows(), 1);
1192        assert_eq!(gathered.a.ncols(), 6);
1193        let expected = [0.0, 0.0, 0.0, 0.0, 2.0, -1.0];
1194        for (j, &e) in expected.iter().enumerate() {
1195            assert_eq!(gathered.a[[0, j]], e);
1196        }
1197        assert_eq!(gathered.b[0], 0.0);
1198    }
1199
1200    #[test]
1201    fn constructor_rejects_bad_coupled_rows() {
1202        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
1203        assert!(KhatriRaoConeConstraints::new(Arc::new(psi.clone()), vec![3], 3).is_err());
1204        assert!(KhatriRaoConeConstraints::new(Arc::new(psi.clone()), vec![1, 1], 3).is_err());
1205        assert!(KhatriRaoConeConstraints::new(Arc::new(psi), vec![], 3).is_err());
1206    }
1207
1208    #[test]
1209    fn shifted_to_delta_matches_dense_shift() {
1210        let cone = cone_fixture();
1211        let set = ConstraintSet::KhatriRaoCone(cone);
1212        let beta = beta_fixture();
1213        let shifted = set.shifted_to_delta(beta.view()).expect("shift");
1214        // Oracle: dense shift b' = b − Aβ.
1215        let dense = set.to_dense().expect("dense");
1216        let expected_b = &dense.b - &dense.a.dot(&beta);
1217        for row in 0..set.nrows() {
1218            assert!(
1219                (shifted.bound(row).expect("bound") - expected_b[row]).abs() < 1e-14,
1220                "shifted bound mismatch at row {row}"
1221            );
1222        }
1223        // The delta system at δ = 0 has slack equal to the original at β.
1224        let zero = Array1::<f64>::zeros(set.ncols());
1225        let (viol_delta, row_delta) = shifted
1226            .max_scaled_violation(zero.view())
1227            .expect("delta violation");
1228        let (viol_orig, row_orig) = set.max_scaled_violation(beta.view()).expect("violation");
1229        assert!((viol_delta - viol_orig).abs() < 1e-14);
1230        assert_eq!(row_delta, row_orig);
1231    }
1232
1233    #[test]
1234    fn block_diagonal_composes_ids_bounds_and_values() {
1235        // Block 0: dense 2-row system on columns 0..2; block 1: cone on 2..8.
1236        let dense = LinearInequalityConstraints::new(
1237            array![[1.0_f64, 0.0], [0.0, -2.0]],
1238            array![0.5_f64, -1.0],
1239        )
1240        .expect("dense block");
1241        let cone = cone_fixture();
1242        let joint = ConstraintSet::block_diagonal(
1243            vec![
1244                PlacedConstraintBlock {
1245                    col_start: 0,
1246                    set: ConstraintSet::Dense(dense.clone()),
1247                },
1248                PlacedConstraintBlock {
1249                    col_start: 2,
1250                    set: ConstraintSet::KhatriRaoCone(cone.clone()),
1251                },
1252            ],
1253            8,
1254        )
1255        .expect("joint");
1256        assert_eq!(joint.nrows(), 2 + 6);
1257        assert_eq!(joint.ncols(), 8);
1258        let mut beta = Array1::<f64>::zeros(8);
1259        beta[0] = 2.0;
1260        beta[1] = 1.0;
1261        beta.slice_mut(ndarray::s![2..8]).assign(&beta_fixture());
1262        let values = joint.values(beta.view()).expect("values");
1263        assert!((values[0] - 2.0).abs() < 1e-15);
1264        assert!((values[1] + 2.0).abs() < 1e-15);
1265        let cone_values = cone.values(beta_fixture().view()).expect("cone values");
1266        for (idx, &cv) in cone_values.iter().enumerate() {
1267            assert!((values[2 + idx] - cv).abs() < 1e-15);
1268        }
1269        assert_eq!(joint.bound(0).expect("b0"), 0.5);
1270        assert_eq!(joint.bound(2).expect("b2"), 0.0);
1271        // Gathered joint row 3 (= cone row 1) occupies columns 2 + [2..4).
1272        let gathered = joint.gather_rows(&[3]).expect("gather");
1273        assert_eq!(gathered.a.ncols(), 8);
1274        assert_eq!(gathered.a[[0, 4]], 2.0);
1275        assert_eq!(gathered.a[[0, 5]], -1.0);
1276        // Overlapping ranges are rejected.
1277        assert!(
1278            ConstraintSet::block_diagonal(
1279                vec![
1280                    PlacedConstraintBlock {
1281                        col_start: 0,
1282                        set: ConstraintSet::Dense(dense.clone()),
1283                    },
1284                    PlacedConstraintBlock {
1285                        col_start: 1,
1286                        set: ConstraintSet::Dense(dense),
1287                    },
1288                ],
1289                8,
1290            )
1291            .is_err()
1292        );
1293    }
1294
1295    #[test]
1296    fn zero_factor_rows_are_vacuous_not_violations() {
1297        // Ψ with an all-zero observation row: 0ᵀβ ≥ 0 is vacuous and must be
1298        // skipped by violation and ratio sweeps (norm 0), matching the dense
1299        // canonicalization contract for zero rows with b ≤ 0.
1300        let psi = array![[0.0_f64, 0.0], [1.0, 1.0]];
1301        let cone = KhatriRaoConeConstraints::new(Arc::new(psi), vec![1], 2).expect("cone");
1302        let set = ConstraintSet::KhatriRaoCone(cone);
1303        let beta = array![0.0_f64, 0.0, -5.0, 4.0];
1304        // Slot 0: values (0, −1). Row 0 vacuous; row 1 violated by 1/√2.
1305        let (violation, row) = set.max_scaled_violation(beta.view()).expect("violation");
1306        assert_eq!(row, Some(1));
1307        assert!((violation - 1.0 / 2.0_f64.sqrt()).abs() < 1e-14);
1308    }
1309
1310    /// `β ≥ 0` on two coordinates, expressed with a deliberately non-unit row
1311    /// so the scaled/raw distinction is observable.
1312    fn scaled_box() -> ConstraintSet {
1313        // Row 0: 1e-3·β₀ ≥ 0 (‖a‖ = 1e-3). Row 1: β₁ ≥ 0 (‖a‖ = 1).
1314        ConstraintSet::Dense(
1315            LinearInequalityConstraints::new(
1316                array![[1.0e-3_f64, 0.0], [0.0, 1.0]],
1317                Array1::<f64>::zeros(2),
1318            )
1319            .expect("scaled box"),
1320        )
1321    }
1322
1323    /// gam#2719, the headline: at a coordinate sitting EXACTLY on its bound, a
1324    /// drift far below the feasibility contract must not crush the step. The
1325    /// exact ratio test answers 0 (its numerator is the exact slack); the
1326    /// contract ratio test answers 1, because the endpoint of the full step is
1327    /// a point this very carrier calls feasible.
1328    #[test]
1329    fn a_sub_tolerance_drift_off_an_active_row_does_not_limit_the_step() {
1330        let set = scaled_box();
1331        let beta = array![0.0_f64, 0.0];
1332        let direction = array![1.0_f64, -1.0e-15];
1333
1334        let (exact, blocking) = set
1335            .max_feasible_step(beta.view(), direction.view(), &[])
1336            .expect("exact ratio test");
1337        assert_eq!(exact, 0.0, "the exact rule crushes the step");
1338        assert_eq!(blocking, Some(1));
1339
1340        let contract = set
1341            .max_contract_feasible_step(beta.view(), direction.view())
1342            .expect("contract ratio test");
1343        assert_eq!(contract.fraction, 1.0);
1344        assert_eq!(contract.blocking_row, None);
1345
1346        // And the claim the relief rests on: the endpoint really is feasible.
1347        let endpoint = &beta + &direction;
1348        let (violation, _) = set
1349            .max_scaled_violation(endpoint.view())
1350            .expect("endpoint violation");
1351        assert!(violation <= PRIMAL_FEASIBILITY_TOL);
1352    }
1353
1354    /// The relief is bounded by the contract, not open-ended: a drift one
1355    /// order ABOVE the tolerance still blocks, and blocks at the true
1356    /// boundary (fraction 0 here, since the slack is exactly 0).
1357    #[test]
1358    fn a_drift_above_the_contract_still_blocks_at_the_true_boundary() {
1359        let set = scaled_box();
1360        let beta = array![0.0_f64, 0.0];
1361        let contract = set
1362            .max_contract_feasible_step(beta.view(), array![0.0_f64, -1.0e-7].view())
1363            .expect("contract ratio test");
1364        assert_eq!(contract.fraction, 0.0);
1365        assert_eq!(contract.blocking_row, Some(1));
1366        assert!(contract.is_blocked_by_active_face());
1367    }
1368
1369    /// A healthy interior step is limited exactly where the exact rule limits
1370    /// it: the contract clause only ever fires on sub-tolerance excursions, so
1371    /// ordinary fraction-to-boundary behaviour is untouched.
1372    #[test]
1373    fn an_interior_iterate_gets_the_ordinary_fraction_to_boundary() {
1374        let set = scaled_box();
1375        let beta = array![1.0_f64, 0.25];
1376        let direction = array![0.0_f64, -1.0];
1377        let contract = set
1378            .max_contract_feasible_step(beta.view(), direction.view())
1379            .expect("contract ratio test");
1380        assert_eq!(contract.blocking_row, Some(1));
1381        assert!((contract.fraction - 0.25).abs() < 1e-15);
1382        let (exact, _) = set
1383            .max_feasible_step(beta.view(), direction.view(), &[])
1384            .expect("exact ratio test");
1385        assert!((contract.fraction - exact).abs() < 1e-15);
1386    }
1387
1388    /// gam#2721, asked of all three rules at once: hand each of them the
1389    /// MAXIMALLY bad argument — every component `NaN` — and require a refusal.
1390    ///
1391    /// Each arm carries a positive control on the same fixture first, so a
1392    /// green cannot come from the fixture never reaching the rule: the finite
1393    /// direction must be evaluated and clipped, and the finite violating
1394    /// iterate must be measured as violating.
1395    #[test]
1396    fn every_feasibility_rule_refuses_an_argument_that_is_not_a_number() {
1397        let set = scaled_box();
1398        let interior = array![1.0_f64, 1.0];
1399        let nan = array![f64::NAN, f64::NAN];
1400
1401        // --- the contract ratio test ---
1402        let clipped = set
1403            .max_contract_feasible_step(interior.view(), array![0.0_f64, -2.0].view())
1404            .expect("a finite binding direction must be evaluated");
1405        assert!(
1406            clipped.fraction > 0.0 && clipped.fraction < 1.0,
1407            "positive control: a binding finite direction must clip, got {}",
1408            clipped.fraction
1409        );
1410        match set.max_contract_feasible_step(interior.view(), nan.view()) {
1411            Err(ContractFeasibleStepError::NonFinite { row, .. }) => {
1412                assert_eq!(row, 0, "the refusal must name the offending row");
1413            }
1414            other => panic!("a NaN step must be refused, got {other:?}"),
1415        }
1416
1417        // --- the exact ratio test (the feasible-chord clipper's rule) ---
1418        let (exact, blocking) = set
1419            .max_feasible_step(interior.view(), array![0.0_f64, -2.0].view(), &[])
1420            .expect("a finite binding direction must be evaluated");
1421        assert!(
1422            exact > 0.0 && exact < 1.0,
1423            "positive control: a binding finite direction must clip, got {exact}"
1424        );
1425        assert_eq!(blocking, Some(1));
1426        let refusal = set
1427            .max_feasible_step(interior.view(), nan.view(), &[])
1428            .expect_err("a NaN chord must be refused, not reported as fully feasible");
1429        assert!(
1430            refusal.contains("max_feasible_step") && refusal.contains("cannot be decided"),
1431            "the refusal must name the rule and the condition, got: {refusal}"
1432        );
1433
1434        // --- the violation sweep (THE feasibility verdict on an iterate) ---
1435        let (violation, row) = set
1436            .max_scaled_violation(array![-1.0_f64, -0.5].view())
1437            .expect("a finite iterate must be measured");
1438        assert!(
1439            violation > 0.0 && row.is_some(),
1440            "positive control: a violating finite iterate must be seen as violating"
1441        );
1442        let refusal = set
1443            .max_scaled_violation(nan.view())
1444            .expect_err("a NaN iterate must be refused, not reported as feasible");
1445        assert!(
1446            refusal.contains("max_scaled_violation") && refusal.contains("cannot be decided"),
1447            "the refusal must name the rule and the condition, got: {refusal}"
1448        );
1449    }
1450
1451    /// The disposition the refusal must NOT swallow: a VACUOUS row (`‖a‖ = 0`
1452    /// with a non-positive bound) is `0 ≥ b`, true for every `β`. It is
1453    /// perfectly decidable and each rule already has its own answer for it —
1454    /// skip. A refusal placed at the wrong point, or written over the wrong
1455    /// quantity, turns that row into a hard error, so the `NaN` arms above are
1456    /// paired with this one.
1457    #[test]
1458    fn a_vacuous_row_keeps_its_own_disposition_and_is_not_refused() {
1459        // Row 0: 0ᵀβ ≥ 0 (vacuous). Row 1: β₁ ≥ 0.
1460        let set = ConstraintSet::Dense(
1461            LinearInequalityConstraints::new(
1462                array![[0.0_f64, 0.0], [0.0, 1.0]],
1463                Array1::<f64>::zeros(2),
1464            )
1465            .expect("vacuous-row system"),
1466        );
1467        let beta = array![-1.0e9_f64, 1.0];
1468
1469        let (violation, row) = set
1470            .max_scaled_violation(beta.view())
1471            .expect("a vacuous row must be decided, not refused");
1472        assert_eq!(violation, 0.0, "a vacuous row cannot be violated");
1473        assert_eq!(row, None);
1474
1475        // The vacuous row must not limit the step either; the real one still
1476        // does, at its exact ratio.
1477        let direction = array![-1.0e12_f64, -2.0];
1478        let (exact, blocking) = set
1479            .max_feasible_step(beta.view(), direction.view(), &[])
1480            .expect("a vacuous row must be decided, not refused");
1481        assert!((exact - 0.5).abs() < 1e-15, "expected 0.5, got {exact}");
1482        assert_eq!(blocking, Some(1));
1483
1484        let contract = set
1485            .max_contract_feasible_step(beta.view(), direction.view())
1486            .expect("a vacuous row must be decided, not refused");
1487        assert!((contract.fraction - 0.5).abs() < 1e-15);
1488        assert_eq!(contract.blocking_row, Some(1));
1489    }
1490
1491    /// The tolerance is a SCALED one. Row 0 has ‖a‖ = 1e-3, so a raw drift of
1492    /// −1e-8 on β₀ is a scaled drift of −1e-8 · 1e-3 / 1e-3 = −1e-8 · … — the
1493    /// point being that the rule must divide by ‖a‖ on BOTH slack and drift,
1494    /// or the same geometric step gets different verdicts on differently
1495    /// normalized rows. Two carriers that differ only by a positive per-row
1496    /// rescaling must give the identical fraction.
1497    #[test]
1498    fn the_fraction_is_invariant_to_per_row_rescaling() {
1499        let unit = ConstraintSet::Dense(
1500            LinearInequalityConstraints::new(
1501                array![[1.0_f64, 0.0], [0.0, 1.0]],
1502                Array1::<f64>::zeros(2),
1503            )
1504            .expect("unit box"),
1505        );
1506        let beta = array![0.5_f64, 3.0];
1507        let direction = array![-1.0_f64, 0.25];
1508        let scaled = scaled_box();
1509        let a = unit
1510            .max_contract_feasible_step(beta.view(), direction.view())
1511            .expect("unit");
1512        let b = scaled
1513            .max_contract_feasible_step(beta.view(), direction.view())
1514            .expect("scaled");
1515        assert_eq!(a.blocking_row, b.blocking_row);
1516        assert!((a.fraction - b.fraction).abs() < 1e-15);
1517        assert!((a.fraction - 0.5).abs() < 1e-15);
1518    }
1519
1520    /// A round-off-negative slack inside the band is AT the boundary, not a
1521    /// violation: the iterate is accepted and the step is limited at the true
1522    /// boundary (fraction 0), never reported as an infeasible iterate.
1523    #[test]
1524    fn an_in_band_negative_slack_is_a_boundary_not_an_infeasible_iterate() {
1525        let set = scaled_box();
1526        let beta = array![0.0_f64, -1.0e-9];
1527        let contract = set
1528            .max_contract_feasible_step(beta.view(), array![0.0_f64, -1.0].view())
1529            .expect("in-band slack is feasible");
1530        assert_eq!(contract.fraction, 0.0);
1531        assert_eq!(contract.blocking_row, Some(1));
1532
1533        // One order out of the band IS an infeasible iterate, and stays loud.
1534        let outside = array![0.0_f64, -1.0e-7];
1535        match set.max_contract_feasible_step(outside.view(), array![0.0_f64, 1.0].view()) {
1536            Err(ContractFeasibleStepError::InfeasibleIterate { row, scaled_slack }) => {
1537                assert_eq!(row, 1);
1538                assert!((scaled_slack + 1.0e-7).abs() < 1e-20);
1539            }
1540            other => panic!("expected an infeasible-iterate report, got {other:?}"),
1541        }
1542    }
1543
1544    /// Repeated full steps whose excursion is individually sub-tolerance
1545    /// cannot walk the iterate past the contract: the admitted violation is
1546    /// bounded by the tolerance for any number of steps.
1547    #[test]
1548    fn sub_tolerance_relief_cannot_accumulate_past_the_contract() {
1549        let set = scaled_box();
1550        let direction = array![0.0_f64, -2.0e-9];
1551        let mut beta = array![0.0_f64, 0.0];
1552        for step in 0..64 {
1553            let contract = set
1554                .max_contract_feasible_step(beta.view(), direction.view())
1555                .unwrap_or_else(|e| panic!("step {step} must keep a feasible origin: {e}"));
1556            beta = &beta + &(&direction * contract.fraction);
1557            let (violation, _) = set
1558                .max_scaled_violation(beta.view())
1559                .expect("violation sweep");
1560            assert!(
1561                violation <= PRIMAL_FEASIBILITY_TOL,
1562                "step {step} left scaled violation {violation:.3e} outside the contract"
1563            );
1564        }
1565    }
1566
1567    /// The factored cone answers identically to its dense materialization —
1568    /// the ratio test must not be a dense-only rule.
1569    #[test]
1570    fn cone_and_dense_agree_on_the_contract_fraction() {
1571        let cone = cone_fixture();
1572        let set = ConstraintSet::KhatriRaoCone(cone.clone());
1573        let dense = ConstraintSet::Dense(cone.to_dense().expect("dense"));
1574        // A_{1,:} = A_{2,:} = (1, 1) makes every cone functional positive, so
1575        // the ratio test has a feasible origin; the direction pulls A_{1,:}
1576        // toward the ψ₁ = (2, −1) face, which binds first at t = 1/2.
1577        let beta = array![9.0_f64, -4.0, 1.0, 1.0, 1.0, 1.0];
1578        let direction = array![0.0_f64, 0.0, -1.0, 0.0, 0.0, 0.0];
1579        let via_cone = set
1580            .max_contract_feasible_step(beta.view(), direction.view())
1581            .expect("cone");
1582        let via_dense = dense
1583            .max_contract_feasible_step(beta.view(), direction.view())
1584            .expect("dense");
1585        assert_eq!(via_cone.blocking_row, via_dense.blocking_row);
1586        assert!((via_cone.fraction - via_dense.fraction).abs() < 1e-14);
1587        assert_eq!(via_cone.blocking_row, Some(1));
1588        assert!((via_cone.fraction - 0.5).abs() < 1e-14);
1589    }
1590
1591    /// A vacuous row with a positive bound is an empty feasible set; no step
1592    /// fraction exists and the ratio test says so instead of returning 1.
1593    #[test]
1594    fn a_vacuous_row_with_a_positive_bound_has_no_feasible_origin() {
1595        let set = ConstraintSet::Dense(
1596            LinearInequalityConstraints::new(array![[0.0_f64, 0.0]], array![1.0_f64])
1597                .expect("vacuous row"),
1598        );
1599        match set.max_contract_feasible_step(array![1.0_f64, 1.0].view(), array![1.0_f64, 0.0].view())
1600        {
1601            Err(ContractFeasibleStepError::InfeasibleIterate { row, scaled_slack }) => {
1602                assert_eq!(row, 0);
1603                assert_eq!(scaled_slack, f64::NEG_INFINITY);
1604            }
1605            other => panic!("expected an empty-feasible-set report, got {other:?}"),
1606        }
1607    }
1608
1609    #[test]
1610    fn width_mismatches_are_reported_before_any_arithmetic() {
1611        let set = scaled_box();
1612        match set.max_contract_feasible_step(array![0.0_f64].view(), array![0.0_f64, 0.0].view()) {
1613            Err(ContractFeasibleStepError::Dimension {
1614                beta,
1615                direction,
1616                expected,
1617            }) => {
1618                assert_eq!((beta, direction, expected), (1, 2, 2));
1619            }
1620            other => panic!("expected a dimension report, got {other:?}"),
1621        }
1622    }
1623}