pounce_sensitivity/activity.rs
1//! Post-solve activity classification (the covariance/information
2//! roadmap's item 0, gh #362).
3//!
4//! Classifies every bounded variable and every finite-bounded inequality
5//! row of a converged barrier solve into one of five statuses, keyed on
6//! the ratio of barrier curvature to the model's own curvature:
7//!
8//! ```text
9//! r = Σ / q, Σ = z/s summed over the sides that exist,
10//! q = |H_ii| (variable)
11//! |∇dⱼᵀ H ∇dⱼ| / ‖∇dⱼ‖⁴ (inequality row)
12//! ```
13//!
14//! The row denominator carries the fourth power so that `r` is
15//! invariant to rescaling the row: `d → c·d` sends `Σ → Σ/c²` while
16//! the curvature along the unit normal is unchanged, and `‖∇d‖⁴`
17//! restores the balance. Equivalently, the geometric barrier weight
18//! `Σ‖∇d‖²` (distance to the surface is `d/‖∇d‖`, its conjugate
19//! multiplier `v‖∇d‖`) is measured against the curvature along the
20//! unit normal. Variable bounds are invariant as written. This also
21//! absorbs the solver's own per-row `d_scale`.
22//!
23//! `H` is the exact Lagrangian Hessian, so constraint curvature
24//! contributes to `q` alongside the objective's. For variables, `q`
25//! reads the Hessian DIAGONAL only, so purely off-diagonal coupling is
26//! invisible to it: `f = x₁x₂` with bounds on both variables reports
27//! `unidentified` on every bound even though the bound directions have
28//! well-defined curvature. Items 1-4 of the covariance roadmap inherit
29//! these semantics where they consume the per-coordinate statuses;
30//! their reduced-block classification is where coupling becomes
31//! visible, folded into the reduced diagonal by elimination.
32//!
33//! # The diagonal is not the curvature that generates the multiplier
34//!
35//! The same DIAGONAL-only reading has a consequence sharper than a
36//! missed `q`, and it is the one to know before building on a status
37//! (gh#763). At a kink the multiplier is generated by the curvature
38//! **reduced** along the coordinate — what is left after the other
39//! free variables re-optimize — not by `H_ii`. Eliminating a free
40//! partner `y` from `[[h, c], [c, m]]` leaves `h − c²/m`, and
41//! `Σ = z/s` equals exactly that, so
42//!
43//! ```text
44//! r = reduced / diagonal
45//! ```
46//!
47//! which is `1` only where the coordinate is **decoupled**. Couple it
48//! and a genuine kink drops out of the `[1e-1, 1e1]` band and reads
49//! [`AMBIGUOUS`] — at any tolerance, because `r` there is
50//! `μ`-independent, so re-solving tighter reports the same thing. On a
51//! collocation model coupling between neighbouring coordinates is the
52//! normal case, not a corner.
53//!
54//! So **[`AMBIGUOUS`] is not "probably not a kink"**, and the class
55//! must not be used as a proxy for kink-ness. That inference is not
56//! hypothetical: gh#756 made it and shipped a first-order wrong
57//! derivative. [`reduced_activity`] is the accessor that answers the
58//! question the class does not — one back-solve per coordinate,
59//! normalizing by the reduced curvature — and on it the same kink
60//! reads [`WEAKLY_ACTIVE`] at every coupling.
61//!
62//! The default stays the diagonal because the reduced normalizer is
63//! the reciprocal diagonal of an *inverse*: there is no
64//! diagonal-of-the-inverse shortcut, so classifying every bounded
65//! variable that way is `n` back-solves, and at 62k variables that is
66//! not a post-solve diagnostic any more. The refinement is on demand,
67//! over the entries in question.
68//!
69//! The row path normalizes by a directional curvature rather than a
70//! diagonal, and `∇dᵀH∇d/‖∇d‖²` is a genuine curvature along the
71//! row's own gradient — strictly better than a bare `H_ii`, which is
72//! why it was not the one gh#763 fixed. But it is not *reduced*
73//! either: the other free coordinates still re-optimize. So a row's
74//! `r` is `reduced/directional` by the same algebra, `1` only where
75//! the row's direction is decoupled from the remaining free space,
76//! and a coupled row kink reads [`AMBIGUOUS`] at any tolerance for
77//! the same `μ`-independent reason (gh#804).
78//! [`reduced_row_activity`] is the row half of the answer, one
79//! back-solve per row: the row's own value IS a coordinate of the KKT
80//! system — the slack the barrier acts on, tied to the model by
81//! `dⱼ(x) = sⱼ` — so it is the same back-solve one block over.
82//!
83//! `r` is `O(μ)` when the bound is inactive, `O(1)` when weakly active
84//! (slack and multiplier vanish together), and `O(1/μ)` when strongly
85//! active, so one ratio separates the regimes at any `μ` where a fixed
86//! threshold on the slack or the multiplier alone cannot: both are
87//! `O(√μ)` at weak activity, so any constant tracks the solve rather
88//! than the geometry.
89//!
90//! Everything read here is retained by the converged state the
91//! backsolver already holds: the bound multipliers on the iterate, the
92//! solver's own slacks, `Σ` through the backsolver's
93//! `barrier_sigma_x` / `barrier_sigma_s` — `curr_sigma_x` /
94//! `curr_sigma_s` unless the held iterate came from crossover, in which
95//! case the declared-frame diagonal the factor is also built with
96//! (gh#654) — the barrier parameter, and the exact Lagrangian Hessian,
97//! so `H` is never recovered from the barrier-augmented factor.
98//!
99//! The report is indexed in **user space**: `var_*` arrays have the
100//! user TNLP's full variable count and `row_*` arrays its full
101//! constraint count. A variable removed internally by
102//! `fixed_variable_treatment = make_parameter` (`lb == ub`, the
103//! default) reports [`FIXED`] at its own user index, and an equality
104//! constraint reports [`EQUALITY`], so user indices never shift.
105
106use std::rc::Rc;
107
108use pounce_common::types::{Index, Number};
109use pounce_linalg::Matrix;
110use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
111use pounce_linalg::expansion_matrix::ExpansionMatrix;
112use pounce_linalg::triplet::{GenTMatrix, SymTMatrix};
113
114use crate::PdSensBacksolver;
115use crate::backsolver::SensBacksolver;
116use crate::vec_util::dense_to_vec;
117
118// The status codes and the classification rule now live in
119// `pounce-sens-core`, so the convex arm decides what a kink is with the same
120// code rather than a parallel one. Re-exported here because
121// `pounce_sensitivity::activity::WEAKLY_ACTIVE` (and its siblings) is the path
122// `pounce-py` and four test files already use.
123pub use pounce_sens_core::activity_kernel::{
124 AMBIGUOUS, EQUALITY, FIXED, INACTIVE, STRONGLY_ACTIVE, UNBOUNDED, UNIDENTIFIED, WEAKLY_ACTIVE,
125};
126use pounce_sens_core::activity_kernel::{
127 Entry, NOT_CLASSIFIED, classify_entry, off_path, sign_of, zero_gradient_row,
128};
129
130/// Per-variable and per-row classification of a converged solve.
131///
132/// All vectors are **user-space**: `var_*` have length `n_full_x` (the
133/// user TNLP's `n`) and `row_*` length `n_full_g` (the user's `m`).
134/// Entries with no finite bound hold [`UNBOUNDED`]; [`FIXED`]
135/// variables and [`EQUALITY`] rows are placeholders for entries the
136/// barrier never classified. All three carry `NaN` ratios.
137pub struct ActivityReport {
138 /// Barrier parameter of the converged iterate.
139 pub mu: Number,
140 /// Status per user variable (codes above).
141 ///
142 /// [`AMBIGUOUS`] here includes genuine kinks whose coordinate is
143 /// coupled to a neighbour: `q` is the Hessian diagonal, not the
144 /// reduced curvature that generates the multiplier, so the ratio
145 /// is `reduced/diagonal` (gh#763). Do not read the class as an
146 /// answer to "is this bound at a kink" — [`reduced_activity`]
147 /// answers that, one back-solve per coordinate.
148 pub var_status: Vec<i8>,
149 /// `Σ_i / q_i` per user variable; `NaN` where not classified.
150 /// For an [`UNIDENTIFIED`] entry the value is `Σ/floor`, a lower
151 /// bound on any honest ratio rather than the ratio itself, since
152 /// `q` is below the identification floor there.
153 ///
154 /// `q` is `|H_ii|`, so at a kink this ratio is
155 /// `reduced/diagonal` — `1` only where the coordinate is
156 /// decoupled, and `μ`-independent, so a tighter solve does not
157 /// move it. See [`Self::var_status`].
158 pub var_ratio: Vec<Number>,
159 /// Sign of the signed curvature `H_ii` (−1, 0, +1); the absolute
160 /// value goes into `q`, so an indefinite direction is reported
161 /// rather than hidden.
162 pub var_q_sign: Vec<i8>,
163 /// `s·z` differs from `μ` by more than a factor of ten on some
164 /// side: off the central path, or the bound was relaxed.
165 pub var_off_central_path: Vec<bool>,
166 /// Classified inactive yet `r` non-negligible: barrier curvature
167 /// where none should be.
168 pub var_contaminated: Vec<bool>,
169 /// The barrier diagonal `Σ_i = z/s` itself per user variable, both
170 /// sides summed; 0 where not classified. In **natural (unscaled)
171 /// units**, the repo's sensitivity-output contract: classification
172 /// runs on the solver's scaled quantities (the ratio is
173 /// scale-invariant), the report does not. The covariance roadmap's
174 /// item 1 subtracts exactly this from the factor's natural-units
175 /// reduced Hessian.
176 pub var_sigma: Vec<Number>,
177 /// Status per user constraint row.
178 ///
179 /// [`AMBIGUOUS`] here includes genuine kinks whose direction is
180 /// coupled to the remaining free space: `q` is the curvature
181 /// along the row's own gradient, not the reduced curvature that
182 /// generates the multiplier, so the ratio is
183 /// `reduced/directional` (gh#804). Do not read the class as an
184 /// answer to "is this row at a kink" — [`reduced_row_activity`]
185 /// answers that, one back-solve per row.
186 pub row_status: Vec<i8>,
187 /// `Σ_j / q_j` per user row; `NaN` where not classified.
188 /// [`UNIDENTIFIED`] entries hold `Σ/floor` as for variables.
189 ///
190 /// `q` is the directional curvature `|∇dᵀH∇d|/‖∇d‖²`, so at a
191 /// kink this ratio is `reduced/directional` — `1` only where the
192 /// row's direction is decoupled, and `μ`-independent, so a
193 /// tighter solve does not move it. See [`Self::row_status`].
194 pub row_ratio: Vec<Number>,
195 /// Sign of the signed row curvature `∇dⱼᵀ H ∇dⱼ`.
196 pub row_q_sign: Vec<i8>,
197 /// Central-path check per row, as for variables.
198 pub row_off_central_path: Vec<bool>,
199 /// Contamination check per row, as for variables.
200 pub row_contaminated: Vec<bool>,
201 /// The row barrier diagonal `Σ_j = v/s` per user row, both sides
202 /// summed; 0 where not classified. In **natural (unscaled) units**
203 /// like [`Self::var_sigma`], and RAW rather than the geometric
204 /// weight the classification uses: item 1 restricts the normal to
205 /// its own fitted block and applies its own `‖a‖²` factor there.
206 pub row_sigma: Vec<Number>,
207}
208
209/// Scatter a compressed (bounded-entries-only) vector to full length
210/// through its expansion matrix. Entries without that bound stay 0.
211fn expand(compressed: &[Number], px: &Rc<dyn Matrix>, n: usize) -> Vec<Number> {
212 let em = px
213 .as_any()
214 .downcast_ref::<ExpansionMatrix>()
215 .expect("bound projection is an ExpansionMatrix (orig_ipopt_nlp builds no other kind)");
216 let idx = em.expanded_pos_indices();
217 assert_eq!(
218 idx.len(),
219 compressed.len(),
220 "compressed bound vector length disagrees with its expansion",
221 );
222 let mut full = vec![0.0; n];
223 for (k, &pos) in idx.iter().enumerate() {
224 full[pos as usize] = compressed[k];
225 }
226 full
227}
228
229/// Presence mask for a bound side, from the same expansion.
230fn present(px: &Rc<dyn Matrix>, n: usize) -> Vec<bool> {
231 let em = px
232 .as_any()
233 .downcast_ref::<ExpansionMatrix>()
234 .expect("bound projection is an ExpansionMatrix (orig_ipopt_nlp builds no other kind)");
235 let mut mask = vec![false; n];
236 for &pos in em.expanded_pos_indices() {
237 mask[pos as usize] = true;
238 }
239 mask
240}
241
242/// The exact Hessian diagonal: one pass over the triplet structure for
243/// the type `eval_h` builds today. The mat-vec fallback keeps any
244/// future non-triplet `SymMatrix` correct, at O(n·nnz) cost.
245fn hessian_diagonal(hess: &Rc<dyn pounce_linalg::SymMatrix>, n: usize) -> Vec<Number> {
246 let mut diag = vec![0.0; n];
247 if let Some(t) = hess.as_any().downcast_ref::<SymTMatrix>() {
248 // triplet indices are 1-based (the GenTMatrix convention);
249 // duplicates accumulate, matching mult_vector
250 for ((&i, &j), &v) in t.irows().iter().zip(t.jcols()).zip(t.values()) {
251 if i == j {
252 diag[(i - 1) as usize] += v;
253 }
254 }
255 return diag;
256 }
257 let space = DenseVectorSpace::new(n as i32);
258 let mut e = DenseVector::new(space.clone());
259 let mut he = DenseVector::new(space);
260 for (i, d) in diag.iter_mut().enumerate() {
261 e.values_mut().fill(0.0);
262 e.values_mut()[i] = 1.0;
263 he.values_mut().fill(0.0);
264 hess.mult_vector(1.0, &e, 0.0, &mut he);
265 // values_mut, not values: a zero product may have left the
266 // output homogeneous (empty backing slice); this materializes
267 *d = he.values_mut()[i];
268 }
269 diag
270}
271
272/// The per-variable pieces both classifiers measure against, with the
273/// `user-scaling` change of variables (gh#486 stage 3) divided out and
274/// the objective scale `df` still in: `Σ̃·d²` and `H̃_ii·d² = df·H_ii`.
275/// The ratio of the two is invariant to both, so classification runs
276/// here; the identification `floor` is not (it is one number shared
277/// across entries), which is why the change of variables comes out
278/// before it is formed.
279///
280/// Extracted so [`compute`] and [`reduced_activity`] cannot drift on
281/// the frame conversion — the dimension leg 1 of
282/// `sens_invariance_legs.rs` exists for.
283struct VarFrame {
284 /// Barrier diagonal `Σ_i` per var-x column.
285 sigma: Vec<Number>,
286 /// Exact-Lagrangian-Hessian diagonal per var-x column.
287 diag: Vec<Number>,
288 /// Identification floor shared by every entry, relative to the
289 /// largest curvature anywhere on the diagonal rather than just the
290 /// bounded entries, so a row-only model still measures `q` against
291 /// the model's own scale.
292 floor: Number,
293}
294
295fn var_frame(bs: &PdSensBacksolver, hess: &Rc<dyn pounce_linalg::SymMatrix>, n: usize) -> VarFrame {
296 let d_var = bs.variable_scaling();
297 let dv = |i: usize| -> Number { d_var.map_or(1.0, |d| d[i]) };
298 // `Σ̃_i = df·Σ_i/d_i²`: the `d_i²` comes out here, the `df` at the
299 // caller's export boundary (it cancels in every ratio, so
300 // classification never sees it).
301 let sigma = dense_to_vec(bs.barrier_sigma_x().as_ref())
302 .iter()
303 .enumerate()
304 .map(|(i, &s)| s * dv(i) * dv(i))
305 .collect();
306 let diag: Vec<Number> = hessian_diagonal(hess, n)
307 .iter()
308 .enumerate()
309 .map(|(i, &h)| h * dv(i) * dv(i))
310 .collect();
311 let max_abs_diag = diag.iter().fold(0.0, |a: Number, d| a.max(d.abs()));
312 let floor = Number::EPSILON.sqrt() * max_abs_diag.max(1.0);
313 VarFrame { sigma, diag, floor }
314}
315
316pub(crate) fn compute(bs: &PdSensBacksolver) -> ActivityReport {
317 let (data, cq, nlp) = bs.activity_handles();
318
319 // scoped borrows: the Cq getters below re-borrow the NLP (mutably,
320 // for lazy evaluation) and the data, so nothing here may hold
321 // either across a Cq call
322 let mu = bs.barrier_mu();
323 let (mult_z_l, mult_z_u, mult_v_l, mult_v_u, n, m_d) = {
324 let d = data.borrow();
325 let curr = d.curr.as_ref().expect("converged state has an iterate");
326 (
327 Rc::clone(&curr.z_l),
328 Rc::clone(&curr.z_u),
329 Rc::clone(&curr.v_l),
330 Rc::clone(&curr.v_u),
331 curr.x.dim() as usize,
332 curr.s.dim() as usize,
333 )
334 };
335 let (px_l, px_u, pd_l, pd_u, obj_scale, d_scale) = {
336 let nl = nlp.borrow();
337 (
338 nl.px_l(),
339 nl.px_u(),
340 nl.pd_l(),
341 nl.pd_u(),
342 nl.obj_scaling_factor(),
343 nl.d_scale_vec(),
344 )
345 };
346 let cq = cq.borrow();
347
348 // Per-variable factors of a `user-scaling` change of variables
349 // (gh#486 stage 3), in var-x space; 1.0 everywhere when none ran.
350 // Every internal x-space quantity below is a `d`-transform of the
351 // model's own — writing `a_j` for the gradient of inequality row
352 // `j`, since `d` is spoken for here: `ã = a ⊘ d`,
353 // `H̃ = H ⊘ (d ⊗ d)`, `Σ̃ = Σ · df ⊘ (d ⊙ d)`. Undoing that here rather than only on
354 // the exported `Σ` is what keeps a status from depending on the
355 // conditioning the user asked for: the per-entry ratio `Σ/q` is
356 // invariant, but the identification `floor` is a single number
357 // shared across entries, so a non-uniform `d` moves entries across
358 // it. `1.0` multiplies are exact, so an unscaled solve is
359 // bit-identical to the pre-#486 path.
360 let d_var = bs.variable_scaling();
361 let dv = |i: usize| -> Number { d_var.map_or(1.0, |d| d[i]) };
362
363 // --- variables, in internal space ------------------------------------
364 let has_l = present(&px_l, n);
365 let has_u = present(&px_u, n);
366 let z_l = expand(&dense_to_vec(mult_z_l.as_ref()), &px_l, n);
367 let z_u = expand(&dense_to_vec(mult_z_u.as_ref()), &px_u, n);
368 // The solver's own slacks, deliberately, even when `Σ` below comes
369 // from the declared frame (gh#654): these feed `off_path` only, and
370 // "is `s·z` near `μ`" is a question about the central path, which is
371 // the barrier's geometry and therefore the barrier's slacks. A
372 // crossed-over point is off that path by construction and reads so
373 // under either frame.
374 let s_l = expand(&dense_to_vec(cq.curr_slack_x_l().as_ref()), &px_l, n);
375 let s_u = expand(&dense_to_vec(cq.curr_slack_x_u().as_ref()), &px_u, n);
376 // `Σ` and the Hessian diagonal, with the change of variables
377 // already divided out and the objective scale still in; the `df`
378 // comes off on export below (it cancels in every ratio, so
379 // classification never sees it).
380 let hess = cq.curr_exact_hessian();
381 let VarFrame {
382 sigma: sigma_x,
383 diag,
384 floor,
385 } = var_frame(bs, &hess, n);
386
387 let mut vars = vec![NOT_CLASSIFIED; n];
388 for i in 0..n {
389 if !(has_l[i] || has_u[i]) {
390 continue;
391 }
392 let mut e = classify_entry(sigma_x[i], diag[i], floor, mu);
393 e.off_path = (has_l[i] && off_path(s_l[i], z_l[i], mu))
394 || (has_u[i] && off_path(s_u[i], z_u[i], mu));
395 // the ratio is scale-invariant, so classification ran in the
396 // solver's own space up to the change of variables already
397 // divided out of `sigma_x` / `diag` above; the REPORTED sigma
398 // follows the repo's natural-units contract, and what is left
399 // to undo is the objective scale the internal z carries
400 e.sigma /= obj_scale;
401 vars[i] = e;
402 }
403
404 // --- inequality rows, in internal space -------------------------------
405 let rhas_l = present(&pd_l, m_d);
406 let rhas_u = present(&pd_u, m_d);
407 let v_l = expand(&dense_to_vec(mult_v_l.as_ref()), &pd_l, m_d);
408 let v_u = expand(&dense_to_vec(mult_v_u.as_ref()), &pd_u, m_d);
409 let rs_l = expand(&dense_to_vec(cq.curr_slack_s_l().as_ref()), &pd_l, m_d);
410 let rs_u = expand(&dense_to_vec(cq.curr_slack_s_u().as_ref()), &pd_u, m_d);
411 let sigma_s = dense_to_vec(bs.barrier_sigma_s().as_ref());
412
413 let jac_d = cq.curr_jac_d();
414 // One pass over the Jacobian triplets gathers every row's support
415 // and one pass over the Hessian triplets builds an adjacency view,
416 // so each row's curvature costs its own support times its
417 // neighbours instead of a full mat-vec pair per row (second
418 // review). The mat-vec loop below remains the fallback for any
419 // future non-triplet matrix types.
420 let mut rows = vec![NOT_CLASSIFIED; m_d];
421 let fast = match (
422 jac_d.as_any().downcast_ref::<GenTMatrix>(),
423 hess.as_any().downcast_ref::<SymTMatrix>(),
424 ) {
425 (Some(jt), Some(ht)) => {
426 // gather and merge each row's entries (triplet duplicates
427 // sum, matching mult_vector; indices are 1-based)
428 let mut support: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m_d];
429 for ((&r, &c), &v) in jt.irows().iter().zip(jt.jcols()).zip(jt.values()) {
430 let col = (c - 1) as usize;
431 // `a = ã ⊙ d`: the row's own scale stays (the ratio
432 // divides it out), the change of variables does not.
433 support[(r - 1) as usize].push((col, v * dv(col)));
434 }
435 for sup in &mut support {
436 sup.sort_unstable_by_key(|&(c, _)| c);
437 sup.dedup_by(|a, b| {
438 if a.0 == b.0 {
439 b.1 += a.1;
440 true
441 } else {
442 false
443 }
444 });
445 }
446 let mut adj: Vec<Vec<(usize, Number)>> = vec![Vec::new(); n];
447 for ((&i, &l), &v) in ht.irows().iter().zip(ht.jcols()).zip(ht.values()) {
448 let (a, b) = ((i - 1) as usize, (l - 1) as usize);
449 // `H = H̃ ⊙ (d ⊗ d)`, matching the `d²` already taken
450 // out of `diag` (which sets the shared floor).
451 let v = v * dv(a) * dv(b);
452 adj[a].push((b, v));
453 if a != b {
454 adj[b].push((a, v));
455 }
456 }
457 let mut scratch = vec![0.0; n];
458 for j in 0..m_d {
459 if !(rhas_l[j] || rhas_u[j]) {
460 continue;
461 }
462 let sup = &support[j];
463 let norm2: Number = sup.iter().map(|&(_, g)| g * g).sum();
464 rows[j] = if norm2 <= 0.0 {
465 zero_gradient_row(sigma_s[j], floor)
466 } else {
467 for &(k, g) in sup {
468 scratch[k] = g;
469 }
470 let mut ghg = 0.0;
471 for &(k, gk) in sup {
472 let mut acc = 0.0;
473 for &(l, v) in &adj[k] {
474 acc += v * scratch[l];
475 }
476 ghg += gk * acc;
477 }
478 for &(k, _) in sup {
479 scratch[k] = 0.0;
480 }
481 // Σ·‖∇d‖² against curvature along the unit
482 // normal: invariant to rescaling the row; the
483 // report keeps the raw Σ
484 let mut e = classify_entry(sigma_s[j] * norm2, ghg / norm2, floor, mu);
485 e.sigma = sigma_s[j];
486 e
487 };
488 }
489 true
490 }
491 _ => false,
492 };
493 if !fast {
494 let mspace = DenseVectorSpace::new(m_d as i32);
495 let mut e_row = DenseVector::new(mspace);
496 let nspace = DenseVectorSpace::new(n as i32);
497 let mut grad = DenseVector::new(nspace.clone());
498 let mut hgrad = DenseVector::new(nspace);
499 for j in 0..m_d {
500 if !(rhas_l[j] || rhas_u[j]) {
501 continue;
502 }
503 // ∇dⱼ = Jdᵀ eⱼ, then the curvature along the normal;
504 // values_mut throughout because a zero product may leave
505 // the output homogeneous (empty backing slice)
506 e_row.values_mut().fill(0.0);
507 e_row.values_mut()[j] = 1.0;
508 grad.values_mut().fill(0.0);
509 jac_d.trans_mult_vector(1.0, &e_row, 0.0, &mut grad);
510 // `a = ã ⊙ d`, then `aᵀHa = uᵀH̃u` with `u = d ⊙ a`
511 // (since `H = H̃ ⊙ (d ⊗ d)`) — so the vector handed to the
512 // internal Hessian carries `d²`, and `norm2` carries `d`.
513 let norm2: Number = grad
514 .values_mut()
515 .iter()
516 .enumerate()
517 .map(|(i, g)| (*g * dv(i)) * (*g * dv(i)))
518 .sum();
519 rows[j] = if norm2 <= 0.0 {
520 zero_gradient_row(sigma_s[j], floor)
521 } else {
522 for (i, g) in grad.values_mut().iter_mut().enumerate() {
523 *g *= dv(i) * dv(i);
524 }
525 hgrad.values_mut().fill(0.0);
526 hess.mult_vector(1.0, &grad, 0.0, &mut hgrad);
527 let ghg: Number = {
528 let h = hgrad.values_mut();
529 grad.values_mut()
530 .iter()
531 .zip(h.iter())
532 .map(|(g, h)| g * h)
533 .sum()
534 };
535 let mut e = classify_entry(sigma_s[j] * norm2, ghg / norm2, floor, mu);
536 e.sigma = sigma_s[j];
537 e
538 };
539 }
540 }
541 for j in 0..m_d {
542 if !(rhas_l[j] || rhas_u[j]) {
543 continue;
544 }
545 rows[j].off_path = (rhas_l[j] && off_path(rs_l[j], v_l[j], mu))
546 || (rhas_u[j] && off_path(rs_u[j], v_u[j], mu));
547 // natural-units report, as for variables: the scaled row
548 // multiplier carries df/dg and the scaled slack dg, so
549 // Sigma_nat = Sigma * dg^2 / df
550 let dg = d_scale.as_ref().map_or(1.0, |v| v[j]);
551 rows[j].sigma *= dg * dg / obj_scale;
552 }
553
554 // --- scatter to user space --------------------------------------------
555 // all Cq evaluation is done, so borrowing the NLP again is safe
556 let nl = nlp.borrow();
557 let n_full_x = nl.n_full_x() as usize;
558 let n_full_g = nl.n_full_g() as usize;
559
560 let fixed_entry = Entry {
561 status: FIXED,
562 ..NOT_CLASSIFIED
563 };
564 let mut var_full = vec![fixed_entry; n_full_x];
565 for (i, e) in vars.iter().enumerate() {
566 var_full[nl.var_x_to_full_x(i as Index) as usize] = *e;
567 }
568
569 let equality_entry = Entry {
570 status: EQUALITY,
571 ..NOT_CLASSIFIED
572 };
573 let mut row_full = vec![equality_entry; n_full_g];
574 // BoundClassification's d_map is one ascending scan over the
575 // user's g, so the j-th full-g index outside the c-block is
576 // internal inequality row j
577 let mut d_pos = 0usize;
578 for (full_idx, slot) in row_full.iter_mut().enumerate() {
579 if nl.full_g_to_c_block(full_idx as Index).is_none() {
580 *slot = rows[d_pos];
581 d_pos += 1;
582 }
583 }
584 assert_eq!(d_pos, m_d, "inequality count disagrees with the c/d split");
585
586 ActivityReport {
587 mu,
588 var_status: var_full.iter().map(|e| e.status).collect(),
589 var_ratio: var_full.iter().map(|e| e.ratio).collect(),
590 var_q_sign: var_full.iter().map(|e| e.q_sign).collect(),
591 var_off_central_path: var_full.iter().map(|e| e.off_path).collect(),
592 var_contaminated: var_full.iter().map(|e| e.contaminated).collect(),
593 var_sigma: var_full.iter().map(|e| e.sigma).collect(),
594 row_status: row_full.iter().map(|e| e.status).collect(),
595 row_ratio: row_full.iter().map(|e| e.ratio).collect(),
596 row_q_sign: row_full.iter().map(|e| e.q_sign).collect(),
597 row_off_central_path: row_full.iter().map(|e| e.off_path).collect(),
598 row_contaminated: row_full.iter().map(|e| e.contaminated).collect(),
599 row_sigma: row_full.iter().map(|e| e.sigma).collect(),
600 }
601}
602
603/// One coordinate's activity re-measured against the curvature that
604/// actually generates its multiplier, rather than against the Hessian
605/// diagonal (gh#763).
606///
607/// Parallel arrays, one entry per requested variable, in the order
608/// they were requested. See [`reduced_activity`] for what the
609/// quantities mean.
610#[derive(Debug, Clone)]
611pub struct ReducedActivityReport {
612 /// Barrier parameter of the converged iterate, as
613 /// [`ActivityReport::mu`].
614 pub mu: Number,
615 /// The user-space variable index each entry answers about.
616 pub var: Vec<usize>,
617 /// Status from the same rule [`ActivityReport::var_status`] uses,
618 /// applied to [`Self::ratio`].
619 pub status: Vec<i8>,
620 /// `Σ_i / |q_i^red|`. `NaN` where nothing was classified.
621 pub ratio: Vec<Number>,
622 /// The reduced curvature `q_i^red` itself, signed, in **natural
623 /// (unscaled) units** like [`ActivityReport::var_sigma`]. `NaN`
624 /// for a [`FIXED`] variable and where the factor offers no
625 /// reduced curvature (see [`reduced_activity`]).
626 pub q_reduced: Vec<Number>,
627 /// Sign of [`Self::q_reduced`] (−1, 0, +1); the absolute value is
628 /// what the ratio divides by.
629 pub q_sign: Vec<i8>,
630 /// `Σ_i`, natural units, identical to the same variable's entry in
631 /// [`ActivityReport::var_sigma`].
632 pub sigma: Vec<Number>,
633}
634
635/// Why a [`reduced_activity`] call could not be answered.
636pub(crate) enum ReducedActivityError {
637 /// A requested index is not a user variable. Carries the offending
638 /// index and the user TNLP's `n`.
639 OutOfRange { got: usize, n_full_x: usize },
640 /// The back-solve against the held factor failed.
641 Backsolve,
642}
643
644/// [`compute`]'s per-variable classification, re-normalized by the
645/// **reduced** curvature along each requested coordinate instead of
646/// the Hessian diagonal — one back-solve against the held factor per
647/// variable asked about (gh#763).
648///
649/// # What is different
650///
651/// [`compute`] forms a variable's ratio as `Σ_i / |H_ii|`. At a kink
652/// the multiplier is not generated by the diagonal: it is generated by
653/// the curvature **reduced** along that coordinate, i.e. what is left
654/// after the other free variables re-optimize. Eliminating a free
655/// partner `y` from `[[h, c], [c, m]]` leaves `h − c²/m`, and
656/// `Σ = z/s` equals that, so the diagonal ratio is `1` only when the
657/// coordinate is **decoupled** (`c = 0`). Couple it and a genuine kink
658/// reads [`AMBIGUOUS`] at any tolerance, because the ratio is
659/// `μ`-independent — routine on a collocation model, where coupling
660/// between neighbouring coordinates is the normal case. This function
661/// is the answer to "is that AMBIGUOUS entry a kink?": on the reduced
662/// normalizer the same kink reads [`WEAKLY_ACTIVE`] whatever it is
663/// coupled to.
664///
665/// # How it is computed
666///
667/// The reduced curvature the barrier subproblem sees along coordinate
668/// `i` is the reciprocal of the `i`-th diagonal entry of the inverse
669/// of the barrier-augmented KKT matrix — one column of `K⁻¹`, so one
670/// back-solve — with the barrier's own contribution at `i` taken back
671/// off:
672///
673/// ```text
674/// q_i^red = 1 / (K⁻¹)_ii − Σ_i
675/// ```
676///
677/// Adding `diag(Σ)` to the free block shifts entry `i` by exactly
678/// `Σ_i`, which the subtraction removes; it also shifts every OTHER
679/// free coordinate, which is deliberately kept. That is what makes the
680/// answer the curvature the multiplier is generated against: a
681/// strongly active neighbour carries `Σ = O(1/μ)` and so does not
682/// re-optimize, an inactive one carries `O(μ)` and re-optimizes
683/// freely, and the elimination weights each accordingly. Eliminating
684/// against the bare Hessian would let a pinned neighbour move.
685///
686/// `K` here is the natural-units KKT matrix, so `q_i^red` and `Σ_i`
687/// are both in the model's own units and the ratio is invariant to
688/// `nlp_scaling_method` and to a `user-scaling` change of variables,
689/// exactly as [`compute`]'s is.
690///
691/// # Cost, and why this is not the default
692///
693/// The correct normalizer is the reciprocal diagonal of an *inverse*,
694/// and there is no diagonal-of-the-inverse shortcut: it is one
695/// back-solve per coordinate. Classifying every bounded variable this
696/// way is `n` back-solves, which on a 62k-variable model is not a
697/// post-solve diagnostic any more. So [`compute`] keeps the `O(nnz)`
698/// diagonal and this is the on-demand refinement — the natural call is
699/// over the [`AMBIGUOUS`] entries of a report, which is a handful on
700/// the models where the question arises.
701///
702/// Pass those indices in ONE call: the back-solves batch, and the
703/// per-call fixed cost — one pass over the Hessian for the shared
704/// identification floor — is paid once rather than per index.
705///
706/// # Edge cases
707///
708/// * A [`FIXED`] variable has no column in the factor: status
709/// [`FIXED`], `NaN` curvature and ratio, as in the report.
710/// * A variable with no finite bound gets its `q_i^red` (it is
711/// well-defined and already paid for) but status [`UNBOUNDED`] and a
712/// `NaN` ratio: there is no bound question to answer.
713/// * A coordinate the constraints determine outright has
714/// `(K⁻¹)_ii = 0`: no direction left to reduce along, so `q_i^red`
715/// is infinite and the ratio is `0`, i.e. [`INACTIVE`] — whatever
716/// holds that coordinate, it is not its bound. A `(K⁻¹)_ii` at
717/// roundoff level reads the same from either side of zero.
718/// * A `NaN` from the back-solve reports [`UNIDENTIFIED`] with a `NaN`
719/// curvature and ratio.
720/// * `|q_i^red|` below the same identification floor [`compute`] uses
721/// reports [`UNIDENTIFIED`], as there.
722/// * Under `obj_scaling_factor < 0` — the documented way to maximize —
723/// `Sigma` and `q_i^red` are both reported with the sign the
724/// natural-units contract gives them (negative, as
725/// [`ActivityReport::var_sigma`] is), but the CLASSIFICATION runs on
726/// the objective-scale-positive orientation [`compute`] classifies
727/// in, so a status here means the same thing at either sign of `df`.
728///
729/// Variable bounds only. The row path carries the same un-reduced
730/// distinction — see [`reduced_row_activity`], which is this function
731/// one KKT block over (gh#804).
732pub(crate) fn reduced_activity(
733 bs: &PdSensBacksolver,
734 user_vars: &[usize],
735) -> Result<ReducedActivityReport, ReducedActivityError> {
736 let (data, cq, nlp) = bs.activity_handles();
737 let mu = bs.barrier_mu();
738 let n = {
739 let d = data.borrow();
740 d.curr
741 .as_ref()
742 .expect("converged state has an iterate")
743 .x
744 .dim() as usize
745 };
746
747 // full-x in, var-x rows out, through the same map the report
748 // scatters through: reading the user index as a factor row returns
749 // a NEIGHBORING variable's answer wherever a fixed variable
750 // precedes it (the gh#450 hazard).
751 let n_full_x = bs.n_full_x() as usize;
752 let mut rows: Vec<Option<usize>> = Vec::with_capacity(user_vars.len());
753 for &i in user_vars {
754 if i >= n_full_x {
755 return Err(ReducedActivityError::OutOfRange { got: i, n_full_x });
756 }
757 rows.push(bs.full_x_to_var_x(i as Index).map(|r| r as usize));
758 }
759
760 let (px_l, px_u, obj_scale) = {
761 let nl = nlp.borrow();
762 (nl.px_l(), nl.px_u(), nl.obj_scaling_factor())
763 };
764 let has_l = present(&px_l, n);
765 let has_u = present(&px_u, n);
766 // the NLP borrow above is dropped: the Cq getter re-borrows it
767 // mutably for lazy evaluation
768 let frame = {
769 let cq = cq.borrow();
770 let hess = cq.curr_exact_hessian();
771 var_frame(bs, &hess, n)
772 };
773 // `Σ` and the floor come out of `var_frame` with the change of
774 // variables divided out but the objective scale still in; the
775 // reciprocal `(K⁻¹)_ii` below is natural units already, so both
776 // sides of the subtraction meet there.
777 let floor = frame.floor / obj_scale.abs();
778
779 // One unit RHS per requested variable, batched against the held
780 // factor. Chunked so the buffers stay bounded when a caller hands
781 // over a long list rather than the report's ambiguous entries.
782 const CHUNK: usize = 64;
783 let dim = bs.dim();
784 let solve_rows: Vec<usize> = rows.iter().flatten().copied().collect();
785 let mut kinv: Vec<Number> = Vec::with_capacity(solve_rows.len());
786 for chunk in solve_rows.chunks(CHUNK) {
787 let k = chunk.len();
788 let mut rhs = vec![0.0; k * dim];
789 let mut lhs = vec![0.0; k * dim];
790 for (c, &r) in chunk.iter().enumerate() {
791 rhs[c * dim + r] = 1.0;
792 }
793 if !bs.solve_many(&rhs, &mut lhs, k) {
794 return Err(ReducedActivityError::Backsolve);
795 }
796 for (c, &r) in chunk.iter().enumerate() {
797 kinv.push(lhs[c * dim + r]);
798 }
799 }
800
801 let mut out = ReducedActivityReport {
802 mu,
803 var: user_vars.to_vec(),
804 status: Vec::with_capacity(user_vars.len()),
805 ratio: Vec::with_capacity(user_vars.len()),
806 q_reduced: Vec::with_capacity(user_vars.len()),
807 q_sign: Vec::with_capacity(user_vars.len()),
808 sigma: Vec::with_capacity(user_vars.len()),
809 };
810 let mut next = 0usize;
811 for &row in &rows {
812 let Some(row) = row else {
813 out.status.push(FIXED);
814 out.ratio.push(Number::NAN);
815 out.q_reduced.push(Number::NAN);
816 out.q_sign.push(0);
817 out.sigma.push(0.0);
818 continue;
819 };
820 let d = kinv[next];
821 next += 1;
822 let sigma = frame.sigma[row] / obj_scale;
823 if !d.is_finite() {
824 out.status.push(UNIDENTIFIED);
825 out.ratio.push(Number::NAN);
826 out.q_reduced.push(Number::NAN);
827 out.q_sign.push(0);
828 out.sigma.push(sigma);
829 continue;
830 }
831 // `(K⁻¹)_ii = 0` — the constraints determine the coordinate
832 // outright, so there is no direction left to reduce along —
833 // sends `q` to an infinity the ratio divides to zero, i.e.
834 // INACTIVE: whatever holds the coordinate there, it is not the
835 // bound. A `(K⁻¹)_ii` at roundoff level lands in the same
836 // class from either side of zero, which is why no guard
837 // branches on its sign; a genuinely negative reduced curvature
838 // is modest in magnitude and reports through `q_sign`, exactly
839 // as an indefinite `H_ii` does in the report.
840 let q = 1.0 / d - sigma;
841 // classify in the same orientation `compute` does: it runs the
842 // rule on the df-in `Sigma` (internal `z/s`, non-negative),
843 // dividing the objective scale out only on export. Here both
844 // sides are already natural, so a NEGATIVE df -- the
845 // documented way to maximize -- would otherwise hand the rule
846 // a negative ratio and read a pinned bound as INACTIVE.
847 let sgn = if obj_scale < 0.0 { -1.0 } else { 1.0 };
848 let e = classify_entry(sigma * sgn, q * sgn, floor, mu);
849 // no finite bound: the curvature is still an answer, the
850 // activity question is not
851 let bounded = has_l[row] || has_u[row];
852 out.status.push(if bounded { e.status } else { UNBOUNDED });
853 out.ratio.push(if bounded { e.ratio } else { Number::NAN });
854 out.q_reduced.push(q);
855 out.q_sign.push(sign_of(q));
856 out.sigma.push(sigma);
857 }
858 debug_assert_eq!(next, kinv.len(), "every free row consumed its solve");
859 Ok(out)
860}
861
862/// One constraint row's activity re-measured against the curvature
863/// that actually generates its multiplier, rather than against the
864/// directional curvature along its own gradient (gh#804).
865///
866/// Parallel arrays, one entry per requested row, in the order they
867/// were requested. See [`reduced_row_activity`] for what the
868/// quantities mean.
869#[derive(Debug, Clone)]
870pub struct ReducedRowActivityReport {
871 /// Barrier parameter of the converged iterate, as
872 /// [`ActivityReport::mu`].
873 pub mu: Number,
874 /// The user-space constraint index each entry answers about.
875 pub row: Vec<usize>,
876 /// Status from the same rule [`ActivityReport::row_status`] uses,
877 /// applied to [`Self::ratio`].
878 pub status: Vec<i8>,
879 /// `Σ_j‖∇dⱼ‖² / |q_j^red|`. `NaN` where nothing was classified.
880 ///
881 /// The numerator is the geometric barrier weight, not the raw
882 /// `Σ_j` [`Self::sigma`] reports — the same pairing
883 /// [`ActivityReport::row_ratio`] uses, so the two ratios are
884 /// directly comparable and agree on a decoupled row.
885 pub ratio: Vec<Number>,
886 /// The reduced curvature `q_j^red` itself, signed, along the
887 /// **unit** normal `∇dⱼ/‖∇dⱼ‖` and in **natural (unscaled)
888 /// units** — the same quantity and units
889 /// [`ActivityReport::row_q_sign`] takes the sign of, so it is what
890 /// `|∇dⱼᵀH∇dⱼ|/‖∇dⱼ‖²` would have been had the curvature been
891 /// reduced. `NaN` for an [`EQUALITY`] row and where the back-solve
892 /// offers no reduced curvature (see [`reduced_row_activity`]).
893 pub q_reduced: Vec<Number>,
894 /// Sign of [`Self::q_reduced`] (−1, 0, +1); the absolute value is
895 /// what the ratio divides by.
896 pub q_sign: Vec<i8>,
897 /// `Σ_j`, RAW (not the geometric weight the ratio uses) and in
898 /// natural units, identical to the same row's entry in
899 /// [`ActivityReport::row_sigma`].
900 pub sigma: Vec<Number>,
901}
902
903/// Why a [`reduced_row_activity`] call could not be answered.
904pub(crate) enum ReducedRowActivityError {
905 /// A requested index is not a user constraint. Carries the
906 /// offending index and the user TNLP's `m`.
907 OutOfRange { got: usize, n_full_g: usize },
908 /// The back-solve against the held factor failed.
909 Backsolve,
910}
911
912/// `‖∇dⱼ‖²` for the requested internal inequality rows, in the frame
913/// [`compute`] classifies in: `a = ã ⊙ d`, so the change of variables
914/// is divided out and the row's own `d_scale` is still in. The caller
915/// divides that `dg²` out to reach natural units.
916///
917/// One pass over the Jacobian triplets for the whole batch, with the
918/// mat-vec loop kept as the fallback for any future non-triplet
919/// matrix type — the same two paths, and the same duplicate-summing
920/// convention, [`compute`] uses.
921fn row_norm2(
922 jac_d: &Rc<dyn Matrix>,
923 d_var: Option<&[Number]>,
924 wanted: &[Option<usize>],
925 n: usize,
926 m_d: usize,
927 out_len: usize,
928) -> Vec<Number> {
929 let dv = |i: usize| -> Number { d_var.map_or(1.0, |d| d[i]) };
930 let mut norm2 = vec![0.0; out_len];
931 if let Some(jt) = jac_d.as_any().downcast_ref::<GenTMatrix>() {
932 let mut support: Vec<Vec<(usize, Number)>> = vec![Vec::new(); out_len];
933 for ((&r, &c), &v) in jt.irows().iter().zip(jt.jcols()).zip(jt.values()) {
934 let Some(slot) = wanted[(r - 1) as usize] else {
935 continue;
936 };
937 let col = (c - 1) as usize;
938 support[slot].push((col, v * dv(col)));
939 }
940 for (slot, sup) in support.iter_mut().enumerate() {
941 // triplet duplicates sum before the square, matching
942 // `mult_vector` and `compute`'s own gather
943 sup.sort_unstable_by_key(|&(c, _)| c);
944 sup.dedup_by(|a, b| {
945 if a.0 == b.0 {
946 b.1 += a.1;
947 true
948 } else {
949 false
950 }
951 });
952 norm2[slot] = sup.iter().map(|&(_, g)| g * g).sum();
953 }
954 return norm2;
955 }
956 let mspace = DenseVectorSpace::new(m_d as i32);
957 let mut e_row = DenseVector::new(mspace);
958 let nspace = DenseVectorSpace::new(n as i32);
959 let mut grad = DenseVector::new(nspace);
960 for (j, slot) in wanted.iter().enumerate() {
961 let Some(slot) = *slot else { continue };
962 // values_mut throughout: a zero product may leave the output
963 // homogeneous (empty backing slice)
964 e_row.values_mut().fill(0.0);
965 e_row.values_mut()[j] = 1.0;
966 grad.values_mut().fill(0.0);
967 jac_d.trans_mult_vector(1.0, &e_row, 0.0, &mut grad);
968 norm2[slot] = grad
969 .values_mut()
970 .iter()
971 .enumerate()
972 .map(|(i, g)| (*g * dv(i)) * (*g * dv(i)))
973 .sum();
974 }
975 norm2
976}
977
978/// [`compute`]'s per-row classification, re-normalized by the
979/// **reduced** curvature along each requested row's gradient instead
980/// of the directional curvature `∇dᵀH∇d/‖∇d‖²` — one back-solve
981/// against the held factor per row asked about (gh#804).
982///
983/// The row counterpart of [`reduced_activity`], and the same defect:
984/// gh#763 for rows.
985///
986/// # What is different
987///
988/// [`compute`] forms a row's ratio as `Σ_j‖∇d‖⁴/|∇dᵀH∇d|`. That
989/// denominator is a genuine curvature along the row's own gradient
990/// direction — strictly better than the variable path's bare `H_ii`,
991/// which is why gh#763 fixed the variables first — but it is not a
992/// *reduced* curvature: it does not account for the other free
993/// coordinates re-optimizing. The quantity that generates a row's
994/// multiplier is what is left after that elimination, exactly as for
995/// a variable, so a row's `r` is `reduced/directional` and equals `1`
996/// only where the row's direction is decoupled from the remaining
997/// free space. Couple it and a genuine row kink falls out of the
998/// `[1e-1, 1e1]` band and reads [`AMBIGUOUS`] — at any tolerance,
999/// because that ratio is `μ`-independent, so re-solving tighter
1000/// reports the same thing.
1001///
1002/// # How it is computed
1003///
1004/// The row's own value IS a coordinate of the KKT system: the slack
1005/// `s_j` the barrier acts on, tied to the model by `dⱼ(x) = s_j`. So
1006/// the reduced curvature the barrier subproblem sees along the row is
1007/// the reciprocal of that coordinate's diagonal entry of the inverse
1008/// — one back-solve against a unit right-hand side in the `s` block —
1009/// with the row's own barrier contribution taken back off:
1010///
1011/// ```text
1012/// q_j^raw = 1 / (K⁻¹)_{sⱼsⱼ} − Σ_j (per unit of dⱼ)
1013/// q_j^red = q_j^raw · ‖∇dⱼ‖² (per unit of x, reported)
1014/// ```
1015///
1016/// Driving the `x` block with the row's gradient `∇dⱼ` instead gives
1017/// the identical number — `∇dⱼᵀK⁻¹∇dⱼ = (K⁻¹)_{sⱼsⱼ}` for every `j`,
1018/// since `∇dⱼ` reaches the system only through the row it defines —
1019/// so the slack unit vector is used: it needs no gradient assembled
1020/// into the right-hand side, and it is the same call
1021/// [`reduced_activity`] makes one block over.
1022///
1023/// Everything the elimination weights is what makes this the
1024/// curvature the multiplier is generated against, exactly as in
1025/// [`reduced_activity`]: a strongly active neighbour carries
1026/// `Σ = O(1/μ)` and does not re-optimize, an inactive one carries
1027/// `O(μ)` and re-optimizes freely.
1028///
1029/// The `‖∇dⱼ‖²` puts the answer along the **unit** normal, which is
1030/// where [`compute`]'s `q` lives and what the shared identification
1031/// floor is scaled for. Paired with the geometric weight
1032/// `Σ_j‖∇dⱼ‖²` in the numerator — [`compute`]'s pairing — the ratio
1033/// is invariant to rescaling the row, so `d → c·d` does not change a
1034/// status here any more than it does there.
1035///
1036/// `K` is the natural-units KKT matrix, so the ratio is also
1037/// invariant to `nlp_scaling_method` and to a `user-scaling` change
1038/// of variables.
1039///
1040/// # Cost, and why this is not the default
1041///
1042/// One back-solve per row, for the same reason [`reduced_activity`]
1043/// costs one per variable: the correct normalizer is a diagonal entry
1044/// of an *inverse*, and there is no shortcut to it. So [`compute`]
1045/// keeps the `O(nnz)` directional curvature and this is the on-demand
1046/// refinement — the natural call is over the [`AMBIGUOUS`] rows of a
1047/// report. Pass them in ONE call: the back-solves batch, and the
1048/// per-call fixed cost (one pass over the Hessian for the shared
1049/// identification floor, one over the Jacobian for the norms) is paid
1050/// once rather than per row.
1051///
1052/// # Edge cases
1053///
1054/// * An equality row has no slack and no barrier multiplier pair:
1055/// status [`EQUALITY`], `NaN` curvature and ratio, as in the report.
1056/// * An inequality row with no finite bound gets its `q_j^red` but
1057/// status [`UNBOUNDED`] and a `NaN` ratio: no bound question to
1058/// answer.
1059/// * A row whose gradient vanishes at the iterate has no direction to
1060/// measure curvature along: [`UNIDENTIFIED`] with the raw
1061/// `Σ/floor` lower bound, exactly as [`compute`] reports it.
1062/// * A row the constraints determine outright has `(K⁻¹)_{sⱼsⱼ} = 0`:
1063/// no direction left to reduce along, so `q_j^red` is infinite and
1064/// the ratio is `0`, i.e. [`INACTIVE`] — whatever holds that row, it
1065/// is not its own bound.
1066/// * A `NaN` from the back-solve reports [`UNIDENTIFIED`] with a `NaN`
1067/// curvature and ratio.
1068/// * `|q_j^red|` below the same identification floor [`compute`] uses
1069/// reports [`UNIDENTIFIED`], as there.
1070/// * Under `obj_scaling_factor < 0` — the documented way to maximize —
1071/// `Σ` and `q_j^red` are both reported with the sign the
1072/// natural-units contract gives them, but the CLASSIFICATION runs on
1073/// the objective-scale-positive orientation [`compute`] classifies
1074/// in, so a status here means the same thing at either sign of `df`.
1075pub(crate) fn reduced_row_activity(
1076 bs: &PdSensBacksolver,
1077 user_rows: &[usize],
1078) -> Result<ReducedRowActivityReport, ReducedRowActivityError> {
1079 let (data, cq, nlp) = bs.activity_handles();
1080 let mu = bs.barrier_mu();
1081 let (n, m_d) = {
1082 let d = data.borrow();
1083 let curr = d.curr.as_ref().expect("converged state has an iterate");
1084 (curr.x.dim() as usize, curr.s.dim() as usize)
1085 };
1086
1087 // full-g in, d-block rows out, through the NLP's own c/d map:
1088 // reading the user index as an inequality position returns a
1089 // NEIGHBORING row's answer wherever an equality precedes it (the
1090 // gh#450 hazard, one block over). It is the same map
1091 // `Solver::d_multiplier_rows` addresses the `y_d` block with
1092 // (gh#910), deliberately rather than a second ascending scan that
1093 // agrees with it today: the gate that accepts a strictly active
1094 // inequality's `dλ/dp` classifies with THIS function and then
1095 // reads THAT row, so a disagreement between the two would classify
1096 // one row and answer about another.
1097 let n_full_g = bs.n_full_g() as usize;
1098 let d_index: Vec<Option<usize>> = {
1099 let nl = nlp.borrow();
1100 let map: Vec<Option<usize>> = (0..n_full_g)
1101 .map(|g| nl.full_g_to_d_block(g as Index).map(|p| p as usize))
1102 .collect();
1103 // the same invariant [`compute`] asserts after its own scan,
1104 // restated here because every `sigma_s` / `d_scale` index
1105 // below rests on it
1106 assert_eq!(
1107 map.iter().filter(|p| p.is_some()).count(),
1108 m_d,
1109 "inequality count disagrees with the c/d split"
1110 );
1111 map
1112 };
1113 let mut rows: Vec<Option<usize>> = Vec::with_capacity(user_rows.len());
1114 for &j in user_rows {
1115 if j >= n_full_g {
1116 return Err(ReducedRowActivityError::OutOfRange { got: j, n_full_g });
1117 }
1118 rows.push(d_index[j]);
1119 }
1120
1121 let (pd_l, pd_u, obj_scale, d_scale) = {
1122 let nl = nlp.borrow();
1123 (
1124 nl.pd_l(),
1125 nl.pd_u(),
1126 nl.obj_scaling_factor(),
1127 nl.d_scale_vec(),
1128 )
1129 };
1130 let rhas_l = present(&pd_l, m_d);
1131 let rhas_u = present(&pd_u, m_d);
1132 let sigma_s = dense_to_vec(bs.barrier_sigma_s().as_ref());
1133
1134 // Which internal rows the batch asks about, and where each one's
1135 // norm lands. A row requested twice shares one slot and one gather.
1136 let mut slot_of: Vec<Option<usize>> = vec![None; m_d];
1137 let mut n_slots = 0usize;
1138 for row in rows.iter().flatten() {
1139 if slot_of[*row].is_none() {
1140 slot_of[*row] = Some(n_slots);
1141 n_slots += 1;
1142 }
1143 }
1144
1145 // the NLP borrow above is dropped: the Cq getters re-borrow it
1146 // mutably for lazy evaluation
1147 let (floor, norm2) = {
1148 let cq = cq.borrow();
1149 let hess = cq.curr_exact_hessian();
1150 // only the shared identification floor is wanted from the
1151 // frame; it is the one number a per-entry ratio cannot supply
1152 let floor = var_frame(bs, &hess, n).floor;
1153 let jac_d = cq.curr_jac_d();
1154 let norm2 = row_norm2(&jac_d, bs.variable_scaling(), &slot_of, n, m_d, n_slots);
1155 (floor, norm2)
1156 };
1157 // the floor comes out of `var_frame` with the objective scale
1158 // still in; the reciprocal `(K⁻¹)_{ss}` below is natural units
1159 // already, so both sides of the comparison meet there.
1160 let floor = floor / obj_scale.abs();
1161
1162 // One unit RHS per requested row, in the `s` block, batched
1163 // against the held factor. Chunked so the buffers stay bounded
1164 // when a caller hands over a long list rather than the report's
1165 // ambiguous rows.
1166 const CHUNK: usize = 64;
1167 let dim = bs.dim();
1168 let s_offset = bs.block_dims()[0];
1169 let solve_rows: Vec<usize> = rows.iter().flatten().map(|&r| s_offset + r).collect();
1170 let mut kinv: Vec<Number> = Vec::with_capacity(solve_rows.len());
1171 for chunk in solve_rows.chunks(CHUNK) {
1172 let k = chunk.len();
1173 let mut rhs = vec![0.0; k * dim];
1174 let mut lhs = vec![0.0; k * dim];
1175 for (c, &r) in chunk.iter().enumerate() {
1176 rhs[c * dim + r] = 1.0;
1177 }
1178 if !bs.solve_many(&rhs, &mut lhs, k) {
1179 return Err(ReducedRowActivityError::Backsolve);
1180 }
1181 for (c, &r) in chunk.iter().enumerate() {
1182 kinv.push(lhs[c * dim + r]);
1183 }
1184 }
1185
1186 let mut out = ReducedRowActivityReport {
1187 mu,
1188 row: user_rows.to_vec(),
1189 status: Vec::with_capacity(user_rows.len()),
1190 ratio: Vec::with_capacity(user_rows.len()),
1191 q_reduced: Vec::with_capacity(user_rows.len()),
1192 q_sign: Vec::with_capacity(user_rows.len()),
1193 sigma: Vec::with_capacity(user_rows.len()),
1194 };
1195 // `compute` runs the rule on the df-in `Sigma` (internal `v/s`,
1196 // non-negative), dividing the objective scale out only on export.
1197 // Here every quantity is already natural, so a NEGATIVE df -- the
1198 // documented way to maximize -- would otherwise hand the rule a
1199 // negative ratio and read a pinned row as INACTIVE.
1200 let sgn = if obj_scale < 0.0 { -1.0 } else { 1.0 };
1201 let mut next = 0usize;
1202 for &row in &rows {
1203 let Some(row) = row else {
1204 out.status.push(EQUALITY);
1205 out.ratio.push(Number::NAN);
1206 out.q_reduced.push(Number::NAN);
1207 out.q_sign.push(0);
1208 out.sigma.push(0.0);
1209 continue;
1210 };
1211 let d = kinv[next];
1212 next += 1;
1213 // natural units, as `compute` exports them: the scaled row
1214 // multiplier carries df/dg and the scaled slack dg
1215 let dg = d_scale.as_ref().map_or(1.0, |v| v[row]);
1216 let sigma = sigma_s[row] * dg * dg / obj_scale;
1217 // `a = ã ⊙ d` leaves the row's own `dg` in, and the reported
1218 // curvature is natural, so it comes back out here
1219 let norm2 = norm2[slot_of[row].expect("every solved row has a norm slot")] / (dg * dg);
1220 if norm2 <= 0.0 {
1221 // no direction to measure curvature along, exactly as in
1222 // the report -- and the geometric weight is degenerate at
1223 // zero gradient, so the raw `Σ/floor` lower bound stands
1224 let e = zero_gradient_row(sigma * sgn, floor);
1225 out.status.push(e.status);
1226 out.ratio.push(e.ratio);
1227 out.q_reduced.push(Number::NAN);
1228 out.q_sign.push(0);
1229 out.sigma.push(sigma);
1230 continue;
1231 }
1232 if !d.is_finite() {
1233 out.status.push(UNIDENTIFIED);
1234 out.ratio.push(Number::NAN);
1235 out.q_reduced.push(Number::NAN);
1236 out.q_sign.push(0);
1237 out.sigma.push(sigma);
1238 continue;
1239 }
1240 // `(K⁻¹)_{ss} = 0` -- the rest of the model determines the
1241 // row's value outright, so there is no direction left to
1242 // reduce along -- sends `q` to an infinity the ratio divides
1243 // to zero, i.e. INACTIVE: whatever holds the row there, it is
1244 // not its own bound. A `(K⁻¹)_{ss}` at roundoff level lands in
1245 // the same class from either side of zero, which is why no
1246 // guard branches on its sign; a genuinely negative reduced
1247 // curvature is modest in magnitude and reports through
1248 // `q_sign`, exactly as an indefinite `∇dᵀH∇d` does in the
1249 // report.
1250 let q = (1.0 / d - sigma) * norm2;
1251 // the geometric weight against the curvature along the unit
1252 // normal: `compute`'s pairing, so a decoupled row's ratio here
1253 // IS the report's
1254 let e = classify_entry(sigma * norm2 * sgn, q * sgn, floor, mu);
1255 let bounded = rhas_l[row] || rhas_u[row];
1256 out.status.push(if bounded { e.status } else { UNBOUNDED });
1257 out.ratio.push(if bounded { e.ratio } else { Number::NAN });
1258 out.q_reduced.push(q);
1259 out.q_sign.push(sign_of(q));
1260 out.sigma.push(sigma);
1261 }
1262 debug_assert_eq!(next, kinv.len(), "every inequality row consumed its solve");
1263 Ok(out)
1264}
1265
1266/// The gradient of one user constraint row at the converged iterate,
1267/// in user variable order (length `n_full_x`) and **natural (unscaled)
1268/// units**: the internal Jacobian row carries the solver's per-row
1269/// scale, which is divided out here per the sensitivity-output
1270/// contract. Works for equality and inequality rows alike; entries for
1271/// `make_parameter`-removed fixed variables are 0 because the solve
1272/// dropped their columns.
1273pub(crate) fn row_normal(bs: &PdSensBacksolver, user_row: usize) -> Result<Vec<Number>, usize> {
1274 let (data, cq, nlp) = bs.activity_handles();
1275 let n = {
1276 let d = data.borrow();
1277 d.curr
1278 .as_ref()
1279 .expect("converged state has an iterate")
1280 .x
1281 .dim() as usize
1282 };
1283 // position of the row within its own c/d block, by the same
1284 // ascending scan the report's scatter uses
1285 let c_pos = {
1286 let nl = nlp.borrow();
1287 if user_row >= nl.n_full_g() as usize {
1288 return Err(nl.n_full_g() as usize);
1289 }
1290 nl.full_g_to_c_block(user_row as Index)
1291 };
1292 let block_pos = match c_pos {
1293 Some(p) => p as usize,
1294 None => {
1295 let nl = nlp.borrow();
1296 (0..user_row)
1297 .filter(|&g| nl.full_g_to_c_block(g as Index).is_none())
1298 .count()
1299 }
1300 };
1301
1302 let row_scale = {
1303 let nl = nlp.borrow();
1304 let sv = if c_pos.is_some() {
1305 nl.c_scale_vec()
1306 } else {
1307 nl.d_scale_vec()
1308 };
1309 sv.map_or(1.0, |v| v[block_pos])
1310 };
1311 let cq = cq.borrow();
1312 let jac = if c_pos.is_some() {
1313 cq.curr_jac_c()
1314 } else {
1315 cq.curr_jac_d()
1316 };
1317 let m_block = jac.n_rows() as usize;
1318 let mspace = DenseVectorSpace::new(m_block as i32);
1319 let mut e_row = DenseVector::new(mspace);
1320 let nspace = DenseVectorSpace::new(n as i32);
1321 let mut grad = DenseVector::new(nspace);
1322 e_row.values_mut().fill(0.0);
1323 e_row.values_mut()[block_pos] = 1.0;
1324 grad.values_mut().fill(0.0);
1325 jac.trans_mult_vector(1.0, &e_row, 0.0, &mut grad);
1326
1327 let d_var = bs.variable_scaling();
1328 let nl = nlp.borrow();
1329 let n_full_x = nl.n_full_x() as usize;
1330 let mut full = vec![0.0; n_full_x];
1331 let g = grad.values_mut();
1332 for (i, slot) in g.iter().enumerate() {
1333 // `∇g̃ = (∇g ⊘ d) · row_scale`, so both come back out here
1334 // (gh#486 stage 3).
1335 let dx = d_var.map_or(1.0, |d| d[i]);
1336 full[nl.var_x_to_full_x(i as Index) as usize] = *slot * dx / row_scale;
1337 }
1338 Ok(full)
1339}
1340
1341/// The exact Lagrangian Hessian times a user-space vector, in user
1342/// variable order and **natural (unscaled) units**: the internal
1343/// Hessian carries the objective scale, divided out here per the
1344/// sensitivity-output contract. Entries for `make_parameter`-removed
1345/// fixed variables are 0 in and out (their columns left the solve).
1346/// Serves the covariance roadmap's item 2: the tangent-recovered
1347/// reduced Hessian is `T^T (H T)`, one product per fitted column.
1348pub(crate) fn hessian_vec(bs: &PdSensBacksolver, v_full: &[Number]) -> Result<Vec<Number>, usize> {
1349 let (data, cq, nlp) = bs.activity_handles();
1350 let n = {
1351 let d = data.borrow();
1352 d.curr
1353 .as_ref()
1354 .expect("converged state has an iterate")
1355 .x
1356 .dim() as usize
1357 };
1358 let (n_full_x, obj_scale) = {
1359 let nl = nlp.borrow();
1360 (nl.n_full_x() as usize, nl.obj_scaling_factor())
1361 };
1362 if v_full.len() != n_full_x {
1363 return Err(n_full_x);
1364 }
1365
1366 // `H = H̃ ⊙ (d ⊗ d)` under a change of variables (gh#486 stage 3),
1367 // so `H v = d ⊙ (H̃ (d ⊙ v))`: the factor goes in with the vector
1368 // and comes back out of the product.
1369 let d_var = bs.variable_scaling();
1370 let nspace = DenseVectorSpace::new(n as i32);
1371 let mut v_int = DenseVector::new(nspace.clone());
1372 let mut hv = DenseVector::new(nspace);
1373 {
1374 let nl = nlp.borrow();
1375 let vals = v_int.values_mut();
1376 vals.fill(0.0);
1377 for i in 0..n {
1378 let dx = d_var.map_or(1.0, |d| d[i]);
1379 vals[i] = v_full[nl.var_x_to_full_x(i as Index) as usize] * dx;
1380 }
1381 }
1382 let hess = {
1383 let cq = cq.borrow();
1384 cq.curr_exact_hessian()
1385 };
1386 hess.mult_vector(1.0, &v_int, 0.0, &mut hv);
1387
1388 let nl = nlp.borrow();
1389 let mut out = vec![0.0; n_full_x];
1390 let h = hv.values_mut();
1391 for (i, slot) in h.iter().enumerate() {
1392 let dx = d_var.map_or(1.0, |d| d[i]);
1393 out[nl.var_x_to_full_x(i as Index) as usize] = *slot * dx / obj_scale;
1394 }
1395 Ok(out)
1396}