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}