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/// Nonnegativity cone `(e_k ⊗ ψ_i)ᵀ β ≥ 0` for a row-major Khatri-Rao block.
29///
30/// The coefficient block is `β = vec(A)` with `A` reshaped row-major as
31/// `p_left × p_cov` (coefficient `A[k, j] = β[k · p_cov + j]`). The cone
32/// constrains the factored linear functionals `α_k(x_i) = ψ_iᵀ A_{k,:}` to be
33/// non-negative for every observation row `i` of `factor` and every
34/// `k ∈ coupled_rows`.
35///
36/// Row identifiers are stable and dense: row `r = s · n + i` where `s` indexes
37/// into `coupled_rows` and `i` is the observation row. Active-set warm starts
38/// therefore survive across iterations exactly as with the dense system.
39#[derive(Clone, Debug)]
40pub struct KhatriRaoConeConstraints {
41    /// Covariate factor `Ψ` (`n × p_cov`).
42    factor: Arc<Array2<f64>>,
43    /// Euclidean norm of each `Ψ` row (unit-normalization denominators).
44    factor_row_norms: Array1<f64>,
45    /// Coefficient rows of `A` (indices into `0..p_left`) that carry the cone.
46    coupled_rows: Vec<usize>,
47    /// Total number of coefficient rows in the block reshape.
48    p_left: usize,
49    /// Per-row right-hand sides. The homogeneous cone has `b ≡ 0`; a
50    /// delta-coordinate solve (`β = β₀ + δ`) shifts them to `−(rowᵀβ₀)`.
51    /// Bounds are `O(nrows)` — cheap even when the matrix is not.
52    bounds: Option<Array1<f64>>,
53}
54
55impl KhatriRaoConeConstraints {
56    pub fn new(
57        factor: Arc<Array2<f64>>,
58        coupled_rows: Vec<usize>,
59        p_left: usize,
60    ) -> Result<Self, String> {
61        if factor.nrows() == 0 || factor.ncols() == 0 {
62            return Err("KhatriRaoConeConstraints: factor must be non-empty".to_string());
63        }
64        if factor.iter().any(|v| !v.is_finite()) {
65            return Err("KhatriRaoConeConstraints: factor must be finite".to_string());
66        }
67        if coupled_rows.is_empty() {
68            return Err(
69                "KhatriRaoConeConstraints: at least one coupled coefficient row is required"
70                    .to_string(),
71            );
72        }
73        let mut seen = vec![false; p_left];
74        for &k in &coupled_rows {
75            if k >= p_left {
76                return Err(format!(
77                    "KhatriRaoConeConstraints: coupled row {k} out of range (p_left = {p_left})"
78                ));
79            }
80            if seen[k] {
81                return Err(format!(
82                    "KhatriRaoConeConstraints: coupled row {k} is duplicated"
83                ));
84            }
85            seen[k] = true;
86        }
87        let factor_row_norms = Array1::from_iter(
88            factor
89                .rows()
90                .into_iter()
91                .map(|row| row.dot(&row).sqrt()),
92        );
93        Ok(Self {
94            factor,
95            factor_row_norms,
96            coupled_rows,
97            p_left,
98            bounds: None,
99        })
100    }
101
102    pub fn factor(&self) -> &Array2<f64> {
103        self.factor.as_ref()
104    }
105
106    pub fn coupled_rows(&self) -> &[usize] {
107        &self.coupled_rows
108    }
109
110    pub fn p_left(&self) -> usize {
111        self.p_left
112    }
113
114    /// One coupled response-row slot as a standalone cone over a single
115    /// `p_cov` coefficient block. The covariate factor remains shared by
116    /// [`Arc`]; only the small row-norm vector and this slot's optional bounds
117    /// are copied. This is the exact block decomposition of an identity-Hessian
118    /// projection, not a reduced-data approximation.
119    pub fn single_coupled_slot(&self, slot: usize) -> Result<Self, String> {
120        if slot >= self.coupled_rows.len() {
121            return Err(format!(
122                "KhatriRaoConeConstraints: coupled slot {slot} out of range ({} slots)",
123                self.coupled_rows.len()
124            ));
125        }
126        let n = self.factor.nrows();
127        let bounds = self.bounds.as_ref().map(|all| {
128            all.slice(ndarray::s![slot * n..(slot + 1) * n])
129                .to_owned()
130        });
131        Ok(Self {
132            factor: Arc::clone(&self.factor),
133            factor_row_norms: self.factor_row_norms.clone(),
134            coupled_rows: vec![0],
135            p_left: 1,
136            bounds,
137        })
138    }
139
140    pub fn nrows(&self) -> usize {
141        self.coupled_rows.len() * self.factor.nrows()
142    }
143
144    pub fn ncols(&self) -> usize {
145        self.p_left * self.factor.ncols()
146    }
147
148    /// Decompose a row id into `(coupled-row slot, observation row)`.
149    #[inline]
150    fn split_row_id(&self, row: usize) -> Result<(usize, usize), String> {
151        let n = self.factor.nrows();
152        let slot = row / n;
153        if slot >= self.coupled_rows.len() {
154            return Err(format!(
155                "KhatriRaoConeConstraints: row id {row} out of range ({} rows)",
156                self.nrows()
157            ));
158        }
159        Ok((slot, row % n))
160    }
161
162    /// Raw (un-normalized) constraint values `A β` for the full row set,
163    /// laid out slot-major (`r = s·n + i`).
164    ///
165    /// Cost: one `n × p_cov · p_cov` product per coupled row — never the
166    /// `nrows × ncols` dense system.
167    pub fn values(&self, beta: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
168        let p_cov = self.factor.ncols();
169        if beta.len() != self.ncols() {
170            return Err(format!(
171                "KhatriRaoConeConstraints: beta length {} != {}",
172                beta.len(),
173                self.ncols()
174            ));
175        }
176        let n = self.factor.nrows();
177        let mut out = Array1::<f64>::zeros(self.nrows());
178        for (slot, &k) in self.coupled_rows.iter().enumerate() {
179            let block = beta.slice(ndarray::s![k * p_cov..(k + 1) * p_cov]);
180            let alpha = self.factor.dot(&block);
181            out.slice_mut(ndarray::s![slot * n..(slot + 1) * n])
182                .assign(&alpha);
183        }
184        Ok(out)
185    }
186
187    /// Unit-normalization denominator of one row (`‖ψ_i‖`, shared across
188    /// coupled slots). Zero rows are vacuous (`0ᵀβ ≥ 0` always holds) exactly
189    /// like the canonicalized dense system keeps them inert.
190    pub fn row_norm(&self, row: usize) -> Result<f64, String> {
191        let (_, i) = self.split_row_id(row)?;
192        Ok(self.factor_row_norms[i])
193    }
194
195    /// The coefficient columns row `row` acts on, ascending.
196    ///
197    /// Row `(slot, i)` has normal `e_k ⊗ ψ_i` with `k = coupled_rows[slot]`, and
198    /// [`Self::values`] reads exactly the block `β[k·p_cov .. (k+1)·p_cov]`, so
199    /// the support is `k·p_cov + j` over the columns `j` where `ψ_{i,j} ≠ 0`.
200    /// Every other coefficient has a structurally zero coefficient in this row.
201    pub fn row_column_support(&self, row: usize) -> Result<Vec<usize>, String> {
202        let (slot, i) = self.split_row_id(row)?;
203        let p_cov = self.factor.ncols();
204        let base = self.coupled_rows[slot] * p_cov;
205        Ok((0..p_cov)
206            .filter(|&j| self.factor[[i, j]] != 0.0)
207            .map(|j| base + j)
208            .collect())
209    }
210
211    /// Per-row right-hand side (`0` for the homogeneous cone, shifted values
212    /// after [`ConstraintSet::shifted_to_delta`]).
213    pub fn bound(&self, row: usize) -> Result<f64, String> {
214        self.split_row_id(row)?;
215        Ok(self.bounds.as_ref().map_or(0.0, |bounds| bounds[row]))
216    }
217
218    /// Materialize the requested rows as a dense system (active-set KKT use;
219    /// the id order of `rows` is preserved). Rows come out RAW (un-normalized),
220    /// matching the raw dense construction path; callers that need geometric
221    /// tolerances canonicalize the gathered system.
222    pub fn gather_rows(&self, rows: &[usize]) -> Result<LinearInequalityConstraints, String> {
223        let p_cov = self.factor.ncols();
224        let mut a = Array2::<f64>::zeros((rows.len(), self.ncols()));
225        let mut b = Array1::<f64>::zeros(rows.len());
226        for (out_row, &row) in rows.iter().enumerate() {
227            let (slot, i) = self.split_row_id(row)?;
228            let k = self.coupled_rows[slot];
229            a.row_mut(out_row)
230                .slice_mut(ndarray::s![k * p_cov..(k + 1) * p_cov])
231                .assign(&self.factor.row(i));
232            b[out_row] = self.bound(row)?;
233        }
234        LinearInequalityConstraints::new(a, b)
235    }
236
237    /// Exact dense equivalent of the ENTIRE cone. Test/oracle use only — this
238    /// is the materialization the carrier exists to avoid.
239    pub fn to_dense(&self) -> Result<LinearInequalityConstraints, String> {
240        let all: Vec<usize> = (0..self.nrows()).collect();
241        self.gather_rows(&all)
242    }
243}
244
245/// A row index in a [`ConstraintSet`]'s OWN constraint-row space — the space
246/// addressed by [`ConstraintSet::values`], [`ConstraintSet::bound`] and
247/// [`ConstraintSet::row_norm`], i.e. `0..nrows()`.
248///
249/// This is NOT a coefficient (β) index. The two spaces have different sizes
250/// (`nrows()` vs `ncols()`) and different meanings, and they coincide only in
251/// the special case of a square carrier whose row `r` is exactly the box
252/// `β_r ≥ 0`. A block-diagonal composition breaks that coincidence: its row ids
253/// are the CONCATENATION of the member row counts while its columns are the
254/// concatenation of the member column ranges, so as soon as one member has
255/// `nrows() < ncols()` (a monotone sub-basis alongside unconstrained intercept /
256/// covariate columns) row id `r` of a later block names a β coordinate owned by
257/// an EARLIER block. The newtype exists so that mistake cannot be made silently;
258/// to go from a row to the coefficients it acts on, call
259/// [`ConstraintSet::row_column_support`].
260#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
261pub struct ConstraintRowId(pub usize);
262
263impl ConstraintRowId {
264    /// The raw index, for addressing a `values()` / `bound()` / `row_norm()`
265    /// result. Deliberately explicit: reach for this only when indexing
266    /// something that really is in constraint-row space.
267    #[inline]
268    pub fn index(self) -> usize {
269        self.0
270    }
271}
272
273/// One block of a [`ConstraintSet::BlockDiagonal`] composition: an inner set
274/// acting on the coefficient columns `[col_start, col_start + set.ncols())` of
275/// the joint vector.
276#[derive(Clone, Debug)]
277pub struct PlacedConstraintBlock {
278    pub col_start: usize,
279    pub set: ConstraintSet,
280}
281
282/// Closed union of the constraint carriers the blockwise solvers accept.
283#[derive(Clone, Debug)]
284pub enum ConstraintSet {
285    /// Explicit rows, exactly as today.
286    Dense(LinearInequalityConstraints),
287    /// Factored Khatri-Rao nonnegativity cone.
288    KhatriRaoCone(KhatriRaoConeConstraints),
289    /// Block-diagonal composition over disjoint column ranges of a joint
290    /// coefficient vector (the multi-block joint-Newton assembly). Row ids
291    /// are the concatenation of the member row ids in order.
292    BlockDiagonal {
293        blocks: Vec<PlacedConstraintBlock>,
294        total_cols: usize,
295    },
296}
297
298impl ConstraintSet {
299    /// Validated block-diagonal composition: member column ranges must lie
300    /// inside the joint width and must not overlap.
301    pub fn block_diagonal(
302        blocks: Vec<PlacedConstraintBlock>,
303        total_cols: usize,
304    ) -> Result<Self, String> {
305        let mut ranges: Vec<(usize, usize)> = Vec::with_capacity(blocks.len());
306        for block in &blocks {
307            let end = block.col_start + block.set.ncols();
308            if end > total_cols {
309                return Err(format!(
310                    "ConstraintSet::block_diagonal: block columns {}..{} exceed joint width {}",
311                    block.col_start, end, total_cols
312                ));
313            }
314            ranges.push((block.col_start, end));
315        }
316        ranges.sort_unstable();
317        for pair in ranges.windows(2) {
318            if pair[1].0 < pair[0].1 {
319                return Err(format!(
320                    "ConstraintSet::block_diagonal: overlapping column ranges {:?} and {:?}",
321                    pair[0], pair[1]
322                ));
323            }
324        }
325        Ok(ConstraintSet::BlockDiagonal { blocks, total_cols })
326    }
327
328    /// Locate the member block owning a joint row id.
329    fn block_for_row<'a>(
330        blocks: &'a [PlacedConstraintBlock],
331        row: usize,
332    ) -> Result<(&'a PlacedConstraintBlock, usize), String> {
333        let mut offset = 0usize;
334        for block in blocks {
335            let rows = block.set.nrows();
336            if row < offset + rows {
337                return Ok((block, row - offset));
338            }
339            offset += rows;
340        }
341        Err(format!(
342            "ConstraintSet: row {row} out of range ({offset} rows)"
343        ))
344    }
345
346    pub fn nrows(&self) -> usize {
347        match self {
348            ConstraintSet::Dense(dense) => dense.a.nrows(),
349            ConstraintSet::KhatriRaoCone(cone) => cone.nrows(),
350            ConstraintSet::BlockDiagonal { blocks, .. } => {
351                blocks.iter().map(|block| block.set.nrows()).sum()
352            }
353        }
354    }
355
356    pub fn ncols(&self) -> usize {
357        match self {
358            ConstraintSet::Dense(dense) => dense.a.ncols(),
359            ConstraintSet::KhatriRaoCone(cone) => cone.ncols(),
360            ConstraintSet::BlockDiagonal { total_cols, .. } => *total_cols,
361        }
362    }
363
364    /// Raw constraint values `Aβ` (dense) / factored functional values (cone).
365    pub fn values(&self, beta: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
366        match self {
367            ConstraintSet::Dense(dense) => {
368                if beta.len() != dense.a.ncols() {
369                    return Err(format!(
370                        "ConstraintSet: beta length {} != {}",
371                        beta.len(),
372                        dense.a.ncols()
373                    ));
374                }
375                Ok(dense.a.dot(&beta))
376            }
377            ConstraintSet::KhatriRaoCone(cone) => cone.values(beta),
378            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
379                if beta.len() != *total_cols {
380                    return Err(format!(
381                        "ConstraintSet: beta length {} != {}",
382                        beta.len(),
383                        total_cols
384                    ));
385                }
386                let mut out = Array1::<f64>::zeros(self.nrows());
387                let mut offset = 0usize;
388                for block in blocks {
389                    let width = block.set.ncols();
390                    let local = beta.slice(ndarray::s![
391                        block.col_start..block.col_start + width
392                    ]);
393                    let values = block.set.values(local)?;
394                    let rows = values.len();
395                    out.slice_mut(ndarray::s![offset..offset + rows]).assign(&values);
396                    offset += rows;
397                }
398                Ok(out)
399            }
400        }
401    }
402
403    /// Right-hand sides (`b` dense; cone bounds are zero unless delta-shifted).
404    pub fn bound(&self, row: usize) -> Result<f64, String> {
405        match self {
406            ConstraintSet::Dense(dense) => {
407                dense.b.get(row).copied().ok_or_else(|| {
408                    format!(
409                        "ConstraintSet: row {row} out of range ({} rows)",
410                        dense.b.len()
411                    )
412                })
413            }
414            ConstraintSet::KhatriRaoCone(cone) => cone.bound(row),
415            ConstraintSet::BlockDiagonal { blocks, .. } => {
416                let (block, local) = Self::block_for_row(blocks, row)?;
417                block.set.bound(local)
418            }
419        }
420    }
421
422    pub fn row_norm(&self, row: usize) -> Result<f64, String> {
423        match self {
424            ConstraintSet::Dense(dense) => {
425                if row >= dense.a.nrows() {
426                    return Err(format!(
427                        "ConstraintSet: row {row} out of range ({} rows)",
428                        dense.a.nrows()
429                    ));
430                }
431                let r = dense.a.row(row);
432                Ok(r.dot(&r).sqrt())
433            }
434            ConstraintSet::KhatriRaoCone(cone) => cone.row_norm(row),
435            ConstraintSet::BlockDiagonal { blocks, .. } => {
436                let (block, local) = Self::block_for_row(blocks, row)?;
437                block.set.row_norm(local)
438            }
439        }
440    }
441
442    /// The coefficient (β) columns that constraint row `row` acts on, ascending
443    /// and in the JOINT column space of this set — the one and only sanctioned
444    /// route from constraint-row space to coefficient space.
445    ///
446    /// Needed because the two spaces are genuinely different (see
447    /// [`ConstraintRowId`]): a consumer building a free/pinned β mask from a
448    /// reduced face has row ids in hand and coefficient positions to fill, and
449    /// the identity map between them is valid only for a square box carrier.
450    /// The block-diagonal arm is where it visibly fails — row ids advance by
451    /// each member's `nrows()` while columns advance by its `ncols()`, so the
452    /// two run at different rates the moment any member constrains fewer rows
453    /// than it has coefficients.
454    pub fn row_column_support(&self, row: ConstraintRowId) -> Result<Vec<usize>, String> {
455        let row = row.index();
456        match self {
457            ConstraintSet::Dense(dense) => {
458                if row >= dense.a.nrows() {
459                    return Err(format!(
460                        "ConstraintSet: row {row} out of range ({} rows)",
461                        dense.a.nrows()
462                    ));
463                }
464                Ok(dense
465                    .a
466                    .row(row)
467                    .iter()
468                    .enumerate()
469                    .filter(|(_, value)| **value != 0.0)
470                    .map(|(col, _)| col)
471                    .collect())
472            }
473            ConstraintSet::KhatriRaoCone(cone) => cone.row_column_support(row),
474            ConstraintSet::BlockDiagonal { blocks, .. } => {
475                let (block, local) = Self::block_for_row(blocks, row)?;
476                // The member reports support in ITS OWN column space; the joint
477                // offset is the block's `col_start`, which is independent of the
478                // row offset used to reach `local`.
479                let mut cols = block.set.row_column_support(ConstraintRowId(local))?;
480                for col in &mut cols {
481                    *col += block.col_start;
482                }
483                Ok(cols)
484            }
485        }
486    }
487
488    /// The same constraint system expressed in delta coordinates around
489    /// `beta`: `A(β + δ) ≥ b  ⇔  Aδ ≥ b − Aβ`. The matrix carrier is shared;
490    /// only the `O(nrows)` bounds change.
491    pub fn shifted_to_delta(&self, beta: ArrayView1<'_, f64>) -> Result<Self, String> {
492        let values = self.values(beta)?;
493        match self {
494            ConstraintSet::Dense(dense) => Ok(ConstraintSet::Dense(
495                LinearInequalityConstraints::new(dense.a.clone(), &dense.b - &values)?,
496            )),
497            ConstraintSet::KhatriRaoCone(cone) => {
498                let mut shifted = cone.clone();
499                let base = shifted
500                    .bounds
501                    .take()
502                    .unwrap_or_else(|| Array1::zeros(values.len()));
503                shifted.bounds = Some(&base - &values);
504                Ok(ConstraintSet::KhatriRaoCone(shifted))
505            }
506            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
507                let mut shifted_blocks = Vec::with_capacity(blocks.len());
508                for block in blocks {
509                    let width = block.set.ncols();
510                    let local = beta.slice(ndarray::s![
511                        block.col_start..block.col_start + width
512                    ]);
513                    shifted_blocks.push(PlacedConstraintBlock {
514                        col_start: block.col_start,
515                        set: block.set.shifted_to_delta(local)?,
516                    });
517                }
518                Ok(ConstraintSet::BlockDiagonal {
519                    blocks: shifted_blocks,
520                    total_cols: *total_cols,
521                })
522            }
523        }
524    }
525
526    /// Scaled violation sweep: `max_r (b_r − (Aβ)_r) / max(‖a_r‖, 1)` restricted
527    /// to non-vacuous rows, plus the arg-max row. Matches the canonicalized
528    /// dense geometry (unit rows) without materializing it.
529    pub fn max_scaled_violation(
530        &self,
531        beta: ArrayView1<'_, f64>,
532    ) -> Result<(f64, Option<usize>), String> {
533        let values = self.values(beta)?;
534        let mut worst = 0.0_f64;
535        let mut worst_row = None;
536        for (row, &value) in values.iter().enumerate() {
537            let norm = self.row_norm(row)?;
538            if norm <= 0.0 {
539                continue;
540            }
541            let violation = (self.bound(row)? - value) / norm;
542            if violation > worst {
543                worst = violation;
544                worst_row = Some(row);
545            }
546        }
547        Ok((worst, worst_row))
548    }
549
550    /// Largest `t ∈ [0, 1]` with `β + t·δ` feasible for every row, together
551    /// with the first blocking row (the exact ratio test of a primal
552    /// active-set method). Rows already violated at `β` (beyond `tol` in
553    /// scaled units) are reported as blocking at `t = 0`.
554    pub fn max_feasible_step(
555        &self,
556        beta: ArrayView1<'_, f64>,
557        delta: ArrayView1<'_, f64>,
558        skip_rows: &[usize],
559    ) -> Result<(f64, Option<usize>), String> {
560        let values = self.values(beta)?;
561        let directional = self.values(delta)?;
562        let mut skip = vec![false; values.len()];
563        for &row in skip_rows {
564            if row < skip.len() {
565                skip[row] = true;
566            }
567        }
568        let mut step = 1.0_f64;
569        let mut blocking = None;
570        for row in 0..values.len() {
571            if skip[row] {
572                continue;
573            }
574            let norm = self.row_norm(row)?;
575            if norm <= 0.0 {
576                continue;
577            }
578            let slack = values[row] - self.bound(row)?;
579            let rate = directional[row];
580            if rate >= 0.0 {
581                continue;
582            }
583            let t = slack / (-rate);
584            if t < step {
585                step = t.max(0.0);
586                blocking = Some(row);
587            }
588        }
589        Ok((step, blocking))
590    }
591
592    /// Materialize the requested rows densely (KKT systems on the active set).
593    pub fn gather_rows(&self, rows: &[usize]) -> Result<LinearInequalityConstraints, String> {
594        match self {
595            ConstraintSet::Dense(dense) => {
596                let mut a = Array2::<f64>::zeros((rows.len(), dense.a.ncols()));
597                let mut b = Array1::<f64>::zeros(rows.len());
598                for (out_row, &row) in rows.iter().enumerate() {
599                    if row >= dense.a.nrows() {
600                        return Err(format!(
601                            "ConstraintSet: row {row} out of range ({} rows)",
602                            dense.a.nrows()
603                        ));
604                    }
605                    a.row_mut(out_row).assign(&dense.a.row(row));
606                    b[out_row] = dense.b[row];
607                }
608                LinearInequalityConstraints::new(a, b)
609            }
610            ConstraintSet::KhatriRaoCone(cone) => cone.gather_rows(rows),
611            ConstraintSet::BlockDiagonal { blocks, total_cols } => {
612                let mut a = Array2::<f64>::zeros((rows.len(), *total_cols));
613                let mut b = Array1::<f64>::zeros(rows.len());
614                for (out_row, &row) in rows.iter().enumerate() {
615                    let (block, local) = Self::block_for_row(blocks, row)?;
616                    let gathered = block.set.gather_rows(&[local])?;
617                    a.row_mut(out_row)
618                        .slice_mut(ndarray::s![
619                            block.col_start..block.col_start + block.set.ncols()
620                        ])
621                        .assign(&gathered.a.row(0));
622                    b[out_row] = gathered.b[0];
623                }
624                LinearInequalityConstraints::new(a, b)
625            }
626        }
627    }
628
629    /// Exact dense equivalent of the whole set (tests / small systems only).
630    pub fn to_dense(&self) -> Result<LinearInequalityConstraints, String> {
631        match self {
632            ConstraintSet::Dense(dense) => Ok(dense.clone()),
633            _ => {
634                let all: Vec<usize> = (0..self.nrows()).collect();
635                self.gather_rows(&all)
636            }
637        }
638    }
639}
640
641impl From<LinearInequalityConstraints> for ConstraintSet {
642    fn from(dense: LinearInequalityConstraints) -> Self {
643        ConstraintSet::Dense(dense)
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use ndarray::array;
651
652    fn cone_fixture() -> KhatriRaoConeConstraints {
653        // Ψ: 3 observations × 2 covariate columns; A is 3 coefficient rows
654        // (row 0 = location, rows 1..2 = shape) × 2 columns.
655        let psi = array![[1.0_f64, 0.5], [2.0, -1.0], [0.0, 3.0]];
656        KhatriRaoConeConstraints::new(Arc::new(psi), vec![1, 2], 3).expect("cone fixture")
657    }
658
659    fn beta_fixture() -> Array1<f64> {
660        // vec(A) row-major, A = [[9, -4], [1, 2], [0.5, -0.25]]
661        array![9.0_f64, -4.0, 1.0, 2.0, 0.5, -0.25]
662    }
663
664    #[test]
665    fn cone_values_match_dense_system() {
666        let cone = cone_fixture();
667        let set = ConstraintSet::KhatriRaoCone(cone.clone());
668        let dense = ConstraintSet::Dense(cone.to_dense().expect("dense"));
669        let beta = beta_fixture();
670        let via_cone = set.values(beta.view()).expect("cone values");
671        let via_dense = dense.values(beta.view()).expect("dense values");
672        assert_eq!(via_cone.len(), 6);
673        for (a, b) in via_cone.iter().zip(via_dense.iter()) {
674            assert!((a - b).abs() < 1e-14, "cone/dense mismatch: {a} vs {b}");
675        }
676        // Spot-check one functional exactly: slot 0 (A row 1), observation 1:
677        // ψ = (2, −1), A_{1,:} = (1, 2) → 2·1 − 1·2 = 0.
678        assert!((via_cone[1] - 0.0).abs() < 1e-15);
679    }
680
681    /// `row_column_support` is the sanctioned row → β conversion, so it must
682    /// agree with the explicit dense system row by row: the columns it names are
683    /// exactly the structurally nonzero entries of that row of `A`.
684    #[test]
685    fn cone_row_column_support_matches_the_dense_row_nonzeros() {
686        let cone = cone_fixture();
687        let set = ConstraintSet::KhatriRaoCone(cone.clone());
688        let dense = ConstraintSet::Dense(cone.to_dense().expect("dense"));
689        for row in 0..set.nrows() {
690            let via_cone = set
691                .row_column_support(ConstraintRowId(row))
692                .expect("cone support");
693            let via_dense = dense
694                .row_column_support(ConstraintRowId(row))
695                .expect("dense support");
696            assert_eq!(via_cone, via_dense, "row {row} support mismatch");
697        }
698        // Slot 0 carries coefficient row k = 1, so its columns are 1·p_cov + j.
699        // Observation 2 has ψ = (0, 3): the zero factor entry drops column 2.
700        assert_eq!(
701            set.row_column_support(ConstraintRowId(0)).expect("r0"),
702            vec![2, 3]
703        );
704        assert_eq!(
705            set.row_column_support(ConstraintRowId(2)).expect("r2"),
706            vec![3]
707        );
708        // Slot 1 carries coefficient row k = 2 → columns 4, 5.
709        assert_eq!(
710            set.row_column_support(ConstraintRowId(3)).expect("r3"),
711            vec![4, 5]
712        );
713    }
714
715    /// The block-diagonal arm offsets support by `col_start` while it decodes
716    /// the row by the running `nrows()`. When a member has `nrows() < ncols()`
717    /// the two run at different rates, and only the conversion tracks columns
718    /// correctly: joint row 1 belongs to the block starting at column 3.
719    #[test]
720    fn block_diagonal_row_column_support_uses_col_start_not_the_row_offset() {
721        let narrow = PlacedConstraintBlock {
722            col_start: 0,
723            set: ConstraintSet::Dense(
724                LinearInequalityConstraints::new(
725                    array![[1.0_f64, 0.0, 0.0]],
726                    Array1::<f64>::zeros(1),
727                )
728                .expect("narrow"),
729            ),
730        };
731        let square = PlacedConstraintBlock {
732            col_start: 3,
733            set: ConstraintSet::Dense(
734                LinearInequalityConstraints::new(
735                    array![[1.0_f64, 0.0], [0.0, 1.0]],
736                    Array1::<f64>::zeros(2),
737                )
738                .expect("square"),
739            ),
740        };
741        let set = ConstraintSet::block_diagonal(vec![narrow, square], 5).expect("joint");
742        assert_eq!(set.nrows(), 3);
743        assert_eq!(set.ncols(), 5);
744        assert_eq!(
745            set.row_column_support(ConstraintRowId(0)).expect("r0"),
746            vec![0]
747        );
748        // Row 1 is the second block's first row: column 3, NOT column 1.
749        assert_eq!(
750            set.row_column_support(ConstraintRowId(1)).expect("r1"),
751            vec![3]
752        );
753        assert_eq!(
754            set.row_column_support(ConstraintRowId(2)).expect("r2"),
755            vec![4]
756        );
757        assert!(set.row_column_support(ConstraintRowId(3)).is_err());
758    }
759
760    #[test]
761    fn cone_row_norms_are_factor_row_norms_for_every_slot() {
762        let cone = cone_fixture();
763        let set = ConstraintSet::KhatriRaoCone(cone);
764        let expected = [
765            (1.0_f64 + 0.25).sqrt(),
766            (4.0_f64 + 1.0).sqrt(),
767            3.0_f64,
768        ];
769        for slot in 0..2 {
770            for i in 0..3 {
771                let norm = set.row_norm(slot * 3 + i).expect("norm");
772                assert!((norm - expected[i]).abs() < 1e-15);
773            }
774        }
775    }
776
777    #[test]
778    fn max_scaled_violation_agrees_with_canonicalized_dense() {
779        let cone = cone_fixture();
780        let set = ConstraintSet::KhatriRaoCone(cone.clone());
781        let beta = beta_fixture();
782        let (violation, row) = set.max_scaled_violation(beta.view()).expect("violation");
783        // Dense oracle: canonicalize, then measure b − Aβ on unit rows.
784        let dense = cone.to_dense().expect("dense").canonicalized().expect("canon");
785        let values = dense.a.dot(&beta);
786        let mut worst = 0.0_f64;
787        let mut worst_row = None;
788        for r in 0..values.len() {
789            let v = dense.b[r] - values[r];
790            if v > worst {
791                worst = v;
792                worst_row = Some(r);
793            }
794        }
795        assert!((violation - worst).abs() < 1e-14);
796        assert_eq!(row, worst_row);
797        assert!(violation > 0.0, "fixture must have a violated row");
798    }
799
800    #[test]
801    fn max_feasible_step_matches_scalar_ratio_test() {
802        let cone = cone_fixture();
803        let set = ConstraintSet::KhatriRaoCone(cone);
804        // Feasible start: shape rows of A strictly positive functionals.
805        // A = [[0, 0], [1, 0.1], [1, 0.1]] → α values Ψ·(1, 0.1):
806        // (1.05, 1.9, 0.3) — all positive for both slots.
807        let beta = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
808        // Direction pushing slot 0 observation 2 down: δA_{1,:} = (0, −1) →
809        // rate = ψ_2 · (0, −1) = −3; slack = 0.3 → t = 0.1. All other rows
810        // untouched (rate 0 for slot 1, rates −0.5/1 for slot 0 rows 0/1:
811        // row 0 rate = ψ_0·(0,−1) = −0.5, slack 1.05 → t = 2.1).
812        let delta = array![0.0_f64, 0.0, 0.0, -1.0, 0.0, 0.0];
813        let (step, blocking) = set
814            .max_feasible_step(beta.view(), delta.view(), &[])
815            .expect("step");
816        assert!((step - 0.1).abs() < 1e-14, "expected 0.1, got {step}");
817        assert_eq!(blocking, Some(2));
818        // Skipping the blocking row exposes the next ratio (row 0, t = 2.1 → clamped to 1).
819        let (step_skipped, blocking_skipped) = set
820            .max_feasible_step(beta.view(), delta.view(), &[2])
821            .expect("step skipped");
822        assert!((step_skipped - 1.0).abs() < 1e-14);
823        assert_eq!(blocking_skipped, None);
824    }
825
826    #[test]
827    fn gather_rows_places_factor_rows_in_the_coupled_slot() {
828        let cone = cone_fixture();
829        // Row id 4 = slot 1 (A row 2), observation 1 → ψ = (2, −1) in cols 4..6.
830        let gathered = cone.gather_rows(&[4]).expect("gather");
831        assert_eq!(gathered.a.nrows(), 1);
832        assert_eq!(gathered.a.ncols(), 6);
833        let expected = [0.0, 0.0, 0.0, 0.0, 2.0, -1.0];
834        for (j, &e) in expected.iter().enumerate() {
835            assert_eq!(gathered.a[[0, j]], e);
836        }
837        assert_eq!(gathered.b[0], 0.0);
838    }
839
840    #[test]
841    fn constructor_rejects_bad_coupled_rows() {
842        let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
843        assert!(KhatriRaoConeConstraints::new(Arc::new(psi.clone()), vec![3], 3).is_err());
844        assert!(
845            KhatriRaoConeConstraints::new(Arc::new(psi.clone()), vec![1, 1], 3).is_err()
846        );
847        assert!(KhatriRaoConeConstraints::new(Arc::new(psi), vec![], 3).is_err());
848    }
849
850    #[test]
851    fn shifted_to_delta_matches_dense_shift() {
852        let cone = cone_fixture();
853        let set = ConstraintSet::KhatriRaoCone(cone);
854        let beta = beta_fixture();
855        let shifted = set.shifted_to_delta(beta.view()).expect("shift");
856        // Oracle: dense shift b' = b − Aβ.
857        let dense = set.to_dense().expect("dense");
858        let expected_b = &dense.b - &dense.a.dot(&beta);
859        for row in 0..set.nrows() {
860            assert!(
861                (shifted.bound(row).expect("bound") - expected_b[row]).abs() < 1e-14,
862                "shifted bound mismatch at row {row}"
863            );
864        }
865        // The delta system at δ = 0 has slack equal to the original at β.
866        let zero = Array1::<f64>::zeros(set.ncols());
867        let (viol_delta, row_delta) = shifted
868            .max_scaled_violation(zero.view())
869            .expect("delta violation");
870        let (viol_orig, row_orig) = set.max_scaled_violation(beta.view()).expect("violation");
871        assert!((viol_delta - viol_orig).abs() < 1e-14);
872        assert_eq!(row_delta, row_orig);
873    }
874
875    #[test]
876    fn block_diagonal_composes_ids_bounds_and_values() {
877        // Block 0: dense 2-row system on columns 0..2; block 1: cone on 2..8.
878        let dense = LinearInequalityConstraints::new(
879            array![[1.0_f64, 0.0], [0.0, -2.0]],
880            array![0.5_f64, -1.0],
881        )
882        .expect("dense block");
883        let cone = cone_fixture();
884        let joint = ConstraintSet::block_diagonal(
885            vec![
886                PlacedConstraintBlock {
887                    col_start: 0,
888                    set: ConstraintSet::Dense(dense.clone()),
889                },
890                PlacedConstraintBlock {
891                    col_start: 2,
892                    set: ConstraintSet::KhatriRaoCone(cone.clone()),
893                },
894            ],
895            8,
896        )
897        .expect("joint");
898        assert_eq!(joint.nrows(), 2 + 6);
899        assert_eq!(joint.ncols(), 8);
900        let mut beta = Array1::<f64>::zeros(8);
901        beta[0] = 2.0;
902        beta[1] = 1.0;
903        beta.slice_mut(ndarray::s![2..8]).assign(&beta_fixture());
904        let values = joint.values(beta.view()).expect("values");
905        assert!((values[0] - 2.0).abs() < 1e-15);
906        assert!((values[1] + 2.0).abs() < 1e-15);
907        let cone_values = cone.values(beta_fixture().view()).expect("cone values");
908        for (idx, &cv) in cone_values.iter().enumerate() {
909            assert!((values[2 + idx] - cv).abs() < 1e-15);
910        }
911        assert_eq!(joint.bound(0).expect("b0"), 0.5);
912        assert_eq!(joint.bound(2).expect("b2"), 0.0);
913        // Gathered joint row 3 (= cone row 1) occupies columns 2 + [2..4).
914        let gathered = joint.gather_rows(&[3]).expect("gather");
915        assert_eq!(gathered.a.ncols(), 8);
916        assert_eq!(gathered.a[[0, 4]], 2.0);
917        assert_eq!(gathered.a[[0, 5]], -1.0);
918        // Overlapping ranges are rejected.
919        assert!(
920            ConstraintSet::block_diagonal(
921                vec![
922                    PlacedConstraintBlock {
923                        col_start: 0,
924                        set: ConstraintSet::Dense(dense.clone()),
925                    },
926                    PlacedConstraintBlock {
927                        col_start: 1,
928                        set: ConstraintSet::Dense(dense),
929                    },
930                ],
931                8,
932            )
933            .is_err()
934        );
935    }
936
937    #[test]
938    fn zero_factor_rows_are_vacuous_not_violations() {
939        // Ψ with an all-zero observation row: 0ᵀβ ≥ 0 is vacuous and must be
940        // skipped by violation and ratio sweeps (norm 0), matching the dense
941        // canonicalization contract for zero rows with b ≤ 0.
942        let psi = array![[0.0_f64, 0.0], [1.0, 1.0]];
943        let cone = KhatriRaoConeConstraints::new(Arc::new(psi), vec![1], 2).expect("cone");
944        let set = ConstraintSet::KhatriRaoCone(cone);
945        let beta = array![0.0_f64, 0.0, -5.0, 4.0];
946        // Slot 0: values (0, −1). Row 0 vacuous; row 1 violated by 1/√2.
947        let (violation, row) = set.max_scaled_violation(beta.view()).expect("violation");
948        assert_eq!(row, Some(1));
949        assert!((violation - 1.0 / 2.0_f64.sqrt()).abs() < 1e-14);
950    }
951}