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 rayon::iter::{IntoParallelIterator, ParallelIterator};
27use std::sync::Arc;
28
29/// Primal-feasibility tolerance of the inequality-constrained active-set Newton
30/// solver, measured in the unit-normalized row metric this module defines:
31/// a point `β` is feasible for a [`ConstraintSet`] exactly when
32///
33/// ```text
34/// max_r  (b_r − a_r·β) / ‖a_r‖  ≤  PRIMAL_FEASIBILITY_TOL
35/// ```
36///
37/// over the non-vacuous rows — the quantity [`ConstraintSet::max_scaled_violation`]
38/// returns. This is the ONE definition of "feasible" in the codebase; the solver
39/// certifies its returned iterate against it, every entry gate admits against it,
40/// and `ConstraintSet::max_contract_feasible_step` sizes steps against it.
41///
42/// It lives beside the metric rather than in the solver because the two are the
43/// same statement: the metric says what is measured, this says at what resolution.
44/// `gam_solve::active_set` re-exports it as `ACTIVE_SET_PRIMAL_FEASIBILITY_TOL`.
45///
46/// Any consumer that re-derives a RAW (un-scaled) feasibility tolerance from a
47/// returned iterate must scale this value by the per-row normalization the
48/// constraint builder applied; demanding tighter feasibility than this is
49/// inconsistent with the solver contract and will spuriously reject valid
50/// boundary solutions (gam#2719: a step rule that demanded exact feasibility
51/// refused 314 steps that violated nothing at this tolerance).
52pub const PRIMAL_FEASIBILITY_TOL: f64 = 1e-8;
53
54/// Can this row's feasibility be DECIDED by comparison at all?
55///
56/// EVERY feasibility rule in this module decides with an ordering predicate on
57/// per-row quantities — `slack < −tol`, `drift ≥ 0`, `t < step`,
58/// `violation > worst` — and EVERY one of those is `false` for `NaN`. A row
59/// carrying a `NaN` therefore contributes NOTHING to any of those minima and
60/// maxima, and the rule answers with its neutral element: "take the whole
61/// step", "nothing is violated". That is the exact opposite of the truth, and
62/// it is gam#2721: a step with a `NaN` component was certified at `α = 1.0`,
63/// and its caller rejects only `!α.is_finite() || α ≤ 0.0`, neither of which
64/// `1.0` is.
65///
66/// The quantities are therefore tested BEFORE they are compared, and a row that
67/// cannot be decided is refused by name rather than skipped. The predicate is
68/// exported — rather than re-written at each site — because the defect WAS the
69/// rule existing in several copies and being repaired in one of them: the two
70/// fraction-to-boundary rules here, the violation sweep here, the saddle-escape
71/// chord truncation in `gam-custom-family`, and the Bernoulli marginal-slope
72/// segment cap in `gam-models` all decide with the same comparisons.
73///
74/// `NaN` is the value that cannot be compared, but it is not the only value
75/// that must be refused. An infinite drift passes `drift ≥ 0` and an infinite
76/// iterate value drives the violation to `−∞`, both of which read as "this row
77/// does not object" for an argument that is not a point. And the carrier's own
78/// constructor already holds the same line —
79/// `LinearInequalityConstraints::new` rejects a non-finite `A` or `b` with the
80/// identical reason — so requiring finiteness here keeps the row descriptors
81/// and the per-iterate quantities under ONE rule rather than two.
82///
83/// A `NaN` `row_norm` additionally defeats the `norm <= 0.0` vacuity test that
84/// would otherwise be the branch to catch it, which is why the norm is checked
85/// here and not left to that branch.
86///
87/// This does NOT collide with the legitimately-vacuous row: `‖a‖ = 0` with a
88/// bound at or below zero is finite, passes here, and keeps its own
89/// disposition in each rule.
90/// Where one sweep reads a row's scaling denominator and right-hand side.
91///
92/// `row_norm(row)?` and `bound(row)?` are `Result`-returning methods that
93/// re-derive the row's carrier, slot and index on every call. The sweep calls
94/// them once per row, and on the large-scale CTN cone (1.6 M rows) they were
95/// 34 % of the profile — more than the products they scale. The factored cone
96/// answers both from slices instead; every other carrier keeps its accessors,
97/// so there is still exactly one scan and one decidability contract (gam#979).
98enum RowMetrics<'a> {
99    /// The factored cone: `norms[row % tile]` with `bounds` indexed by row,
100    /// `0` when the cone is homogeneous.
101    Tiled {
102        norms: &'a [f64],
103        tile: usize,
104        bounds: Option<&'a [f64]>,
105    },
106    /// Anything else: through the carrier's own accessors.
107    Carrier(&'a ConstraintSet),
108}
109
110impl RowMetrics<'_> {
111    #[inline]
112    fn read(&self, row: usize) -> Result<(f64, f64), String> {
113        match self {
114            RowMetrics::Tiled {
115                norms,
116                tile,
117                bounds,
118            } => {
119                let norm = norms[row % tile];
120                let bound = bounds.map_or(0.0, |values| values[row]);
121                Ok((norm, bound))
122            }
123            RowMetrics::Carrier(set) => Ok((set.row_norm(row)?, set.bound(row)?)),
124        }
125    }
126}
127
128/// Why a [`ConstraintSet::max_scaled_violation`] sweep stops at a row.
129///
130/// The serial loop this replaces returned at the first such row in index
131/// order; the parallel sweep carries the smallest index instead, so the
132/// verdict does not depend on the row split.
133#[derive(Clone, Debug)]
134enum SweepTerminal {
135    /// The row's own norm or bound could not be read; carries the carrier's
136    /// own refusal so the sweep does not swallow it.
137    RowUnavailable(String),
138    /// `gam#2721`: a non-finite norm, bound or value. Feasibility of an
139    /// iterate that is not a number is undefined, and every comparison in the
140    /// sweep is false for `NaN`, so the row cannot be skipped.
141    Undecidable { norm: f64, bound: f64, value: f64 },
142    /// `0ᵀβ ≥ b` with `b > 0`: unsatisfiable by any `β`.
143    VacuousRowWithPositiveBound,
144}
145
146/// Running state of the row sweep: the first terminal row in index order, and
147/// the largest scaled violation with the smallest row that attains it.
148struct ScaledViolationSweep {
149    terminal: Option<(usize, SweepTerminal)>,
150    worst: f64,
151    worst_row: Option<usize>,
152}
153
154impl ScaledViolationSweep {
155    fn none() -> Self {
156        Self {
157            terminal: None,
158            worst: 0.0,
159            worst_row: None,
160        }
161    }
162
163    fn record_terminal(&mut self, row: usize, terminal: SweepTerminal) {
164        let keep = match self.terminal {
165            Some((seen, _)) => row < seen,
166            None => true,
167        };
168        if keep {
169            self.terminal = Some((row, terminal));
170        }
171    }
172
173    fn record_violation(&mut self, row: usize, violation: f64) {
174        // `>` alone reproduces the serial loop's smallest-index tie-break only
175        // while the rows arrive in order; the merge below restores it across
176        // chunks.
177        if violation > self.worst {
178            self.worst = violation;
179            self.worst_row = Some(row);
180        }
181    }
182
183    fn merge(mut self, other: Self) -> Self {
184        if let Some((row, terminal)) = other.terminal {
185            self.record_terminal(row, terminal);
186        }
187        let take_other = match (other.worst > self.worst, other.worst == self.worst) {
188            (true, _) => true,
189            (false, true) => match (other.worst_row, self.worst_row) {
190                (Some(candidate), Some(held)) => candidate < held,
191                (Some(_), None) => true,
192                _ => false,
193            },
194            _ => false,
195        };
196        if take_other {
197            self.worst = other.worst;
198            self.worst_row = other.worst_row;
199        }
200        self
201    }
202
203    fn verdict(self) -> Result<(f64, Option<usize>), String> {
204        match self.terminal {
205            Some((row, SweepTerminal::RowUnavailable(error))) => Err(format!(
206                "ConstraintSet::max_scaled_violation: row {row} has no readable norm or \
207                 bound: {error}"
208            )),
209            Some((
210                row,
211                SweepTerminal::Undecidable {
212                    norm,
213                    bound,
214                    value,
215                },
216            )) => Err(format!(
217                "ConstraintSet::max_scaled_violation: row {row} cannot be decided \
218                 (row norm {norm:.3e}, bound {bound:.3e}, value {value:.3e}); \
219                 feasibility of a non-finite iterate is undefined and every \
220                 comparison in the sweep is false for NaN, so the row cannot \
221                 be skipped (gam#2721)"
222            )),
223            Some((row, SweepTerminal::VacuousRowWithPositiveBound)) => {
224                Ok((f64::INFINITY, Some(row)))
225            }
226            None => Ok((self.worst, self.worst_row)),
227        }
228    }
229}
230
231pub fn feasibility_quantities_are_finite(quantities: &[f64]) -> bool {
232    quantities.iter().all(|q| q.is_finite())
233}
234
235/// The contract-feasible ratio test itself, over already-evaluated constraint
236/// values, so every carrier — the dense system, the factored cone, the
237/// block-diagonal composition — runs the SAME arithmetic without any of them
238/// having to be materialized as another.
239///
240/// `values[r]` is `a_r·β`, `directional[r]` is `a_r·δ` (the constraint
241/// functional is linear, so its value at `δ` IS the directional derivative).
242/// The rule is documented on [`ConstraintSet::max_contract_feasible_step`].
243pub(crate) fn contract_feasible_step_over_rows<B, N>(
244    values: &Array1<f64>,
245    directional: &Array1<f64>,
246    bound: B,
247    row_norm: N,
248) -> Result<ContractFeasibleStep, ContractFeasibleStepError>
249where
250    B: Fn(usize) -> Result<f64, String>,
251    N: Fn(usize) -> Result<f64, String>,
252{
253    let tol = PRIMAL_FEASIBILITY_TOL;
254    let mut limit = ContractFeasibleStep::UNLIMITED;
255    for row in 0..values.len() {
256        let norm = row_norm(row).map_err(ContractFeasibleStepError::Carrier)?;
257        let bound = bound(row).map_err(ContractFeasibleStepError::Carrier)?;
258        // A ROW THAT CANNOT BE COMPARED IS NOT A FEASIBLE ROW (gam#2721) — see
259        // [`feasibility_quantities_are_finite`] for why skipping it certifies
260        // the whole step. Refuse instead, and name the condition: "this row is
261        // not a number" is a different condition from "the current iterate
262        // violates this row", so it gets its own variant rather than being
263        // reported through `InfeasibleIterate`.
264        if !feasibility_quantities_are_finite(&[norm, bound, values[row], directional[row]]) {
265            return Err(ContractFeasibleStepError::NonFinite {
266                row,
267                scaled_slack: (values[row] - bound) / norm,
268                scaled_drift: directional[row] / norm,
269            });
270        }
271        if !(norm.is_finite() && norm > 0.0) {
272            // A vacuous row constrains nothing unless its bound is positive,
273            // in which case the feasible set is empty and no step fraction
274            // exists. Same disposition as the solver's own violation scan.
275            if bound > 0.0 {
276                return Err(ContractFeasibleStepError::InfeasibleIterate {
277                    row,
278                    scaled_slack: f64::NEG_INFINITY,
279                });
280            }
281            continue;
282        }
283        let slack = (values[row] - bound) / norm;
284        let drift = directional[row] / norm;
285        if slack < -tol {
286            return Err(ContractFeasibleStepError::InfeasibleIterate {
287                row,
288                scaled_slack: slack,
289            });
290        }
291        if drift >= 0.0 {
292            continue;
293        }
294        if slack + drift >= -tol {
295            // The endpoint of the FULL step is feasible on this row to the
296            // contract. Nothing to limit.
297            continue;
298        }
299        // `slack ≥ −tol` and `slack + drift < −tol` give `slack < −drift`, so
300        // this ratio is strictly below 1 and non-negative.
301        let fraction = (slack.max(0.0) / -drift).clamp(0.0, 1.0);
302        if fraction < limit.fraction {
303            limit = ContractFeasibleStep {
304                fraction,
305                blocking_row: Some(row),
306                blocking_scaled_slack: slack,
307                blocking_scaled_drift: drift,
308            };
309        }
310    }
311    Ok(limit)
312}
313
314/// Result of the contract-feasible ratio test
315/// (`ConstraintSet::max_contract_feasible_step`).
316#[derive(Clone, Copy, Debug, PartialEq)]
317pub struct ContractFeasibleStep {
318    /// Largest fraction in `[0, 1]` such that `β + fraction·δ` is feasible at
319    /// [`PRIMAL_FEASIBILITY_TOL`]. `1.0` means no row limits the step.
320    ///
321    /// `0.0` is a legitimate, non-exceptional answer: it says a row is active
322    /// at `β` and `δ` points strictly out of it by more than round-off, so no
323    /// positive multiple of `δ` is admissible. The remedy is a projection onto
324    /// the active face, not a smaller `δ` — the ratio test is invariant under
325    /// `δ ↦ cδ` once the numerator is zero, so shrinking a trust radius against
326    /// it cannot converge (gam#2719).
327    pub fraction: f64,
328    /// Row that limited `fraction`, if any.
329    pub blocking_row: Option<usize>,
330    /// Scaled slack `(a·β − b)/‖a‖` of `blocking_row` at `β`.
331    pub blocking_scaled_slack: f64,
332    /// Scaled drift `(a·δ)/‖a‖` of `blocking_row` (strictly negative when a
333    /// row blocks).
334    pub blocking_scaled_drift: f64,
335}
336
337impl ContractFeasibleStep {
338    /// The unlimited answer: the whole direction is admissible.
339    pub const UNLIMITED: Self = Self {
340        fraction: 1.0,
341        blocking_row: None,
342        blocking_scaled_slack: f64::INFINITY,
343        blocking_scaled_drift: 0.0,
344    };
345
346}
347
348/// Why the contract-feasible ratio test could not answer.
349///
350/// Every variant is a violated PRECONDITION of the ratio test, never a small
351/// step: "no admissible step exists" is reported as
352/// [`ContractFeasibleStep::fraction`] `== 0.0`, not as an error.
353#[derive(Clone, Debug, PartialEq)]
354pub enum ContractFeasibleStepError {
355    /// `beta` / `direction` widths disagree with the constraint carrier.
356    Dimension {
357        beta: usize,
358        direction: usize,
359        expected: usize,
360    },
361    /// The CURRENT iterate violates a row by more than
362    /// [`PRIMAL_FEASIBILITY_TOL`], so the ratio test has no feasible origin to
363    /// step from. This is the genuine "infeasible iterate" condition and stays
364    /// loud.
365    InfeasibleIterate { row: usize, scaled_slack: f64 },
366    /// A row cannot be DECIDED by comparison: a non-finite row norm, bound,
367    /// `a·β` or `a·δ`. Reported rather than skipped: every comparison in the
368    /// rule is false for NaN, so a skipped row would silently certify a step
369    /// that is not a number as fully feasible (gam#2721). The reported slack
370    /// and drift are the scaled quantities as computed, so the offending one is
371    /// visible.
372    NonFinite {
373        row: usize,
374        scaled_slack: f64,
375        scaled_drift: f64,
376    },
377    /// The carrier could not evaluate `Aβ` / `Aδ` or a row descriptor.
378    Carrier(String),
379}
380
381impl std::fmt::Display for ContractFeasibleStepError {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match self {
384            ContractFeasibleStepError::Dimension {
385                beta,
386                direction,
387                expected,
388            } => write!(
389                f,
390                "constraint step dimension mismatch: beta={beta}, direction={direction}, constraints={expected}"
391            ),
392            ContractFeasibleStepError::InfeasibleIterate { row, scaled_slack } => write!(
393                f,
394                "current iterate violates constraint row {row}: scaled slack={scaled_slack:.3e} \
395                 below the primal-feasibility contract {PRIMAL_FEASIBILITY_TOL:.3e}"
396            ),
397            ContractFeasibleStepError::NonFinite {
398                row,
399                scaled_slack,
400                scaled_drift,
401            } => write!(
402                f,
403                "constraint row {row} has a non-finite ratio test: scaled slack={scaled_slack:.3e}, \
404                 scaled drift={scaled_drift:.3e}"
405            ),
406            ContractFeasibleStepError::Carrier(reason) => write!(f, "{reason}"),
407        }
408    }
409}
410
411/// Nonnegativity cone `(e_k ⊗ ψ_i)ᵀ β ≥ 0` for a row-major Khatri-Rao block.
412///
413/// The coefficient block is `β = vec(A)` with `A` reshaped row-major as
414/// `p_left × p_cov` (coefficient `A[k, j] = β[k · p_cov + j]`). The cone
415/// constrains the factored linear functionals `α_k(x_i) = ψ_iᵀ A_{k,:}` to be
416/// non-negative for every observation row `i` of `factor` and every
417/// `k ∈ coupled_rows`.
418///
419/// Row identifiers are stable and dense: row `r = s · n + i` where `s` indexes
420/// into `coupled_rows` and `i` is the observation row. Active-set warm starts
421/// therefore survive across iterations exactly as with the dense system.
422#[derive(Clone, Debug)]
423pub struct KhatriRaoConeConstraints {
424    /// Covariate factor `Ψ` (`n × p_cov`).
425    factor: Arc<Array2<f64>>,
426    /// Euclidean norm of each `Ψ` row (unit-normalization denominators).
427    factor_row_norms: Array1<f64>,
428    /// Coefficient rows of `A` (indices into `0..p_left`) that carry the cone.
429    coupled_rows: Vec<usize>,
430    /// Total number of coefficient rows in the block reshape.
431    p_left: usize,
432    /// Per-row right-hand sides. The homogeneous cone has `b ≡ 0`; a
433    /// delta-coordinate solve (`β = β₀ + δ`) shifts them to `−(rowᵀβ₀)`.
434    /// Bounds are `O(nrows)` — cheap even when the matrix is not.
435    bounds: Option<Array1<f64>>,
436}
437
438impl KhatriRaoConeConstraints {
439    pub fn new(
440        factor: Arc<Array2<f64>>,
441        coupled_rows: Vec<usize>,
442        p_left: usize,
443    ) -> Result<Self, String> {
444        if factor.nrows() == 0 || factor.ncols() == 0 {
445            return Err("KhatriRaoConeConstraints: factor must be non-empty".to_string());
446        }
447        if factor.iter().any(|v| !v.is_finite()) {
448            return Err("KhatriRaoConeConstraints: factor must be finite".to_string());
449        }
450        if coupled_rows.is_empty() {
451            return Err(
452                "KhatriRaoConeConstraints: at least one coupled coefficient row is required"
453                    .to_string(),
454            );
455        }
456        let mut seen = vec![false; p_left];
457        for &k in &coupled_rows {
458            if k >= p_left {
459                return Err(format!(
460                    "KhatriRaoConeConstraints: coupled row {k} out of range (p_left = {p_left})"
461                ));
462            }
463            if seen[k] {
464                return Err(format!(
465                    "KhatriRaoConeConstraints: coupled row {k} is duplicated"
466                ));
467            }
468            seen[k] = true;
469        }
470        let factor_row_norms =
471            Array1::from_iter(factor.rows().into_iter().map(|row| row.dot(&row).sqrt()));
472        Ok(Self {
473            factor,
474            factor_row_norms,
475            coupled_rows,
476            p_left,
477            bounds: None,
478        })
479    }
480
481    pub fn factor(&self) -> &Array2<f64> {
482        self.factor.as_ref()
483    }
484
485    pub fn coupled_rows(&self) -> &[usize] {
486        &self.coupled_rows
487    }
488
489    pub fn p_left(&self) -> usize {
490        self.p_left
491    }
492
493    /// One coupled response-row slot as a standalone cone over a single
494    /// `p_cov` coefficient block. The covariate factor remains shared by
495    /// [`Arc`]; only the small row-norm vector and this slot's optional bounds
496    /// are copied. This is the exact block decomposition of an identity-Hessian
497    /// projection, not a reduced-data approximation.
498    pub fn single_coupled_slot(&self, slot: usize) -> Result<Self, String> {
499        if slot >= self.coupled_rows.len() {
500            return Err(format!(
501                "KhatriRaoConeConstraints: coupled slot {slot} out of range ({} slots)",
502                self.coupled_rows.len()
503            ));
504        }
505        let n = self.factor.nrows();
506        let bounds = self
507            .bounds
508            .as_ref()
509            .map(|all| all.slice(ndarray::s![slot * n..(slot + 1) * n]).to_owned());
510        Ok(Self {
511            factor: Arc::clone(&self.factor),
512            factor_row_norms: self.factor_row_norms.clone(),
513            coupled_rows: vec![0],
514            p_left: 1,
515            bounds,
516        })
517    }
518
519    pub fn nrows(&self) -> usize {
520        self.coupled_rows.len() * self.factor.nrows()
521    }
522
523    pub fn ncols(&self) -> usize {
524        self.p_left * self.factor.ncols()
525    }
526
527    /// Decompose a row id into `(coupled-row slot, observation row)`.
528    #[inline]
529    fn split_row_id(&self, row: usize) -> Result<(usize, usize), String> {
530        let n = self.factor.nrows();
531        let slot = row / n;
532        if slot >= self.coupled_rows.len() {
533            return Err(format!(
534                "KhatriRaoConeConstraints: row id {row} out of range ({} rows)",
535                self.nrows()
536            ));
537        }
538        Ok((slot, row % n))
539    }
540
541    /// Raw (un-normalized) constraint values `A β` for the full row set,
542    /// laid out slot-major (`r = s·n + i`).
543    ///
544    /// Cost: one `n × p_cov · p_cov` product per coupled row — never the
545    /// `nrows × ncols` dense system.
546    pub fn values(&self, beta: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
547        let p_cov = self.factor.ncols();
548        if beta.len() != self.ncols() {
549            return Err(format!(
550                "KhatriRaoConeConstraints: beta length {} != {}",
551                beta.len(),
552                self.ncols()
553            ));
554        }
555        let n = self.factor.nrows();
556        let slots = self.coupled_rows.len();
557        // One `Ψ · B` for every slot at once. `Array2::dot(&Array1)` is
558        // ndarray's per-row `unrolled_dot`; `Array2::dot(&Array2)` is a real
559        // matrix product, and this runs on every feasibility sweep of every
560        // trial point at `n = 320000` (gam#979).
561        let mut blocks = Array2::<f64>::zeros((p_cov, slots));
562        for (slot, &k) in self.coupled_rows.iter().enumerate() {
563            blocks
564                .column_mut(slot)
565                .assign(&beta.slice(ndarray::s![k * p_cov..(k + 1) * p_cov]));
566        }
567        let alpha = self.factor.dot(&blocks);
568        let mut out = Array1::<f64>::zeros(self.nrows());
569        for slot in 0..slots {
570            out.slice_mut(ndarray::s![slot * n..(slot + 1) * n])
571                .assign(&alpha.column(slot));
572        }
573        Ok(out)
574    }
575
576    /// Unit-normalization denominator of one row (`‖ψ_i‖`, shared across
577    /// coupled slots). Zero rows are vacuous (`0ᵀβ ≥ 0` always holds) exactly
578    /// like the canonicalized dense system keeps them inert.
579    pub fn row_norm(&self, row: usize) -> Result<f64, String> {
580        let (_, i) = self.split_row_id(row)?;
581        Ok(self.factor_row_norms[i])
582    }
583
584    /// The coefficient columns row `row` acts on, ascending.
585    ///
586    /// Row `(slot, i)` has normal `e_k ⊗ ψ_i` with `k = coupled_rows[slot]`, and
587    /// [`Self::values`] reads exactly the block `β[k·p_cov .. (k+1)·p_cov]`, so
588    /// the support is `k·p_cov + j` over the columns `j` where `ψ_{i,j} ≠ 0`.
589    /// Every other coefficient has a structurally zero coefficient in this row.
590    pub fn row_column_support(&self, row: usize) -> Result<Vec<usize>, String> {
591        let (slot, i) = self.split_row_id(row)?;
592        let p_cov = self.factor.ncols();
593        let base = self.coupled_rows[slot] * p_cov;
594        Ok((0..p_cov)
595            .filter(|&j| self.factor[[i, j]] != 0.0)
596            .map(|j| base + j)
597            .collect())
598    }
599
600    /// Per-row right-hand side (`0` for the homogeneous cone, shifted values
601    /// after [`ConstraintSet::shifted_to_delta`]).
602    pub fn bound(&self, row: usize) -> Result<f64, String> {
603        self.split_row_id(row)?;
604        Ok(self.bounds.as_ref().map_or(0.0, |bounds| bounds[row]))
605    }
606
607    /// The `Ψ`-row norms, one per `i`, shared across every coupled slot.
608    ///
609    /// Read directly by [`RowMetrics::Tiled`]: `row_norm(row)?` is the same
610    /// lookup behind a slot split, a range check and a `Result`, and the
611    /// feasibility sweep does it once per row (gam#979).
612    pub(crate) fn row_norms_slice(&self) -> &[f64] {
613        self.factor_row_norms
614            .as_slice()
615            .expect("factor row norms are contiguous")
616    }
617
618    /// Rows per coupled slot, so `row % tile_rows()` is the `Ψ` row index.
619    pub(crate) fn tile_rows(&self) -> usize {
620        self.factor.nrows()
621    }
622
623    /// The per-row right-hand sides, `None` for the homogeneous cone.
624    pub(crate) fn bounds_slice(&self) -> Option<&[f64]> {
625        self.bounds
626            .as_ref()
627            .map(|bounds| bounds.as_slice().expect("bounds are contiguous"))
628    }
629
630    /// Materialize the requested rows as a dense system (active-set KKT use;
631    /// the id order of `rows` is preserved). Rows come out RAW (un-normalized),
632    /// matching the raw dense construction path; callers that need geometric
633    /// tolerances canonicalize the gathered system.
634    pub fn gather_rows(&self, rows: &[usize]) -> Result<LinearInequalityConstraints, String> {
635        let p_cov = self.factor.ncols();
636        let mut a = Array2::<f64>::zeros((rows.len(), self.ncols()));
637        let mut b = Array1::<f64>::zeros(rows.len());
638        for (out_row, &row) in rows.iter().enumerate() {
639            let (slot, i) = self.split_row_id(row)?;
640            let k = self.coupled_rows[slot];
641            a.row_mut(out_row)
642                .slice_mut(ndarray::s![k * p_cov..(k + 1) * p_cov])
643                .assign(&self.factor.row(i));
644            b[out_row] = self.bound(row)?;
645        }
646        LinearInequalityConstraints::new(a, b)
647    }
648
649    /// Exact dense equivalent of the ENTIRE cone. Test/oracle use only — this
650    /// is the materialization the carrier exists to avoid.
651    pub fn to_dense(&self) -> Result<LinearInequalityConstraints, String> {
652        let all: Vec<usize> = (0..self.nrows()).collect();
653        self.gather_rows(&all)
654    }
655}
656
657/// A row index in a [`ConstraintSet`]'s OWN constraint-row space — the space
658/// addressed by [`ConstraintSet::values`], [`ConstraintSet::bound`] and
659/// [`ConstraintSet::row_norm`], i.e. `0..nrows()`.
660///
661/// This is NOT a coefficient (β) index. The two spaces have different sizes
662/// (`nrows()` vs `ncols()`) and different meanings, and they coincide only in
663/// the special case of a square carrier whose row `r` is exactly the box
664/// `β_r ≥ 0`. A block-diagonal composition breaks that coincidence: its row ids
665/// are the CONCATENATION of the member row counts while its columns are the
666/// concatenation of the member column ranges, so as soon as one member has
667/// `nrows() < ncols()` (a monotone sub-basis alongside unconstrained intercept /
668/// covariate columns) row id `r` of a later block names a β coordinate owned by
669/// an EARLIER block. The newtype exists so that mistake cannot be made silently;
670/// to go from a row to the coefficients it acts on, call
671/// `ConstraintSet::row_column_support`.
672#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
673pub struct ConstraintRowId(pub usize);
674
675impl ConstraintRowId {
676    /// The raw index, for addressing a `values()` / `bound()` / `row_norm()`
677    /// result. Deliberately explicit: reach for this only when indexing
678    /// something that really is in constraint-row space.
679    #[inline]
680    pub fn index(self) -> usize {
681        self.0
682    }
683}
684
685/// One block of a [`ConstraintSet::BlockDiagonal`] composition: an inner set
686/// acting on the coefficient columns `[col_start, col_start + set.ncols())` of
687/// the joint vector.
688#[derive(Clone, Debug)]
689pub struct PlacedConstraintBlock {
690    pub col_start: usize,
691    pub set: ConstraintSet,
692}
693
694/// Closed union of the constraint carriers the blockwise solvers accept.
695#[derive(Clone, Debug)]
696pub enum ConstraintSet {
697    /// Explicit rows, exactly as today.
698    Dense(LinearInequalityConstraints),
699    /// Factored Khatri-Rao nonnegativity cone.
700    KhatriRaoCone(KhatriRaoConeConstraints),
701    /// Block-diagonal composition over disjoint column ranges of a joint
702    /// coefficient vector (the multi-block joint-Newton assembly). Row ids
703    /// are the concatenation of the member row ids in order.
704    BlockDiagonal {
705        blocks: Vec<PlacedConstraintBlock>,
706        total_cols: usize,
707    },
708}
709
710impl ConstraintSet {
711    /// Validated block-diagonal composition: member column ranges must lie
712    /// inside the joint width and must not overlap.
713    pub fn block_diagonal(
714        blocks: Vec<PlacedConstraintBlock>,
715        total_cols: usize,
716    ) -> Result<Self, String> {
717        let mut ranges: Vec<(usize, usize)> = Vec::with_capacity(blocks.len());
718        for block in &blocks {
719            let end = block.col_start + block.set.ncols();
720            if end > total_cols {
721                return Err(format!(
722                    "ConstraintSet::block_diagonal: block columns {}..{} exceed joint width {}",
723                    block.col_start, end, total_cols
724                ));
725            }
726            ranges.push((block.col_start, end));
727        }
728        ranges.sort_unstable();
729        for pair in ranges.windows(2) {
730            if pair[1].0 < pair[0].1 {
731                return Err(format!(
732                    "ConstraintSet::block_diagonal: overlapping column ranges {:?} and {:?}",
733                    pair[0], pair[1]
734                ));
735            }
736        }
737        Ok(ConstraintSet::BlockDiagonal { blocks, total_cols })
738    }
739
740    /// Locate the member block owning a joint row id.
741    fn block_for_row<'a>(
742        blocks: &'a [PlacedConstraintBlock],
743        row: usize,
744    ) -> Result<(&'a PlacedConstraintBlock, usize), String> {
745        let mut offset = 0usize;
746        for block in blocks {
747            let rows = block.set.nrows();
748            if row < offset + rows {
749                return Ok((block, row - offset));
750            }
751            offset += rows;
752        }
753        Err(format!(
754            "ConstraintSet: row {row} out of range ({offset} rows)"
755        ))
756    }
757
758    pub fn nrows(&self) -> usize {
759        match self {
760            ConstraintSet::Dense(dense) => dense.a.nrows(),
761            ConstraintSet::KhatriRaoCone(cone) => cone.nrows(),
762            ConstraintSet::BlockDiagonal { blocks, .. } => {
763                blocks.iter().map(|block| block.set.nrows()).sum()
764            }
765        }
766    }
767
768    pub fn ncols(&self) -> usize {
769        match self {
770            ConstraintSet::Dense(dense) => dense.a.ncols(),
771            ConstraintSet::KhatriRaoCone(cone) => cone.ncols(),
772            ConstraintSet::BlockDiagonal { total_cols, .. } => *total_cols,
773        }
774    }
775
776    /// Raw constraint values `Aβ` (dense) / factored functional values (cone).
777    pub fn values(&self, beta: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
778        match self {
779            ConstraintSet::Dense(dense) => {
780                if beta.len() != dense.a.ncols() {
781                    return Err(format!(
782                        "ConstraintSet: beta length {} != {}",
783                        beta.len(),
784                        dense.a.ncols()
785                    ));
786                }
787                Ok(dense.a.dot(&beta))
788            }
789            ConstraintSet::KhatriRaoCone(cone) => cone.values(beta),
790            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
791                if beta.len() != *total_cols {
792                    return Err(format!(
793                        "ConstraintSet: beta length {} != {}",
794                        beta.len(),
795                        total_cols
796                    ));
797                }
798                let mut out = Array1::<f64>::zeros(self.nrows());
799                let mut offset = 0usize;
800                for block in blocks {
801                    let width = block.set.ncols();
802                    let local = beta.slice(ndarray::s![block.col_start..block.col_start + width]);
803                    let values = block.set.values(local)?;
804                    let rows = values.len();
805                    out.slice_mut(ndarray::s![offset..offset + rows])
806                        .assign(&values);
807                    offset += rows;
808                }
809                Ok(out)
810            }
811        }
812    }
813
814    /// Right-hand sides (`b` dense; cone bounds are zero unless delta-shifted).
815    pub fn bound(&self, row: usize) -> Result<f64, String> {
816        match self {
817            ConstraintSet::Dense(dense) => dense.b.get(row).copied().ok_or_else(|| {
818                format!(
819                    "ConstraintSet: row {row} out of range ({} rows)",
820                    dense.b.len()
821                )
822            }),
823            ConstraintSet::KhatriRaoCone(cone) => cone.bound(row),
824            ConstraintSet::BlockDiagonal { blocks, .. } => {
825                let (block, local) = Self::block_for_row(blocks, row)?;
826                block.set.bound(local)
827            }
828        }
829    }
830
831    pub fn row_norm(&self, row: usize) -> Result<f64, String> {
832        match self {
833            ConstraintSet::Dense(dense) => {
834                if row >= dense.a.nrows() {
835                    return Err(format!(
836                        "ConstraintSet: row {row} out of range ({} rows)",
837                        dense.a.nrows()
838                    ));
839                }
840                let r = dense.a.row(row);
841                Ok(r.dot(&r).sqrt())
842            }
843            ConstraintSet::KhatriRaoCone(cone) => cone.row_norm(row),
844            ConstraintSet::BlockDiagonal { blocks, .. } => {
845                let (block, local) = Self::block_for_row(blocks, row)?;
846                block.set.row_norm(local)
847            }
848        }
849    }
850
851    /// The same constraint system expressed in delta coordinates around
852    /// `beta`: `A(β + δ) ≥ b  ⇔  Aδ ≥ b − Aβ`. The matrix carrier is shared;
853    /// only the `O(nrows)` bounds change.
854    pub fn shifted_to_delta(&self, beta: ArrayView1<'_, f64>) -> Result<Self, String> {
855        let values = self.values(beta)?;
856        match self {
857            ConstraintSet::Dense(dense) => Ok(ConstraintSet::Dense(
858                LinearInequalityConstraints::new(dense.a.clone(), &dense.b - &values)?,
859            )),
860            ConstraintSet::KhatriRaoCone(cone) => {
861                let mut shifted = cone.clone();
862                let base = shifted
863                    .bounds
864                    .take()
865                    .unwrap_or_else(|| Array1::zeros(values.len()));
866                shifted.bounds = Some(&base - &values);
867                Ok(ConstraintSet::KhatriRaoCone(shifted))
868            }
869            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
870                let mut shifted_blocks = Vec::with_capacity(blocks.len());
871                for block in blocks {
872                    let width = block.set.ncols();
873                    let local = beta.slice(ndarray::s![block.col_start..block.col_start + width]);
874                    shifted_blocks.push(PlacedConstraintBlock {
875                        col_start: block.col_start,
876                        set: block.set.shifted_to_delta(local)?,
877                    });
878                }
879                Ok(ConstraintSet::BlockDiagonal {
880                    blocks: shifted_blocks,
881                    total_cols: *total_cols,
882                })
883            }
884        }
885    }
886
887    /// Scaled violation sweep: `max_r (b_r − (Aβ)_r) / ‖a_r‖` restricted to
888    /// non-vacuous rows, plus the arg-max row. Matches the canonicalized dense
889    /// geometry (unit rows) without materializing it.
890    ///
891    /// This is THE feasibility metric: `β` is feasible exactly when the value
892    /// returned here is at or below [`PRIMAL_FEASIBILITY_TOL`].
893    ///
894    /// A vacuous row (`‖a‖ = 0`) with a bound at or below zero is `0 ≥ b`, true
895    /// for every `β`, and contributes nothing. A vacuous row with a POSITIVE
896    /// bound is `0 ≥ b > 0`: no `β` satisfies it, so its violation is infinite
897    /// and the feasible set is empty. Reporting that as `+∞` — rather than
898    /// skipping the row — is what makes this metric agree with
899    /// `ConstraintSetOps::scaled_slack`, which already answers `−∞` for exactly
900    /// this row, and keeps a gate built on this metric from silently admitting
901    /// an unsatisfiable system.
902    ///
903    /// A row that cannot be decided by comparison — a non-finite row norm,
904    /// bound or `a·β` — is refused rather than skipped (gam#2721): feasibility
905    /// of an iterate that is not a number is undefined, and `violation > worst`
906    /// being false for `NaN` would report the neutral `0.0` — "nothing is
907    /// violated" — for exactly the iterate this metric exists to catch.
908    pub fn max_scaled_violation(
909        &self,
910        beta: ArrayView1<'_, f64>,
911    ) -> Result<(f64, Option<usize>), String> {
912        let values = self.values(beta)?;
913        // The sweep is a max over independent rows, and it is THE feasibility
914        // verdict of every active-set solve, so it runs on every trial point.
915        // On the large-scale CTN cone it is 1.6 M rows, and profiling the
916        // preprocessor's reduced-face solve put 92 % of the process inside this
917        // one function on ONE core (gam#979). Rows fan across the pool.
918        //
919        // The serial loop it replaces returned at the FIRST row that ended the
920        // scan — an undecidable row, or a vacuous row with a positive bound —
921        // so the reduction below carries the smallest such row index rather
922        // than whichever thread found one first, and the running maximum breaks
923        // exact ties toward the smaller index. Both make the verdict, the named
924        // row, and the refusal text independent of how the rows were split.
925        let metrics = match self {
926            ConstraintSet::KhatriRaoCone(cone) => RowMetrics::Tiled {
927                norms: cone.row_norms_slice(),
928                tile: cone.tile_rows(),
929                bounds: cone.bounds_slice(),
930            },
931            _ => RowMetrics::Carrier(self),
932        };
933        let sweep = (0..values.len())
934            .into_par_iter()
935            .fold(ScaledViolationSweep::none, |mut sweep, row| {
936                let value = values[row];
937                let (norm, bound) = match metrics.read(row) {
938                    Ok(pair) => pair,
939                    Err(error) => {
940                        sweep.record_terminal(row, SweepTerminal::RowUnavailable(error));
941                        return sweep;
942                    }
943                };
944                // Decidability before comparison (gam#2721): `violation > worst`
945                // is FALSE for `NaN`, so an undecidable row would leave `worst`
946                // at `0.0` and this metric — THE feasibility verdict — would
947                // call an iterate that is not a number feasible. `norm <= 0.0`
948                // is false for a `NaN` norm too, so the vacuous-row branch below
949                // cannot be the one that catches it. Refuse, naming the row and
950                // the quantities.
951                if !feasibility_quantities_are_finite(&[norm, bound, value]) {
952                    sweep.record_terminal(
953                        row,
954                        SweepTerminal::Undecidable {
955                            norm,
956                            bound,
957                            value,
958                        },
959                    );
960                    return sweep;
961                }
962                if norm <= 0.0 {
963                    if bound > 0.0 {
964                        sweep.record_terminal(row, SweepTerminal::VacuousRowWithPositiveBound);
965                    }
966                    return sweep;
967                }
968                sweep.record_violation(row, (bound - value) / norm);
969                sweep
970            })
971            .reduce(ScaledViolationSweep::none, ScaledViolationSweep::merge);
972        sweep.verdict()
973    }
974
975    /// Largest `t ∈ [0, 1]` with `β + t·δ` feasible for every row, together
976    /// with the first blocking row (the EXACT ratio test of a primal
977    /// active-set method — zero tolerance, raw slacks). Rows already violated
978    /// at `β` are reported as blocking at `t = 0`.
979    ///
980    /// This is the *pivot* rule: it answers "where does this chord cross a
981    /// hyperplane in exact arithmetic", and its consumers (the feasible-chord
982    /// clipper) want exactly that. It is NOT the rule for sizing a Newton step
983    /// — a globalization that demands exact feasibility rejects steps this
984    /// carrier's own contract calls feasible. Use
985    /// `ConstraintSet::max_contract_feasible_step` for that.
986    ///
987    /// Like the contract rule, this one is TOTAL (gam#2721): a row that cannot
988    /// be decided by comparison — a non-finite row norm, bound, `a·β` or `a·δ`
989    /// — and that was not explicitly skipped is refused, because every
990    /// comparison it would otherwise feed is false for `NaN` and the answer
991    /// would be an unlimited `t = 1`.
992    pub fn max_feasible_step(
993        &self,
994        beta: ArrayView1<'_, f64>,
995        delta: ArrayView1<'_, f64>,
996        skip_rows: &[usize],
997    ) -> Result<(f64, Option<usize>), String> {
998        let values = self.values(beta)?;
999        let directional = self.values(delta)?;
1000        let mut skip = vec![false; values.len()];
1001        for &row in skip_rows {
1002            if row < skip.len() {
1003                skip[row] = true;
1004            }
1005        }
1006        let mut step = 1.0_f64;
1007        let mut blocking = None;
1008        for row in 0..values.len() {
1009            if skip[row] {
1010                continue;
1011            }
1012            let norm = self.row_norm(row)?;
1013            let bound = self.bound(row)?;
1014            let value = values[row];
1015            let rate = directional[row];
1016            // Same decidability requirement as the contract rule (gam#2721): a
1017            // `NaN` fails `rate >= 0.0` AND `t < step`, so the row would be
1018            // skipped twice over and this exact ratio test would answer
1019            // `step = 1.0` — "the whole chord is feasible" — for a chord that
1020            // is not a point. The clipper built on it would then accept the
1021            // endpoint. Refuse before comparing.
1022            if !feasibility_quantities_are_finite(&[norm, bound, value, rate]) {
1023                return Err(format!(
1024                    "ConstraintSet::max_feasible_step: row {row} cannot be decided \
1025                     (row norm {norm:.3e}, bound {bound:.3e}, value {value:.3e}, \
1026                     drift {rate:.3e}); every comparison in the ratio test is false \
1027                     for NaN, so skipping the row would report the whole step \
1028                     feasible (gam#2721)"
1029                ));
1030            }
1031            if norm <= 0.0 {
1032                continue;
1033            }
1034            if rate >= 0.0 {
1035                continue;
1036            }
1037            let t = (value - bound) / (-rate);
1038            if t < step {
1039                step = t.max(0.0);
1040                blocking = Some(row);
1041            }
1042        }
1043        Ok((step, blocking))
1044    }
1045
1046    /// Materialize the requested rows densely (KKT systems on the active set).
1047    pub fn gather_rows(&self, rows: &[usize]) -> Result<LinearInequalityConstraints, String> {
1048        match self {
1049            ConstraintSet::Dense(dense) => {
1050                let mut a = Array2::<f64>::zeros((rows.len(), dense.a.ncols()));
1051                let mut b = Array1::<f64>::zeros(rows.len());
1052                for (out_row, &row) in rows.iter().enumerate() {
1053                    if row >= dense.a.nrows() {
1054                        return Err(format!(
1055                            "ConstraintSet: row {row} out of range ({} rows)",
1056                            dense.a.nrows()
1057                        ));
1058                    }
1059                    a.row_mut(out_row).assign(&dense.a.row(row));
1060                    b[out_row] = dense.b[row];
1061                }
1062                LinearInequalityConstraints::new(a, b)
1063            }
1064            ConstraintSet::KhatriRaoCone(cone) => cone.gather_rows(rows),
1065            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
1066                let mut a = Array2::<f64>::zeros((rows.len(), *total_cols));
1067                let mut b = Array1::<f64>::zeros(rows.len());
1068                for (out_row, &row) in rows.iter().enumerate() {
1069                    let (block, local) = Self::block_for_row(blocks, row)?;
1070                    let gathered = block.set.gather_rows(&[local])?;
1071                    a.row_mut(out_row)
1072                        .slice_mut(ndarray::s![
1073                            block.col_start..block.col_start + block.set.ncols()
1074                        ])
1075                        .assign(&gathered.a.row(0));
1076                    b[out_row] = gathered.b[0];
1077                }
1078                LinearInequalityConstraints::new(a, b)
1079            }
1080        }
1081    }
1082
1083    /// Exact dense equivalent of the whole set (tests / small systems only).
1084    pub fn to_dense(&self) -> Result<LinearInequalityConstraints, String> {
1085        match self {
1086            ConstraintSet::Dense(dense) => Ok(dense.clone()),
1087            _ => {
1088                let all: Vec<usize> = (0..self.nrows()).collect();
1089                self.gather_rows(&all)
1090            }
1091        }
1092    }
1093}
1094
1095impl From<LinearInequalityConstraints> for ConstraintSet {
1096    fn from(dense: LinearInequalityConstraints) -> Self {
1097        ConstraintSet::Dense(dense)
1098    }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104    use ndarray::array;
1105
1106    fn cone_fixture() -> KhatriRaoConeConstraints {
1107        // Ψ: 3 observations × 2 covariate columns; A is 3 coefficient rows
1108        // (row 0 = location, rows 1..2 = shape) × 2 columns.
1109        let psi = array![[1.0_f64, 0.5], [2.0, -1.0], [0.0, 3.0]];
1110        KhatriRaoConeConstraints::new(Arc::new(psi), vec![1, 2], 3).expect("cone fixture")
1111    }
1112
1113    fn beta_fixture() -> Array1<f64> {
1114        // vec(A) row-major, A = [[9, -4], [1, 2], [0.5, -0.25]]
1115        array![9.0_f64, -4.0, 1.0, 2.0, 0.5, -0.25]
1116    }
1117
1118    #[test]
1119    fn cone_values_match_dense_system() {
1120        let cone = cone_fixture();
1121        let set = ConstraintSet::KhatriRaoCone(cone.clone());
1122        let dense = ConstraintSet::Dense(cone.to_dense().expect("dense"));
1123        let beta = beta_fixture();
1124        let via_cone = set.values(beta.view()).expect("cone values");
1125        let via_dense = dense.values(beta.view()).expect("dense values");
1126        assert_eq!(via_cone.len(), 6);
1127        for (a, b) in via_cone.iter().zip(via_dense.iter()) {
1128            assert!((a - b).abs() < 1e-14, "cone/dense mismatch: {a} vs {b}");
1129        }
1130        // Spot-check one functional exactly: slot 0 (A row 1), observation 1:
1131        // ψ = (2, −1), A_{1,:} = (1, 2) → 2·1 − 1·2 = 0.
1132        assert!((via_cone[1] - 0.0).abs() < 1e-15);
1133    }
1134
1135    #[test]
1136    fn cone_row_norms_are_factor_row_norms_for_every_slot() {
1137        let cone = cone_fixture();
1138        let set = ConstraintSet::KhatriRaoCone(cone);
1139        let expected = [(1.0_f64 + 0.25).sqrt(), (4.0_f64 + 1.0).sqrt(), 3.0_f64];
1140        for slot in 0..2 {
1141            for i in 0..3 {
1142                let norm = set.row_norm(slot * 3 + i).expect("norm");
1143                assert!((norm - expected[i]).abs() < 1e-15);
1144            }
1145        }
1146    }
1147
1148    #[test]
1149    fn max_scaled_violation_agrees_with_canonicalized_dense() {
1150        let cone = cone_fixture();
1151        let set = ConstraintSet::KhatriRaoCone(cone.clone());
1152        let beta = beta_fixture();
1153        let (violation, row) = set.max_scaled_violation(beta.view()).expect("violation");
1154        // Dense oracle: canonicalize, then measure b − Aβ on unit rows.
1155        let dense = cone
1156            .to_dense()
1157            .expect("dense")
1158            .canonicalized()
1159            .expect("canon");
1160        let values = dense.a.dot(&beta);
1161        let mut worst = 0.0_f64;
1162        let mut worst_row = None;
1163        for r in 0..values.len() {
1164            let v = dense.b[r] - values[r];
1165            if v > worst {
1166                worst = v;
1167                worst_row = Some(r);
1168            }
1169        }
1170        assert!((violation - worst).abs() < 1e-14);
1171        assert_eq!(row, worst_row);
1172        assert!(violation > 0.0, "fixture must have a violated row");
1173    }
1174
1175    #[test]
1176    fn max_feasible_step_matches_scalar_ratio_test() {
1177        let cone = cone_fixture();
1178        let set = ConstraintSet::KhatriRaoCone(cone);
1179        // Feasible start: shape rows of A strictly positive functionals.
1180        // A = [[0, 0], [1, 0.1], [1, 0.1]] → α values Ψ·(1, 0.1):
1181        // (1.05, 1.9, 0.3) — all positive for both slots.
1182        let beta = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
1183        // Direction pushing slot 0 observation 2 down: δA_{1,:} = (0, −1) →
1184        // rate = ψ_2 · (0, −1) = −3; slack = 0.3 → t = 0.1. All other rows
1185        // untouched (rate 0 for slot 1, rates −0.5/1 for slot 0 rows 0/1:
1186        // row 0 rate = ψ_0·(0,−1) = −0.5, slack 1.05 → t = 2.1).
1187        let delta = array![0.0_f64, 0.0, 0.0, -1.0, 0.0, 0.0];
1188        let (step, blocking) = set
1189            .max_feasible_step(beta.view(), delta.view(), &[])
1190            .expect("step");
1191        assert!((step - 0.1).abs() < 1e-14, "expected 0.1, got {step}");
1192        assert_eq!(blocking, Some(2));
1193        // Skipping the blocking row exposes the next ratio (row 0, t = 2.1 → clamped to 1).
1194        let (step_skipped, blocking_skipped) = set
1195            .max_feasible_step(beta.view(), delta.view(), &[2])
1196            .expect("step skipped");
1197        assert!((step_skipped - 1.0).abs() < 1e-14);
1198        assert_eq!(blocking_skipped, None);
1199    }
1200
1201    #[test]
1202    fn gather_rows_places_factor_rows_in_the_coupled_slot() {
1203        let cone = cone_fixture();
1204        // Row id 4 = slot 1 (A row 2), observation 1 → ψ = (2, −1) in cols 4..6.
1205        let gathered = cone.gather_rows(&[4]).expect("gather");
1206        assert_eq!(gathered.a.nrows(), 1);
1207        assert_eq!(gathered.a.ncols(), 6);
1208        let expected = [0.0, 0.0, 0.0, 0.0, 2.0, -1.0];
1209        for (j, &e) in expected.iter().enumerate() {
1210            assert_eq!(gathered.a[[0, j]], e);
1211        }
1212        assert_eq!(gathered.b[0], 0.0);
1213    }
1214
1215    #[test]
1216    fn constructor_rejects_bad_coupled_rows() {
1217        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
1218        assert!(KhatriRaoConeConstraints::new(Arc::new(psi.clone()), vec![3], 3).is_err());
1219        assert!(KhatriRaoConeConstraints::new(Arc::new(psi.clone()), vec![1, 1], 3).is_err());
1220        assert!(KhatriRaoConeConstraints::new(Arc::new(psi), vec![], 3).is_err());
1221    }
1222
1223    #[test]
1224    fn shifted_to_delta_matches_dense_shift() {
1225        let cone = cone_fixture();
1226        let set = ConstraintSet::KhatriRaoCone(cone);
1227        let beta = beta_fixture();
1228        let shifted = set.shifted_to_delta(beta.view()).expect("shift");
1229        // Oracle: dense shift b' = b − Aβ.
1230        let dense = set.to_dense().expect("dense");
1231        let expected_b = &dense.b - &dense.a.dot(&beta);
1232        for row in 0..set.nrows() {
1233            assert!(
1234                (shifted.bound(row).expect("bound") - expected_b[row]).abs() < 1e-14,
1235                "shifted bound mismatch at row {row}"
1236            );
1237        }
1238        // The delta system at δ = 0 has slack equal to the original at β.
1239        let zero = Array1::<f64>::zeros(set.ncols());
1240        let (viol_delta, row_delta) = shifted
1241            .max_scaled_violation(zero.view())
1242            .expect("delta violation");
1243        let (viol_orig, row_orig) = set.max_scaled_violation(beta.view()).expect("violation");
1244        assert!((viol_delta - viol_orig).abs() < 1e-14);
1245        assert_eq!(row_delta, row_orig);
1246    }
1247
1248    #[test]
1249    fn block_diagonal_composes_ids_bounds_and_values() {
1250        // Block 0: dense 2-row system on columns 0..2; block 1: cone on 2..8.
1251        let dense = LinearInequalityConstraints::new(
1252            array![[1.0_f64, 0.0], [0.0, -2.0]],
1253            array![0.5_f64, -1.0],
1254        )
1255        .expect("dense block");
1256        let cone = cone_fixture();
1257        let joint = ConstraintSet::block_diagonal(
1258            vec![
1259                PlacedConstraintBlock {
1260                    col_start: 0,
1261                    set: ConstraintSet::Dense(dense.clone()),
1262                },
1263                PlacedConstraintBlock {
1264                    col_start: 2,
1265                    set: ConstraintSet::KhatriRaoCone(cone.clone()),
1266                },
1267            ],
1268            8,
1269        )
1270        .expect("joint");
1271        assert_eq!(joint.nrows(), 2 + 6);
1272        assert_eq!(joint.ncols(), 8);
1273        let mut beta = Array1::<f64>::zeros(8);
1274        beta[0] = 2.0;
1275        beta[1] = 1.0;
1276        beta.slice_mut(ndarray::s![2..8]).assign(&beta_fixture());
1277        let values = joint.values(beta.view()).expect("values");
1278        assert!((values[0] - 2.0).abs() < 1e-15);
1279        assert!((values[1] + 2.0).abs() < 1e-15);
1280        let cone_values = cone.values(beta_fixture().view()).expect("cone values");
1281        for (idx, &cv) in cone_values.iter().enumerate() {
1282            assert!((values[2 + idx] - cv).abs() < 1e-15);
1283        }
1284        assert_eq!(joint.bound(0).expect("b0"), 0.5);
1285        assert_eq!(joint.bound(2).expect("b2"), 0.0);
1286        // Gathered joint row 3 (= cone row 1) occupies columns 2 + [2..4).
1287        let gathered = joint.gather_rows(&[3]).expect("gather");
1288        assert_eq!(gathered.a.ncols(), 8);
1289        assert_eq!(gathered.a[[0, 4]], 2.0);
1290        assert_eq!(gathered.a[[0, 5]], -1.0);
1291        // Overlapping ranges are rejected.
1292        assert!(
1293            ConstraintSet::block_diagonal(
1294                vec![
1295                    PlacedConstraintBlock {
1296                        col_start: 0,
1297                        set: ConstraintSet::Dense(dense.clone()),
1298                    },
1299                    PlacedConstraintBlock {
1300                        col_start: 1,
1301                        set: ConstraintSet::Dense(dense),
1302                    },
1303                ],
1304                8,
1305            )
1306            .is_err()
1307        );
1308    }
1309
1310    #[test]
1311    fn zero_factor_rows_are_vacuous_not_violations() {
1312        // Ψ with an all-zero observation row: 0ᵀβ ≥ 0 is vacuous and must be
1313        // skipped by violation and ratio sweeps (norm 0), matching the dense
1314        // canonicalization contract for zero rows with b ≤ 0.
1315        let psi = array![[0.0_f64, 0.0], [1.0, 1.0]];
1316        let cone = KhatriRaoConeConstraints::new(Arc::new(psi), vec![1], 2).expect("cone");
1317        let set = ConstraintSet::KhatriRaoCone(cone);
1318        let beta = array![0.0_f64, 0.0, -5.0, 4.0];
1319        // Slot 0: values (0, −1). Row 0 vacuous; row 1 violated by 1/√2.
1320        let (violation, row) = set.max_scaled_violation(beta.view()).expect("violation");
1321        assert_eq!(row, Some(1));
1322        assert!((violation - 1.0 / 2.0_f64.sqrt()).abs() < 1e-14);
1323    }
1324
1325}