Skip to main content

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}