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