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