use pounce_common::types::Number;
use pounce_convex::qp::{QpProblem, QpSolution};
use pounce_convex::sensitivity::QpSensitivity;
use pounce_convex::{QpOptions, QpStatus};
use pounce_linsol::SparseSymLinearSolverInterface;
use crate::nl_reader::NlSuffixes;
use crate::nl_writer::{SolSuffix, SolSuffixTarget, SolSuffixValues};
use crate::qp_extract::ConRowMap;
#[derive(Debug, Clone, PartialEq)]
pub struct SensPins {
pub pin_rows: Vec<usize>,
pub param_vars: Vec<usize>,
pub target: Vec<Number>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PinRefusal {
Suffixes(String),
UntaggedParameter(usize),
PinIsNotAnEquality(usize),
PinRowIsNotUnit { con: usize, coefficient: Number },
}
impl PinRefusal {
pub fn describe(&self) -> String {
match self {
PinRefusal::Suffixes(m) => m.clone(),
PinRefusal::UntaggedParameter(k) => format!(
"parameter {} carries no sens_state_1 or sens_init_constr tag",
k + 1
),
PinRefusal::PinIsNotAnEquality(c) => format!(
"constraint {} pins a parameter but is an inequality or range, and the \
convex parametric step perturbs the equality right-hand side",
c + 1
),
PinRefusal::PinRowIsNotUnit { con, coefficient } => format!(
"constraint {} pins a parameter with coefficient {coefficient} rather \
than 1, which the sIPOPT suffix convention does not describe",
con + 1
),
}
}
}
pub fn resolve_pins(
suffixes: &NlSuffixes,
con_map: &[ConRowMap],
qp: &QpProblem,
n_full: usize,
) -> Result<SensPins, PinRefusal> {
let missing = |what: &str| PinRefusal::Suffixes(format!("the .nl declares no `{what}` suffix"));
let sens_state = suffixes
.var_int
.get("sens_state_1")
.ok_or_else(|| missing("sens_state_1"))?;
let sens_state_value = suffixes
.var_real
.get("sens_state_value_1")
.ok_or_else(|| missing("sens_state_value_1"))?;
let sens_init_constr = suffixes
.con_int
.get("sens_init_constr")
.ok_or_else(|| missing("sens_init_constr"))?;
if sens_state.len() != n_full || sens_state_value.len() != n_full {
return Err(PinRefusal::Suffixes(format!(
"sens_state_1 / sens_state_value_1 length mismatch (expected {n_full})"
)));
}
let n_params = sens_state.iter().copied().max().unwrap_or(0).max(0) as usize;
if n_params == 0 {
return Err(PinRefusal::Suffixes(
"sens_state_1 tags no parameters".to_string(),
));
}
let mut param_var: Vec<Option<usize>> = vec![None; n_params];
for (var_idx, &slot) in sens_state.iter().enumerate() {
if slot > 0 && (slot as usize) <= n_params {
param_var[slot as usize - 1] = Some(var_idx);
}
}
let mut param_con: Vec<Option<usize>> = vec![None; n_params];
for (con_idx, &slot) in sens_init_constr.iter().enumerate() {
if slot > 0 && (slot as usize) <= n_params {
param_con[slot as usize - 1] = Some(con_idx);
}
}
let mut pins = SensPins {
pin_rows: Vec::with_capacity(n_params),
param_vars: Vec::with_capacity(n_params),
target: Vec::with_capacity(n_params),
};
for k in 0..n_params {
let (Some(vi), Some(ci)) = (param_var[k], param_con[k]) else {
return Err(PinRefusal::UntaggedParameter(k));
};
let row = match con_map.get(ci) {
Some(ConRowMap::Eq { a_row }) => *a_row,
_ => return Err(PinRefusal::PinIsNotAnEquality(ci)),
};
let coefficient = row_coefficient(qp, row, vi);
if (coefficient - 1.0).abs() > 1e-12 {
return Err(PinRefusal::PinRowIsNotUnit {
con: ci,
coefficient,
});
}
pins.pin_rows.push(row);
pins.param_vars.push(vi);
pins.target.push(sens_state_value[vi]);
}
Ok(pins)
}
fn row_coefficient(qp: &QpProblem, row: usize, var: usize) -> Number {
qp.a.iter()
.filter(|t| t.row == row && t.col == var)
.map(|t| t.val)
.sum()
}
pub fn perturbed_x<F>(
qp: &QpProblem,
sol: &QpSolution,
opts: &QpOptions,
pins: &SensPins,
make_backend: F,
) -> Result<Vec<Number>, String>
where
F: FnMut() -> Box<dyn SparseSymLinearSolverInterface> + Copy,
{
if sol.status != QpStatus::Optimal {
return Err(format!(
"the solve finished {:?}, so there is no optimum to differentiate at",
sol.status
));
}
let mut sens = QpSensitivity::build(qp, sol, opts, 1e-7, make_backend)
.map_err(|e| format!("could not build the convex sensitivity: {e:?}"))?;
let deltas: Vec<Number> = pins
.param_vars
.iter()
.zip(&pins.target)
.map(|(&vi, &t)| t - sol.x[vi])
.collect();
let dx = sens.parametric_step(&pins.pin_rows, &deltas);
if sens.ill_conditioned() {
let cond = sens.kkt_cond_estimate();
let resid = sens
.last_step_residual()
.map(|r| format!("{r:.3e}"))
.unwrap_or_else(|| "n/a".to_string());
return Err(format!(
"the step is not meaningful here: condition estimate {cond:.3e}, \
step residual {resid}. Either alone is enough to reject it, and on \
a rank-deficient active set it is the residual — the regularized \
KKT is well conditioned there, so the condition estimate looks \
healthy while the answer is not"
));
}
Ok(sol.x.iter().zip(&dx).map(|(a, b)| a + b).collect())
}
pub fn sens_suffix(x_pert: Vec<Number>) -> SolSuffix {
SolSuffix {
name: "sens_sol_state_1".to_string(),
target: SolSuffixTarget::Var,
values: SolSuffixValues::Real(x_pert),
}
}
#[cfg(test)]
mod tests {
use super::*;
use pounce_convex::qp::Triplet;
use std::collections::BTreeMap;
fn qp() -> QpProblem {
QpProblem {
n: 3,
p_lower: (0..3).map(|j| Triplet::new(j, j, 1.0)).collect(),
c: vec![0.0; 3],
a: vec![
Triplet::new(0, 0, 1.0),
Triplet::new(0, 1, 1.0),
Triplet::new(1, 2, 1.0),
],
b: vec![1.0, 1.0],
g: vec![],
h: vec![],
lb: vec![],
ub: vec![],
}
}
fn suffixes(state: Vec<i32>, value: Vec<f64>, con: Vec<i32>) -> NlSuffixes {
let mut s = NlSuffixes::default();
s.var_int.insert("sens_state_1".into(), state);
s.var_real.insert("sens_state_value_1".into(), value);
s.con_int.insert("sens_init_constr".into(), con);
s
}
fn good() -> NlSuffixes {
suffixes(vec![0, 0, 1], vec![0.0, 0.0, 1.5], vec![0, 1])
}
#[test]
fn a_well_formed_request_resolves_to_the_equality_row() {
let pins = resolve_pins(
&good(),
&[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
&qp(),
3,
)
.expect("a unit equality pin is expressible");
assert_eq!(
pins,
SensPins {
pin_rows: vec![1],
param_vars: vec![2],
target: vec![1.5],
}
);
}
#[test]
fn the_nl_constraint_index_is_not_the_equality_row_index() {
let con_map = [
ConRowMap::Ineq {
upper: Some(0),
lower: None,
},
ConRowMap::Eq { a_row: 0 },
];
let mut q = qp();
q.a = vec![Triplet::new(0, 2, 1.0)];
q.b = vec![1.0];
let pins = resolve_pins(&good(), &con_map, &q, 3).expect("still expressible");
assert_eq!(
pins.pin_rows,
vec![0],
"constraint 1 pins equality row 0 here; using the .nl index would perturb a \
row that does not exist"
);
}
#[test]
fn an_inequality_pin_is_refused() {
let con_map = [
ConRowMap::Eq { a_row: 0 },
ConRowMap::Ineq {
upper: Some(0),
lower: None,
},
];
assert_eq!(
resolve_pins(&good(), &con_map, &qp(), 3),
Err(PinRefusal::PinIsNotAnEquality(1))
);
}
#[test]
fn a_non_unit_pin_row_is_refused() {
let mut q = qp();
q.a = vec![
Triplet::new(0, 0, 1.0),
Triplet::new(0, 1, 1.0),
Triplet::new(1, 2, -1.0),
];
assert_eq!(
resolve_pins(
&good(),
&[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
&q,
3
),
Err(PinRefusal::PinRowIsNotUnit {
con: 1,
coefficient: -1.0
})
);
}
#[test]
fn a_parameter_with_no_pinning_constraint_is_refused() {
let s = suffixes(vec![0, 0, 1], vec![0.0, 0.0, 1.5], vec![0, 0]);
assert_eq!(
resolve_pins(
&s,
&[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
&qp(),
3
),
Err(PinRefusal::UntaggedParameter(0))
);
}
#[test]
fn a_length_mismatch_is_refused_rather_than_indexed_past() {
let s = suffixes(vec![0, 1], vec![0.0, 1.5], vec![0, 1]);
assert!(matches!(
resolve_pins(
&s,
&[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
&qp(),
3
),
Err(PinRefusal::Suffixes(_))
));
}
#[test]
fn the_convex_arm_has_no_var_x_split() {
let q = qp();
assert_eq!(
q.n, 3,
"the extracted QP keeps every .nl variable, fixed ones included"
);
let pins = resolve_pins(
&good(),
&[ConRowMap::Eq { a_row: 0 }, ConRowMap::Eq { a_row: 1 }],
&q,
q.n,
)
.unwrap();
assert!(
pins.param_vars.iter().all(|&v| v < q.n),
"parameter variable indices are QP indices and .nl indices at once"
);
}
#[test]
fn every_refusal_describes_itself_without_panicking() {
let cases = [
PinRefusal::Suffixes("x".into()),
PinRefusal::UntaggedParameter(0),
PinRefusal::PinIsNotAnEquality(1),
PinRefusal::PinRowIsNotUnit {
con: 1,
coefficient: -1.0,
},
];
for c in cases {
assert!(!c.describe().is_empty());
}
let _ = BTreeMap::<String, u8>::new();
}
}