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//! `r` is `O(μ)` when the bound is inactive, `O(1)` when weakly active
34//! (slack and multiplier vanish together), and `O(1/μ)` when strongly
35//! active, so one ratio separates the regimes at any `μ` where a fixed
36//! threshold on the slack or the multiplier alone cannot: both are
37//! `O(√μ)` at weak activity, so any constant tracks the solve rather
38//! than the geometry.
39//!
40//! Everything read here is retained by the converged state the
41//! backsolver already holds: the bound multipliers on the iterate, the
42//! solver's own slacks, `Σ` as `curr_sigma_x` / `curr_sigma_s`, the
43//! barrier parameter, and the exact Lagrangian Hessian, so `H` is
44//! never recovered from the barrier-augmented factor.
45//!
46//! The report is indexed in **user space**: `var_*` arrays have the
47//! user TNLP's full variable count and `row_*` arrays its full
48//! constraint count. A variable removed internally by
49//! `fixed_variable_treatment = make_parameter` (`lb == ub`, the
50//! default) reports [`FIXED`] at its own user index, and an equality
51//! constraint reports [`EQUALITY`], so user indices never shift.
52
53use std::rc::Rc;
54
55use pounce_common::types::{Index, Number};
56use pounce_linalg::Matrix;
57use pounce_linalg::dense_vector::{DenseVector, DenseVectorSpace};
58use pounce_linalg::expansion_matrix::ExpansionMatrix;
59use pounce_linalg::triplet::{GenTMatrix, SymTMatrix};
60
61use crate::PdSensBacksolver;
62use crate::vec_util::dense_to_vec;
63
64/// No finite bound on this variable or row: nothing to classify.
65pub const UNBOUNDED: i8 = -1;
66/// `r = O(μ)`: the bound is not doing anything.
67pub const INACTIVE: i8 = 0;
68/// `r = O(1)`: slack and multiplier vanish together; kept, flagged.
69pub const WEAKLY_ACTIVE: i8 = 1;
70/// `r = O(1/μ)`: the bound holds the variable; projected out.
71pub const STRONGLY_ACTIVE: i8 = 2;
72/// `r` in a gap between the band and a `μ`-edge: undetermined at this
73/// `μ`; re-solving tighter separates it.
74pub const AMBIGUOUS: i8 = 3;
75/// The curvature `q` is below noise scale: the bound question does not
76/// arise, and the direction is poorly identified.
77pub const UNIDENTIFIED: i8 = 4;
78/// `lb == ub`: the variable was removed from the solve as a parameter
79/// (`fixed_variable_treatment = make_parameter`), so there is no
80/// barrier geometry to classify.
81pub const FIXED: i8 = 5;
82/// An equality constraint: always active by construction, with no
83/// slack or multiplier pair on the barrier, so outside this
84/// classification.
85pub const EQUALITY: i8 = 6;
86
87/// Per-variable and per-row classification of a converged solve.
88///
89/// All vectors are **user-space**: `var_*` have length `n_full_x` (the
90/// user TNLP's `n`) and `row_*` length `n_full_g` (the user's `m`).
91/// Entries with no finite bound hold [`UNBOUNDED`]; [`FIXED`]
92/// variables and [`EQUALITY`] rows are placeholders for entries the
93/// barrier never classified. All three carry `NaN` ratios.
94pub struct ActivityReport {
95    /// Barrier parameter of the converged iterate.
96    pub mu: Number,
97    /// Status per user variable (codes above).
98    pub var_status: Vec<i8>,
99    /// `Σ_i / q_i` per user variable; `NaN` where not classified.
100    /// For an [`UNIDENTIFIED`] entry the value is `Σ/floor`, a lower
101    /// bound on any honest ratio rather than the ratio itself, since
102    /// `q` is below the identification floor there.
103    pub var_ratio: Vec<Number>,
104    /// Sign of the signed curvature `H_ii` (−1, 0, +1); the absolute
105    /// value goes into `q`, so an indefinite direction is reported
106    /// rather than hidden.
107    pub var_q_sign: Vec<i8>,
108    /// `s·z` differs from `μ` by more than a factor of ten on some
109    /// side: off the central path, or the bound was relaxed.
110    pub var_off_central_path: Vec<bool>,
111    /// Classified inactive yet `r` non-negligible: barrier curvature
112    /// where none should be.
113    pub var_contaminated: Vec<bool>,
114    /// The barrier diagonal `Σ_i = z/s` itself per user variable, both
115    /// sides summed; 0 where not classified. In **natural (unscaled)
116    /// units**, the repo's sensitivity-output contract: classification
117    /// runs on the solver's scaled quantities (the ratio is
118    /// scale-invariant), the report does not. The covariance roadmap's
119    /// item 1 subtracts exactly this from the factor's natural-units
120    /// reduced Hessian.
121    pub var_sigma: Vec<Number>,
122    /// Status per user constraint row.
123    pub row_status: Vec<i8>,
124    /// `Σ_j / q_j` per user row; `NaN` where not classified.
125    /// [`UNIDENTIFIED`] entries hold `Σ/floor` as for variables.
126    pub row_ratio: Vec<Number>,
127    /// Sign of the signed row curvature `∇dⱼᵀ H ∇dⱼ`.
128    pub row_q_sign: Vec<i8>,
129    /// Central-path check per row, as for variables.
130    pub row_off_central_path: Vec<bool>,
131    /// Contamination check per row, as for variables.
132    pub row_contaminated: Vec<bool>,
133    /// The row barrier diagonal `Σ_j = v/s` per user row, both sides
134    /// summed; 0 where not classified. In **natural (unscaled) units**
135    /// like [`Self::var_sigma`], and RAW rather than the geometric
136    /// weight the classification uses: item 1 restricts the normal to
137    /// its own fitted block and applies its own `‖a‖²` factor there.
138    pub row_sigma: Vec<Number>,
139}
140
141/// The classification rule of the roadmap's item 0.
142fn classify(r: Number, mu: Number) -> i8 {
143    if mu > 1e-4 {
144        // The band is fixed at [1e-1, 1e1] while the μ-edges √μ and
145        // 1/√μ move with the solve: they meet the band at μ = 1e-2,
146        // and a full decade separates them from it at μ = 1e-4. Above
147        // 1e-4 that margin is what's thinning, so only the two calls
148        // that stay clear are made and the middle is honest refusal.
149        if r < 1e-1 {
150            INACTIVE
151        } else if r > 1e1 {
152            STRONGLY_ACTIVE
153        } else {
154            AMBIGUOUS
155        }
156    } else if r < mu.sqrt() {
157        INACTIVE
158    } else if r > 1.0 / mu.sqrt() {
159        STRONGLY_ACTIVE
160    } else if (1e-1..=1e1).contains(&r) {
161        WEAKLY_ACTIVE
162    } else {
163        AMBIGUOUS
164    }
165}
166
167fn sign_of(x: Number) -> i8 {
168    if x > 0.0 {
169        1
170    } else if x < 0.0 {
171        -1
172    } else {
173        0
174    }
175}
176
177/// Scatter a compressed (bounded-entries-only) vector to full length
178/// through its expansion matrix. Entries without that bound stay 0.
179fn expand(compressed: &[Number], px: &Rc<dyn Matrix>, n: usize) -> Vec<Number> {
180    let em = px
181        .as_any()
182        .downcast_ref::<ExpansionMatrix>()
183        .expect("bound projection is an ExpansionMatrix (orig_ipopt_nlp builds no other kind)");
184    let idx = em.expanded_pos_indices();
185    assert_eq!(
186        idx.len(),
187        compressed.len(),
188        "compressed bound vector length disagrees with its expansion",
189    );
190    let mut full = vec![0.0; n];
191    for (k, &pos) in idx.iter().enumerate() {
192        full[pos as usize] = compressed[k];
193    }
194    full
195}
196
197/// Presence mask for a bound side, from the same expansion.
198fn present(px: &Rc<dyn Matrix>, n: usize) -> Vec<bool> {
199    let em = px
200        .as_any()
201        .downcast_ref::<ExpansionMatrix>()
202        .expect("bound projection is an ExpansionMatrix (orig_ipopt_nlp builds no other kind)");
203    let mut mask = vec![false; n];
204    for &pos in em.expanded_pos_indices() {
205        mask[pos as usize] = true;
206    }
207    mask
208}
209
210/// The exact Hessian diagonal: one pass over the triplet structure for
211/// the type `eval_h` builds today. The mat-vec fallback keeps any
212/// future non-triplet `SymMatrix` correct, at O(n·nnz) cost.
213fn hessian_diagonal(hess: &Rc<dyn pounce_linalg::SymMatrix>, n: usize) -> Vec<Number> {
214    let mut diag = vec![0.0; n];
215    if let Some(t) = hess.as_any().downcast_ref::<SymTMatrix>() {
216        // triplet indices are 1-based (the GenTMatrix convention);
217        // duplicates accumulate, matching mult_vector
218        for ((&i, &j), &v) in t.irows().iter().zip(t.jcols()).zip(t.values()) {
219            if i == j {
220                diag[(i - 1) as usize] += v;
221            }
222        }
223        return diag;
224    }
225    let space = DenseVectorSpace::new(n as i32);
226    let mut e = DenseVector::new(space.clone());
227    let mut he = DenseVector::new(space);
228    for (i, d) in diag.iter_mut().enumerate() {
229        e.values_mut().fill(0.0);
230        e.values_mut()[i] = 1.0;
231        he.values_mut().fill(0.0);
232        hess.mult_vector(1.0, &e, 0.0, &mut he);
233        // values_mut, not values: a zero product may have left the
234        // output homogeneous (empty backing slice); this materializes
235        *d = he.values_mut()[i];
236    }
237    diag
238}
239
240/// A bounded row whose gradient vanishes at the iterate has no
241/// direction to measure curvature along: unidentified, exactly as a
242/// below-floor `q`, never `unbounded` (the bounds are real). The
243/// ratio is the raw `Σ/floor` lower bound; the geometric weight is
244/// degenerate at zero gradient.
245fn zero_gradient_row(sigma: Number, floor: Number) -> Entry {
246    Entry {
247        status: UNIDENTIFIED,
248        ratio: sigma / floor,
249        q_sign: 0,
250        off_path: false,
251        contaminated: false,
252        sigma,
253    }
254}
255
256/// Central-path check for one side: `s·z` within a factor of ten of `μ`.
257fn off_path(s: Number, z: Number, mu: Number) -> bool {
258    let comp = s * z;
259    comp > 10.0 * mu || comp < 0.1 * mu
260}
261
262/// Classified inactive yet `r` well above the `O(μ)` an inactive
263/// bound should carry: barrier curvature where none should be. The
264/// threshold is μ-relative because `inactive` MEANS `r = O(μ)`; a
265/// fixed constant can never sit below the inactive edge `√μ` at any
266/// converged μ (second review).
267fn contaminated(status: i8, r: Number, mu: Number) -> bool {
268    status == INACTIVE && r > 100.0 * mu
269}
270
271/// One classified entry in internal space, before the user-space
272/// scatter.
273#[derive(Clone, Copy)]
274struct Entry {
275    status: i8,
276    ratio: Number,
277    q_sign: i8,
278    off_path: bool,
279    contaminated: bool,
280    /// The RAW barrier diagonal, whatever weight classification used.
281    sigma: Number,
282}
283
284const NOT_CLASSIFIED: Entry = Entry {
285    status: UNBOUNDED,
286    ratio: Number::NAN,
287    q_sign: 0,
288    off_path: false,
289    contaminated: false,
290    sigma: 0.0,
291};
292
293/// Classify one bounded variable or row from its `Σ` and signed `q`.
294/// `off_path` is the caller's to fill: it reads the per-side slack and
295/// multiplier, not the ratio.
296fn classify_entry(sigma: Number, q_signed: Number, floor: Number, mu: Number) -> Entry {
297    let q_sign = sign_of(q_signed);
298    let q = q_signed.abs();
299    if q < floor {
300        return Entry {
301            status: UNIDENTIFIED,
302            ratio: sigma / floor,
303            q_sign,
304            off_path: false,
305            contaminated: false,
306            sigma,
307        };
308    }
309    let r = sigma / q;
310    let status = classify(r, mu);
311    Entry {
312        status,
313        ratio: r,
314        q_sign,
315        off_path: false,
316        contaminated: contaminated(status, r, mu),
317        sigma,
318    }
319}
320
321pub(crate) fn compute(bs: &PdSensBacksolver) -> ActivityReport {
322    let (data, cq, nlp) = bs.activity_handles();
323
324    // scoped borrows: the Cq getters below re-borrow the NLP (mutably,
325    // for lazy evaluation) and the data, so nothing here may hold
326    // either across a Cq call
327    let (mu, mult_z_l, mult_z_u, mult_v_l, mult_v_u, n, m_d) = {
328        let d = data.borrow();
329        let curr = d.curr.as_ref().expect("converged state has an iterate");
330        (
331            d.curr_mu,
332            Rc::clone(&curr.z_l),
333            Rc::clone(&curr.z_u),
334            Rc::clone(&curr.v_l),
335            Rc::clone(&curr.v_u),
336            curr.x.dim() as usize,
337            curr.s.dim() as usize,
338        )
339    };
340    let (px_l, px_u, pd_l, pd_u, obj_scale, d_scale) = {
341        let nl = nlp.borrow();
342        (
343            nl.px_l(),
344            nl.px_u(),
345            nl.pd_l(),
346            nl.pd_u(),
347            nl.obj_scaling_factor(),
348            nl.d_scale_vec(),
349        )
350    };
351    let cq = cq.borrow();
352
353    // Per-variable factors of a `user-scaling` change of variables
354    // (gh#486 stage 3), in var-x space; 1.0 everywhere when none ran.
355    // Every internal x-space quantity below is a `d`-transform of the
356    // model's own — writing `a_j` for the gradient of inequality row
357    // `j`, since `d` is spoken for here: `ã = a ⊘ d`,
358    // `H̃ = H ⊘ (d ⊗ d)`, `Σ̃ = Σ · df ⊘ (d ⊙ d)`. Undoing that here rather than only on
359    // the exported `Σ` is what keeps a status from depending on the
360    // conditioning the user asked for: the per-entry ratio `Σ/q` is
361    // invariant, but the identification `floor` is a single number
362    // shared across entries, so a non-uniform `d` moves entries across
363    // it. `1.0` multiplies are exact, so an unscaled solve is
364    // bit-identical to the pre-#486 path.
365    let d_var = bs.variable_scaling();
366    let dv = |i: usize| -> Number { d_var.map_or(1.0, |d| d[i]) };
367
368    // --- variables, in internal space ------------------------------------
369    let has_l = present(&px_l, n);
370    let has_u = present(&px_u, n);
371    let z_l = expand(&dense_to_vec(mult_z_l.as_ref()), &px_l, n);
372    let z_u = expand(&dense_to_vec(mult_z_u.as_ref()), &px_u, n);
373    let s_l = expand(&dense_to_vec(cq.curr_slack_x_l().as_ref()), &px_l, n);
374    let s_u = expand(&dense_to_vec(cq.curr_slack_x_u().as_ref()), &px_u, n);
375    // `Σ̃_i = df·Σ_i/d_i²`: the `d_i²` comes out here, the `df` on
376    // export below (it cancels in every ratio, so classification never
377    // sees it).
378    let sigma_x: Vec<Number> = dense_to_vec(cq.curr_sigma_x().as_ref())
379        .iter()
380        .enumerate()
381        .map(|(i, &s)| s * dv(i) * dv(i))
382        .collect();
383
384    let hess = cq.curr_exact_hessian();
385    // the identification floor is relative to the largest curvature
386    // anywhere on the diagonal, not just the bounded entries, so a
387    // row-only model still measures q against the model's own scale
388    let diag: Vec<Number> = hessian_diagonal(&hess, n)
389        .iter()
390        .enumerate()
391        .map(|(i, &h)| h * dv(i) * dv(i))
392        .collect();
393    let max_abs_diag = diag.iter().fold(0.0, |a: Number, d| a.max(d.abs()));
394    let floor = Number::EPSILON.sqrt() * max_abs_diag.max(1.0);
395
396    let mut vars = vec![NOT_CLASSIFIED; n];
397    for i in 0..n {
398        if !(has_l[i] || has_u[i]) {
399            continue;
400        }
401        let mut e = classify_entry(sigma_x[i], diag[i], floor, mu);
402        e.off_path = (has_l[i] && off_path(s_l[i], z_l[i], mu))
403            || (has_u[i] && off_path(s_u[i], z_u[i], mu));
404        // the ratio is scale-invariant, so classification ran in the
405        // solver's own space up to the change of variables already
406        // divided out of `sigma_x` / `diag` above; the REPORTED sigma
407        // follows the repo's natural-units contract, and what is left
408        // to undo is the objective scale the internal z carries
409        e.sigma /= obj_scale;
410        vars[i] = e;
411    }
412
413    // --- inequality rows, in internal space -------------------------------
414    let rhas_l = present(&pd_l, m_d);
415    let rhas_u = present(&pd_u, m_d);
416    let v_l = expand(&dense_to_vec(mult_v_l.as_ref()), &pd_l, m_d);
417    let v_u = expand(&dense_to_vec(mult_v_u.as_ref()), &pd_u, m_d);
418    let rs_l = expand(&dense_to_vec(cq.curr_slack_s_l().as_ref()), &pd_l, m_d);
419    let rs_u = expand(&dense_to_vec(cq.curr_slack_s_u().as_ref()), &pd_u, m_d);
420    let sigma_s = dense_to_vec(cq.curr_sigma_s().as_ref());
421
422    let jac_d = cq.curr_jac_d();
423    // One pass over the Jacobian triplets gathers every row's support
424    // and one pass over the Hessian triplets builds an adjacency view,
425    // so each row's curvature costs its own support times its
426    // neighbours instead of a full mat-vec pair per row (second
427    // review). The mat-vec loop below remains the fallback for any
428    // future non-triplet matrix types.
429    let mut rows = vec![NOT_CLASSIFIED; m_d];
430    let fast = match (
431        jac_d.as_any().downcast_ref::<GenTMatrix>(),
432        hess.as_any().downcast_ref::<SymTMatrix>(),
433    ) {
434        (Some(jt), Some(ht)) => {
435            // gather and merge each row's entries (triplet duplicates
436            // sum, matching mult_vector; indices are 1-based)
437            let mut support: Vec<Vec<(usize, Number)>> = vec![Vec::new(); m_d];
438            for ((&r, &c), &v) in jt.irows().iter().zip(jt.jcols()).zip(jt.values()) {
439                let col = (c - 1) as usize;
440                // `a = ã ⊙ d`: the row's own scale stays (the ratio
441                // divides it out), the change of variables does not.
442                support[(r - 1) as usize].push((col, v * dv(col)));
443            }
444            for sup in &mut support {
445                sup.sort_unstable_by_key(|&(c, _)| c);
446                sup.dedup_by(|a, b| {
447                    if a.0 == b.0 {
448                        b.1 += a.1;
449                        true
450                    } else {
451                        false
452                    }
453                });
454            }
455            let mut adj: Vec<Vec<(usize, Number)>> = vec![Vec::new(); n];
456            for ((&i, &l), &v) in ht.irows().iter().zip(ht.jcols()).zip(ht.values()) {
457                let (a, b) = ((i - 1) as usize, (l - 1) as usize);
458                // `H = H̃ ⊙ (d ⊗ d)`, matching the `d²` already taken
459                // out of `diag` (which sets the shared floor).
460                let v = v * dv(a) * dv(b);
461                adj[a].push((b, v));
462                if a != b {
463                    adj[b].push((a, v));
464                }
465            }
466            let mut scratch = vec![0.0; n];
467            for j in 0..m_d {
468                if !(rhas_l[j] || rhas_u[j]) {
469                    continue;
470                }
471                let sup = &support[j];
472                let norm2: Number = sup.iter().map(|&(_, g)| g * g).sum();
473                rows[j] = if norm2 <= 0.0 {
474                    zero_gradient_row(sigma_s[j], floor)
475                } else {
476                    for &(k, g) in sup {
477                        scratch[k] = g;
478                    }
479                    let mut ghg = 0.0;
480                    for &(k, gk) in sup {
481                        let mut acc = 0.0;
482                        for &(l, v) in &adj[k] {
483                            acc += v * scratch[l];
484                        }
485                        ghg += gk * acc;
486                    }
487                    for &(k, _) in sup {
488                        scratch[k] = 0.0;
489                    }
490                    // Σ·‖∇d‖² against curvature along the unit
491                    // normal: invariant to rescaling the row; the
492                    // report keeps the raw Σ
493                    let mut e = classify_entry(sigma_s[j] * norm2, ghg / norm2, floor, mu);
494                    e.sigma = sigma_s[j];
495                    e
496                };
497            }
498            true
499        }
500        _ => false,
501    };
502    if !fast {
503        let mspace = DenseVectorSpace::new(m_d as i32);
504        let mut e_row = DenseVector::new(mspace);
505        let nspace = DenseVectorSpace::new(n as i32);
506        let mut grad = DenseVector::new(nspace.clone());
507        let mut hgrad = DenseVector::new(nspace);
508        for j in 0..m_d {
509            if !(rhas_l[j] || rhas_u[j]) {
510                continue;
511            }
512            // ∇dⱼ = Jdᵀ eⱼ, then the curvature along the normal;
513            // values_mut throughout because a zero product may leave
514            // the output homogeneous (empty backing slice)
515            e_row.values_mut().fill(0.0);
516            e_row.values_mut()[j] = 1.0;
517            grad.values_mut().fill(0.0);
518            jac_d.trans_mult_vector(1.0, &e_row, 0.0, &mut grad);
519            // `a = ã ⊙ d`, then `aᵀHa = uᵀH̃u` with `u = d ⊙ a`
520            // (since `H = H̃ ⊙ (d ⊗ d)`) — so the vector handed to the
521            // internal Hessian carries `d²`, and `norm2` carries `d`.
522            let norm2: Number = grad
523                .values_mut()
524                .iter()
525                .enumerate()
526                .map(|(i, g)| (*g * dv(i)) * (*g * dv(i)))
527                .sum();
528            rows[j] = if norm2 <= 0.0 {
529                zero_gradient_row(sigma_s[j], floor)
530            } else {
531                for (i, g) in grad.values_mut().iter_mut().enumerate() {
532                    *g *= dv(i) * dv(i);
533                }
534                hgrad.values_mut().fill(0.0);
535                hess.mult_vector(1.0, &grad, 0.0, &mut hgrad);
536                let ghg: Number = {
537                    let h = hgrad.values_mut();
538                    grad.values_mut()
539                        .iter()
540                        .zip(h.iter())
541                        .map(|(g, h)| g * h)
542                        .sum()
543                };
544                let mut e = classify_entry(sigma_s[j] * norm2, ghg / norm2, floor, mu);
545                e.sigma = sigma_s[j];
546                e
547            };
548        }
549    }
550    for j in 0..m_d {
551        if !(rhas_l[j] || rhas_u[j]) {
552            continue;
553        }
554        rows[j].off_path = (rhas_l[j] && off_path(rs_l[j], v_l[j], mu))
555            || (rhas_u[j] && off_path(rs_u[j], v_u[j], mu));
556        // natural-units report, as for variables: the scaled row
557        // multiplier carries df/dg and the scaled slack dg, so
558        // Sigma_nat = Sigma * dg^2 / df
559        let dg = d_scale.as_ref().map_or(1.0, |v| v[j]);
560        rows[j].sigma *= dg * dg / obj_scale;
561    }
562
563    // --- scatter to user space --------------------------------------------
564    // all Cq evaluation is done, so borrowing the NLP again is safe
565    let nl = nlp.borrow();
566    let n_full_x = nl.n_full_x() as usize;
567    let n_full_g = nl.n_full_g() as usize;
568
569    let fixed_entry = Entry {
570        status: FIXED,
571        ..NOT_CLASSIFIED
572    };
573    let mut var_full = vec![fixed_entry; n_full_x];
574    for (i, e) in vars.iter().enumerate() {
575        var_full[nl.var_x_to_full_x(i as Index) as usize] = *e;
576    }
577
578    let equality_entry = Entry {
579        status: EQUALITY,
580        ..NOT_CLASSIFIED
581    };
582    let mut row_full = vec![equality_entry; n_full_g];
583    // BoundClassification's d_map is one ascending scan over the
584    // user's g, so the j-th full-g index outside the c-block is
585    // internal inequality row j
586    let mut d_pos = 0usize;
587    for (full_idx, slot) in row_full.iter_mut().enumerate() {
588        if nl.full_g_to_c_block(full_idx as Index).is_none() {
589            *slot = rows[d_pos];
590            d_pos += 1;
591        }
592    }
593    assert_eq!(d_pos, m_d, "inequality count disagrees with the c/d split");
594
595    ActivityReport {
596        mu,
597        var_status: var_full.iter().map(|e| e.status).collect(),
598        var_ratio: var_full.iter().map(|e| e.ratio).collect(),
599        var_q_sign: var_full.iter().map(|e| e.q_sign).collect(),
600        var_off_central_path: var_full.iter().map(|e| e.off_path).collect(),
601        var_contaminated: var_full.iter().map(|e| e.contaminated).collect(),
602        var_sigma: var_full.iter().map(|e| e.sigma).collect(),
603        row_status: row_full.iter().map(|e| e.status).collect(),
604        row_ratio: row_full.iter().map(|e| e.ratio).collect(),
605        row_q_sign: row_full.iter().map(|e| e.q_sign).collect(),
606        row_off_central_path: row_full.iter().map(|e| e.off_path).collect(),
607        row_contaminated: row_full.iter().map(|e| e.contaminated).collect(),
608        row_sigma: row_full.iter().map(|e| e.sigma).collect(),
609    }
610}
611
612/// The gradient of one user constraint row at the converged iterate,
613/// in user variable order (length `n_full_x`) and **natural (unscaled)
614/// units**: the internal Jacobian row carries the solver's per-row
615/// scale, which is divided out here per the sensitivity-output
616/// contract. Works for equality and inequality rows alike; entries for
617/// `make_parameter`-removed fixed variables are 0 because the solve
618/// dropped their columns.
619pub(crate) fn row_normal(bs: &PdSensBacksolver, user_row: usize) -> Result<Vec<Number>, usize> {
620    let (data, cq, nlp) = bs.activity_handles();
621    let n = {
622        let d = data.borrow();
623        d.curr
624            .as_ref()
625            .expect("converged state has an iterate")
626            .x
627            .dim() as usize
628    };
629    // position of the row within its own c/d block, by the same
630    // ascending scan the report's scatter uses
631    let c_pos = {
632        let nl = nlp.borrow();
633        if user_row >= nl.n_full_g() as usize {
634            return Err(nl.n_full_g() as usize);
635        }
636        nl.full_g_to_c_block(user_row as Index)
637    };
638    let block_pos = match c_pos {
639        Some(p) => p as usize,
640        None => {
641            let nl = nlp.borrow();
642            (0..user_row)
643                .filter(|&g| nl.full_g_to_c_block(g as Index).is_none())
644                .count()
645        }
646    };
647
648    let row_scale = {
649        let nl = nlp.borrow();
650        let sv = if c_pos.is_some() {
651            nl.c_scale_vec()
652        } else {
653            nl.d_scale_vec()
654        };
655        sv.map_or(1.0, |v| v[block_pos])
656    };
657    let cq = cq.borrow();
658    let jac = if c_pos.is_some() {
659        cq.curr_jac_c()
660    } else {
661        cq.curr_jac_d()
662    };
663    let m_block = jac.n_rows() as usize;
664    let mspace = DenseVectorSpace::new(m_block as i32);
665    let mut e_row = DenseVector::new(mspace);
666    let nspace = DenseVectorSpace::new(n as i32);
667    let mut grad = DenseVector::new(nspace);
668    e_row.values_mut().fill(0.0);
669    e_row.values_mut()[block_pos] = 1.0;
670    grad.values_mut().fill(0.0);
671    jac.trans_mult_vector(1.0, &e_row, 0.0, &mut grad);
672
673    let d_var = bs.variable_scaling();
674    let nl = nlp.borrow();
675    let n_full_x = nl.n_full_x() as usize;
676    let mut full = vec![0.0; n_full_x];
677    let g = grad.values_mut();
678    for (i, slot) in g.iter().enumerate() {
679        // `∇g̃ = (∇g ⊘ d) · row_scale`, so both come back out here
680        // (gh#486 stage 3).
681        let dx = d_var.map_or(1.0, |d| d[i]);
682        full[nl.var_x_to_full_x(i as Index) as usize] = *slot * dx / row_scale;
683    }
684    Ok(full)
685}
686
687/// The exact Lagrangian Hessian times a user-space vector, in user
688/// variable order and **natural (unscaled) units**: the internal
689/// Hessian carries the objective scale, divided out here per the
690/// sensitivity-output contract. Entries for `make_parameter`-removed
691/// fixed variables are 0 in and out (their columns left the solve).
692/// Serves the covariance roadmap's item 2: the tangent-recovered
693/// reduced Hessian is `T^T (H T)`, one product per fitted column.
694pub(crate) fn hessian_vec(bs: &PdSensBacksolver, v_full: &[Number]) -> Result<Vec<Number>, usize> {
695    let (data, cq, nlp) = bs.activity_handles();
696    let n = {
697        let d = data.borrow();
698        d.curr
699            .as_ref()
700            .expect("converged state has an iterate")
701            .x
702            .dim() as usize
703    };
704    let (n_full_x, obj_scale) = {
705        let nl = nlp.borrow();
706        (nl.n_full_x() as usize, nl.obj_scaling_factor())
707    };
708    if v_full.len() != n_full_x {
709        return Err(n_full_x);
710    }
711
712    // `H = H̃ ⊙ (d ⊗ d)` under a change of variables (gh#486 stage 3),
713    // so `H v = d ⊙ (H̃ (d ⊙ v))`: the factor goes in with the vector
714    // and comes back out of the product.
715    let d_var = bs.variable_scaling();
716    let nspace = DenseVectorSpace::new(n as i32);
717    let mut v_int = DenseVector::new(nspace.clone());
718    let mut hv = DenseVector::new(nspace);
719    {
720        let nl = nlp.borrow();
721        let vals = v_int.values_mut();
722        vals.fill(0.0);
723        for i in 0..n {
724            let dx = d_var.map_or(1.0, |d| d[i]);
725            vals[i] = v_full[nl.var_x_to_full_x(i as Index) as usize] * dx;
726        }
727    }
728    let hess = {
729        let cq = cq.borrow();
730        cq.curr_exact_hessian()
731    };
732    hess.mult_vector(1.0, &v_int, 0.0, &mut hv);
733
734    let nl = nlp.borrow();
735    let mut out = vec![0.0; n_full_x];
736    let h = hv.values_mut();
737    for (i, slot) in h.iter().enumerate() {
738        let dx = d_var.map_or(1.0, |d| d[i]);
739        out[nl.var_x_to_full_x(i as Index) as usize] = *slot * dx / obj_scale;
740    }
741    Ok(out)
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    #[test]
749    fn tight_mu_walks_all_five_regions() {
750        let mu = 1e-10; // edges at 1e-5 and 1e5
751        assert_eq!(classify(0.9e-5, mu), INACTIVE);
752        assert_eq!(classify(1.1e-5, mu), AMBIGUOUS); // gap: edge..band
753        assert_eq!(classify(0.5, mu), WEAKLY_ACTIVE);
754        assert_eq!(classify(50.0, mu), AMBIGUOUS); // gap: band..edge
755        assert_eq!(classify(2e5, mu), STRONGLY_ACTIVE);
756    }
757
758    #[test]
759    fn band_edges_are_inclusive_and_mu_edges_separate() {
760        let mu = 1e-10;
761        assert_eq!(classify(1e-1, mu), WEAKLY_ACTIVE);
762        assert_eq!(classify(1e1, mu), WEAKLY_ACTIVE);
763        // either side of each μ-edge (exactly-on is float-fragile:
764        // √(1e-10) is not exactly 1e-5)
765        assert_eq!(classify(0.99e-5, mu), INACTIVE);
766        assert_eq!(classify(1.01e-5, mu), AMBIGUOUS);
767        assert_eq!(classify(0.99e5, mu), AMBIGUOUS);
768        assert_eq!(classify(1.01e5, mu), STRONGLY_ACTIVE);
769    }
770
771    #[test]
772    fn loose_mu_refuses_the_weak_call() {
773        // μ > 1e-4: three statuses only, the band reports ambiguous
774        for mu in [1e-3, 1e-2, 1e-1] {
775            assert_eq!(classify(0.05, mu), INACTIVE);
776            assert_eq!(classify(1.0, mu), AMBIGUOUS);
777            assert_eq!(classify(50.0, mu), STRONGLY_ACTIVE);
778        }
779        // at μ = 1e-4 exactly the μ-branch is not taken: the weak call
780        // is available, with a decade of margin edge-to-band
781        assert_eq!(classify(1.0, 1e-4), WEAKLY_ACTIVE);
782    }
783
784    #[test]
785    fn off_path_is_a_factor_of_ten_both_ways() {
786        let mu = 1e-2;
787        assert!(!off_path(1.0, 1e-2, mu)); // s·z = μ exactly
788        assert!(!off_path(0.5, 1e-2, mu)); // within 10×
789        assert!(off_path(1.0, 0.2, mu)); // 20× above
790        assert!(off_path(1.0, 5e-4, mu)); // 20× below
791    }
792
793    #[test]
794    fn contamination_is_mu_relative_and_inactive_only() {
795        let mu = 1e-10; // inactive edge at 1e-5, threshold at 1e-8
796        assert!(contaminated(INACTIVE, 1e-6, mu));
797        assert!(!contaminated(INACTIVE, 5e-9, mu));
798        assert!(!contaminated(WEAKLY_ACTIVE, 1.0, mu));
799        assert!(!contaminated(STRONGLY_ACTIVE, 1e5, mu));
800        // the flag is reachable: 100μ sits below the inactive edge √μ
801        // whenever μ < 1e-4, so an inactive r can exceed it
802        assert!(100.0 * mu < mu.sqrt());
803    }
804
805    #[test]
806    fn below_floor_reports_unidentified_with_the_sign() {
807        let e = classify_entry(0.5, 1e-12, 1e-8, 1e-10);
808        assert_eq!(e.status, UNIDENTIFIED);
809        assert_eq!(e.q_sign, 1);
810        let e = classify_entry(0.5, -1e-12, 1e-8, 1e-10);
811        assert_eq!(e.status, UNIDENTIFIED);
812        assert_eq!(e.q_sign, -1);
813        // negative curvature above the floor classifies on |q| but
814        // keeps its sign visible
815        let e = classify_entry(1.0, -2.0, 1e-8, 1e-10);
816        assert_eq!(e.status, WEAKLY_ACTIVE);
817        assert_eq!(e.q_sign, -1);
818        assert_eq!(e.ratio, 0.5);
819    }
820}