use super::report::{Basis, Confidence, Finding, Report, Severity, num};
use super::statics::affine_row;
use super::{DiagnoseOptions, PointEval, cname, vname};
use crate::nl_reader::{NlBody, NlProblem};
use pounce_common::types::{Number, lower_bound_present, upper_bound_present};
use pounce_nl::nl_scaling::QuadRowCoef;
use serde_json::{Value, json};
const HUGE_BOUND: Number = 1e10;
#[derive(Default)]
struct Range {
min: Option<(Number, String)>,
max: Option<(Number, String)>,
count: usize,
}
impl Range {
fn see(&mut self, v: Number, at: impl FnOnce() -> String) {
let a = v.abs();
if a == 0.0 || !a.is_finite() {
return;
}
self.count += 1;
let need_min = self.min.as_ref().is_none_or(|m| a < m.0);
let need_max = self.max.as_ref().is_none_or(|m| a > m.0);
if need_min || need_max {
let where_ = at();
if need_min {
self.min = Some((a, where_.clone()));
}
if need_max {
self.max = Some((a, where_));
}
}
}
fn ratio(&self) -> Option<Number> {
Some(self.max.as_ref()?.0 / self.min.as_ref()?.0)
}
fn json(&self) -> Value {
json!({
"count": self.count,
"min": self.min.as_ref().map(|m| json!({"magnitude": m.0, "at": m.1})),
"max": self.max.as_ref().map(|m| json!({"magnitude": m.0, "at": m.1})),
"ratio": self.ratio().map(num),
})
}
fn describe(&self) -> String {
match (&self.min, &self.max) {
(Some(lo), Some(hi)) => format!("{:.1e} ({}) to {:.1e} ({})", lo.0, lo.1, hi.0, hi.1),
_ => "none".to_string(),
}
}
}
pub fn check_coefficients(
prob: &NlProblem,
quad: &[QuadRowCoef],
opts: &DiagnoseOptions,
report: &mut Report,
) {
let mut lin = Range::default();
let mut obj = Range::default();
let mut rhs = Range::default();
let mut bnd = Range::default();
let mut curv = Range::default();
for k in 0..prob.m {
let coefs: Vec<(usize, Number)> = match (&prob.con_nonlinear[k], affine_row(prob, k)) {
(NlBody::Tree(_), Some(a)) => a.coefs.into_iter().collect(),
_ => prob.con_linear[k].clone(),
};
for (j, a) in coefs {
lin.see(a, || format!("{} in {}", vname(prob, j), cname(prob, k)));
}
for b in [prob.g_l[k], prob.g_u[k]] {
if lower_bound_present(b) && upper_bound_present(b) {
rhs.see(b, || cname(prob, k));
}
}
}
if prob.num_obj > 0 {
for &(j, c) in &prob.obj_linear {
obj.see(c, || vname(prob, j));
}
}
for q in quad {
curv.see(q.curvature, || cname(prob, q.index));
}
let mut huge: Vec<(usize, Number)> = Vec::new();
for j in 0..prob.n {
for b in [prob.x_l[j], prob.x_u[j]] {
if lower_bound_present(b) && upper_bound_present(b) {
bnd.see(b, || vname(prob, j));
if b.abs() >= HUGE_BOUND {
huge.push((j, b));
}
}
}
}
let table = json!({
"constraint_linear_coefficients": lin.json(),
"objective_linear_coefficients": obj.json(),
"quadratic_row_curvature": curv.json(),
"constraint_right_hand_sides": rhs.json(),
"finite_variable_bounds": bnd.json(),
});
report.sections.insert("coefficient_ranges", table.clone());
let mut sink = report.ran("coefficient_ranges");
sink.push(
Finding::new("coefficient_ranges", "coefficient_ranges", Severity::Info, Basis::Proof,
Confidence::Certain,
format!("Constraint coefficients range {}; objective coefficients {}; right-hand sides {}; finite bounds {}.",
lin.describe(), obj.describe(), rhs.describe(), bnd.describe()))
.why("Magnitudes far from 1, or ranges spanning many orders, make the KKT system badly scaled.")
.next("See sections.coefficient_ranges in the JSON for the full table.")
.evidence(table),
);
for (label, r) in [
("constraint coefficients", &lin),
("objective coefficients", &obj),
("quadratic curvature", &curv),
] {
if let Some(ratio) = r.ratio()
&& ratio > opts.wide_range
{
sink.push(
Finding::new("coefficient_ranges", "wide_coefficient_range", Severity::Warning,
Basis::Proof, Confidence::Certain,
format!("The {label} span {ratio:.1e}: from {}.", r.describe()))
.why("A range this wide usually means mixed units; the linear solves lose about log10(range) digits, and automatic gradient-based scaling only rescales rows, not columns.")
.next("Rescale the variables or rows named at the extremes to comparable units.")
.evidence(json!({"which": label, "ratio": num(ratio), "range": r.json(),
"threshold": opts.wide_range})),
);
}
}
for (j, b) in huge {
sink.push(
Finding::new("coefficient_ranges", "huge_finite_bound", Severity::Warning, Basis::Proof,
Confidence::High,
format!("Variable {} has a finite bound {b:e}, large enough to be a stand-in for \"no bound\".", vname(prob, j)))
.why("The solver treats only |bound| ≥ 1e19 as absent. A finite bound this large is kept, its barrier term is nearly flat, and the bound multiplier is badly scaled; the initial point is pushed relative to it.")
.next("If the variable is meant to be unbounded on that side, use an infinite bound (or one ≥ 1e19).")
.vars(vec![vname(prob, j)])
.evidence(json!({"bound": b, "threshold": HUGE_BOUND})),
);
}
}
pub fn check_jacobian(
prob: &NlProblem,
pt: &PointEval,
opts: &DiagnoseOptions,
report: &mut Report,
) {
if !pt.jac_ok || pt.jac_val.iter().any(|v| !v.is_finite()) {
report.unavailable(
"jacobian_scaling",
format!(
"the Jacobian is not finite at {} (see nonfinite_localization)",
pt.label
),
);
return;
}
let mut row_max = vec![0.0_f64; prob.m];
let mut col_max = vec![0.0_f64; prob.n];
for (k, &v) in pt.jac_val.iter().enumerate() {
let a = v.abs();
let (r, c) = (pt.jac_irow[k], pt.jac_jcol[k]);
row_max[r] = row_max[r].max(a);
col_max[c] = col_max[c].max(a);
}
let mut rows = Range::default();
for (i, &v) in row_max.iter().enumerate() {
rows.see(v, || cname(prob, i));
}
let mut cols = Range::default();
for (j, &v) in col_max.iter().enumerate() {
if prob.x_l[j] != prob.x_u[j] {
cols.see(v, || vname(prob, j));
}
}
let zero_rows: Vec<String> = (0..prob.m)
.filter(|&i| row_max[i] == 0.0)
.map(|i| cname(prob, i))
.collect();
let grad_inf = if pt.grad_ok && pt.grad.iter().all(|v| v.is_finite()) {
Some(pt.grad.iter().fold(0.0_f64, |a, v| a.max(v.abs())))
} else {
None
};
report.sections.insert(
"jacobian_scaling",
json!({"point": pt.label, "row_scales": rows.json(), "column_scales": cols.json(),
"zero_rows": zero_rows.len(), "objective_gradient_inf_norm": grad_inf.map(num)}),
);
let mut sink = report.ran("jacobian_scaling");
sink.push(
Finding::new("jacobian_scaling", "jacobian_scale_summary", Severity::Info, Basis::Numerical,
Confidence::Certain,
format!("At {}, Jacobian row scales range {}; column scales {}; ‖∇f‖∞ = {}.",
pt.label, rows.describe(), cols.describe(),
grad_inf.map(|g| format!("{g:.1e}")).unwrap_or_else(|| "unavailable".into())))
.why("Row and column scale spreads are what scaling has to undo; a gradient far larger or smaller than the column scales makes the objective dominate, or vanish from, the KKT system.")
.next("See sections.jacobian_scaling and the embedded check_x0 scaling preview.")
.evidence(json!({"point": pt.label, "row_scales": rows.json(), "column_scales": cols.json()})),
);
for (label, r) in [("row", &rows), ("column", &cols)] {
if let Some(ratio) = r.ratio()
&& ratio > opts.wide_range
{
sink.push(
Finding::new("jacobian_scaling", if label == "row" { "wide_jacobian_row_scale_range" } else { "wide_jacobian_column_scale_range" },
Severity::Warning, Basis::Numerical, Confidence::High,
format!("At {}, Jacobian {label} scales span {ratio:.1e}: from {}.", pt.label, r.describe()))
.why(if label == "row" {
"Constraints of very different magnitudes are weighted very differently in the feasibility measure; gradient-based scaling caps rows above nlp_scaling_max_gradient but does not raise small ones."
} else {
"Variables in very different units make the KKT matrix badly scaled; the solver does not rescale columns."
})
.next("Rescale the extreme rows or variables to comparable units, then compare with another point to see whether the spread persists.")
.evidence(json!({"point": pt.label, "ratio": num(ratio), "range": r.json(), "threshold": opts.wide_range})),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nl_reader::{BinOp, Expr, NlProblemParts};
fn lin(coefs: &[(usize, Number)]) -> Expr {
Expr::Sum(
coefs
.iter()
.map(|&(j, a)| {
Expr::Binary(BinOp::Mul, Box::new(Expr::Const(a)), Box::new(Expr::Var(j)))
})
.collect(),
)
}
fn prob(cons: Vec<Expr>, n: usize) -> NlProblem {
let m = cons.len();
NlProblem::from_expressions(NlProblemParts {
minimize: true,
objective: Expr::Const(0.0),
obj_constant: 0.0,
constraints: cons,
x_l: vec![-1e19; n],
x_u: vec![1e19; n],
x0: vec![1.0; n],
g_l: vec![1.0; m],
g_u: vec![1.0; m],
var_names: (0..n).map(|j| format!("x{j}")).collect(),
con_names: (0..m).map(|i| format!("c{i}")).collect(),
})
.unwrap()
}
fn codes(r: &Report) -> Vec<&'static str> {
r.findings.iter().map(|f| f.code).collect()
}
#[test]
fn well_scaled_model_has_only_the_summary() {
let p = prob(
vec![lin(&[(0, 1.0), (1, 2.0)]), lin(&[(0, 3.0), (1, -1.0)])],
2,
);
let mut r = Report::new("t".into(), 2, 2, 50);
check_coefficients(&p, &[], &DiagnoseOptions::default(), &mut r);
let pt = PointEval::evaluate_nl(&p, vec![1.0, 1.0], "p".into());
check_jacobian(&p, &pt, &DiagnoseOptions::default(), &mut r);
assert_eq!(
codes(&r),
vec!["coefficient_ranges", "jacobian_scale_summary"]
);
}
#[test]
fn planted_extreme_coefficient_is_named() {
let p = prob(
vec![lin(&[(0, 1.0), (1, 2.0)]), lin(&[(0, 3.0), (1, 1e10)])],
2,
);
let mut r = Report::new("t".into(), 2, 2, 50);
check_coefficients(&p, &[], &DiagnoseOptions::default(), &mut r);
let f = r
.findings
.iter()
.find(|f| f.code == "wide_coefficient_range")
.expect("flagged");
assert!(f.observation.contains("x1 in c1"), "{}", f.observation);
let pt = PointEval::evaluate_nl(&p, vec![1.0, 1.0], "p".into());
check_jacobian(&p, &pt, &DiagnoseOptions::default(), &mut r);
let f = r
.findings
.iter()
.find(|f| f.code == "wide_jacobian_column_scale_range")
.expect("flagged");
assert!(f.observation.contains("x1"), "{}", f.observation);
}
#[test]
fn huge_finite_bound_is_flagged() {
let mut p = prob(vec![lin(&[(0, 1.0)])], 1);
p.x_u[0] = 1e15;
let mut r = Report::new("t".into(), 1, 1, 50);
check_coefficients(&p, &[], &DiagnoseOptions::default(), &mut r);
assert!(codes(&r).contains(&"huge_finite_bound"));
p.x_u[0] = 1e19;
let mut r = Report::new("t".into(), 1, 1, 50);
check_coefficients(&p, &[], &DiagnoseOptions::default(), &mut r);
assert!(!codes(&r).contains(&"huge_finite_bound"));
}
}