use pounce_algorithm::alg_builder::{LinearBackendFactory, LinearSolverChoice};
use pounce_algorithm::application::IpoptApplication;
use pounce_cli::builtin;
use pounce_cli::cli::{Args, ProblemSource};
use pounce_cli::counting_tnlp::CountingTnlp;
use pounce_cli::nl_reader;
use pounce_cli::nl_writer;
use pounce_cli::print;
use pounce_cli::sens;
use pounce_cli::solve_report::{
InputDescriptor, ReportBuilder, ReportDetail, SolutionSuffix, status_to_solve_result_num,
write_report_file,
};
use pounce_common::diagnostics::{
DiagCategory, DiagnosticsConfig, DiagnosticsState, DumpFormat, IterSpec,
};
use pounce_linsol::sparse_sym_iface::SparseSymLinearSolverInterface;
use pounce_nlp::SolveStatistics;
use pounce_nlp::return_codes::ApplicationReturnStatus;
use pounce_nlp::solve_statistics::IterRecord;
use pounce_nlp::tnlp::{InfeasibilityProof, TNLP};
use pounce_restoration::resto_alg_builder::RestoAlgorithmBuilder;
use pounce_restoration::resto_inner_solver::{
InnerBackendFactoryFactory, make_default_restoration_factory_provider,
};
use std::cell::RefCell;
use std::path::PathBuf;
use std::process::ExitCode;
use std::rc::Rc;
fn presolve_verdict(
certified: Option<InfeasibilityProof>,
status: ApplicationReturnStatus,
) -> (String, i32) {
match certified.filter(|_| status == ApplicationReturnStatus::InfeasibleProblemDetected) {
Some(proof) => {
let detail = match proof {
InfeasibilityProof::BoundPropagation => "bound propagation".to_string(),
InfeasibilityProof::IntervalArithmetic { witness } => {
format!("interval arithmetic, constraint {witness}")
}
};
(
format!(
"POUNCE {}: InfeasibleProblemDetected (detected by presolve: {detail})",
env!("CARGO_PKG_VERSION")
),
201,
)
}
None => (
format!("POUNCE {}: {status:?}", env!("CARGO_PKG_VERSION")),
status_to_solve_result_num(status),
),
}
}
pub fn main() -> ExitCode {
pounce_observability::init_subscriber();
let raw_argv: Vec<String> = std::env::args().collect();
if raw_argv.get(1).map(|s| s == "verify").unwrap_or(false) {
return pounce_cli::verify::run_from_argv(&raw_argv[2..]);
}
if raw_argv.get(1).map(|s| s == "check-x0").unwrap_or(false) {
return pounce_cli::check_x0::run_from_argv(&raw_argv[2..]);
}
let mut args = match Args::parse_argv(std::env::args().collect()) {
Ok(a) => a,
Err(msg) => {
eprintln!("pounce: {msg}");
eprintln!("{}", Args::usage());
return ExitCode::from(2);
}
};
if let Ok(env_opts) = std::env::var("pounce_options") {
let mut merged = pounce_cli::cli::options_from_env(&env_opts);
if !merged.is_empty() {
merged.append(&mut args.set_options);
args.set_options = merged;
}
}
if args.help {
println!("{}", Args::usage());
return ExitCode::SUCCESS;
}
if args.version {
println!("pounce {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
if args.about {
print_about();
return ExitCode::SUCCESS;
}
if args.cite {
return run_cite(&args);
}
let mut app = IpoptApplication::new();
let convex_knobs: Result<(), pounce_common::SolverException> = (|| {
let r = app.registered_options();
r.add_bounded_number_option(
"qp_tau",
"Convex IPM fraction-to-boundary parameter τ ∈ (0,1).",
0.0,
true,
1.0,
true,
0.95,
"Convex LP/QP interior-point only. Caps each Newton step at a \
fraction τ of the distance to the cone boundary; nearer 1 is more \
aggressive. The floor of the adaptive rule capped by qp_tau_max, \
and the flat value on the predictor step and on second-order / \
PSD cone blocks. Default 0.95.",
)?;
r.add_bounded_number_option(
"qp_tau_max",
"Convex IPM adaptive fraction-to-boundary ceiling τ_max ∈ (0,1).",
0.0,
true,
1.0,
true,
1.0 - 1e-12,
"Convex LP/QP interior-point only. As the solve converges, the \
corrector's τ on nonnegative-orthant blocks follows the Mehrotra \
tail τ = clamp(1 − μ, qp_tau, qp_tau_max), so a near-optimal \
iterate can take a near-full Newton step — worth 35–60% of the \
iterations when warm starting a sequence of nearby QPs. Set equal \
to qp_tau to pin τ flat (the most conservative setting). Default \
1 − 1e-12.",
)?;
r.add_lower_bounded_number_option(
"qp_reg",
"Convex IPM static KKT regularization δ ≥ 0.",
0.0,
false,
1e-10,
"Convex LP/QP interior-point only. Added on the (block) diagonal to \
keep the reduced KKT quasi-definite for a stable LDLᵀ inertia. Too \
large freezes the primal residual on badly-scaled LPs; the default \
1e-10 is centered in the band that converges the LP/QP suites.",
)?;
r.add_lower_bounded_number_option(
"qp_infeas_tol",
"Convex IPM infeasibility-certificate value tolerance > 0.",
0.0,
true,
1e-7,
"Convex LP/QP interior-point only. Relative tolerance on the value \
and cone-membership parts of an infeasibility / unboundedness \
certificate. The certificate's defining-equation residual is held \
to a far tighter internal tolerance; this only governs when a \
status is backed by a verified proof. Default 1e-7.",
)?;
r.add_string_option(
"qp_hsde",
"Use the homogeneous self-dual embedding for the convex IPM.",
"yes",
&[
("yes", "Self-dual embedding: self-starting, native certificates, robust on ill-conditioned data."),
("no", "Infeasible-start primal–dual method (the warm-start / build-once substrate)."),
],
"Convex LP/QP interior-point only. HSDE (default) self-starts and \
produces infeasibility / unboundedness certificates natively; it \
is also the substrate for non-symmetric cones. Default yes.",
)?;
r.add_string_option(
"qp_equilibrate",
"Ruiz-equilibrate the data before the direct convex IPM solve.",
"yes",
&[
(
"yes",
"Apply Ruiz row/column scaling before solving (direct, non-HSDE path).",
),
("no", "Solve the raw data without equilibration."),
],
"Convex LP/QP interior-point only, and only when `qp_hsde=no` (the \
direct infeasible-start path): a conditioning aid for the raw KKT \
factorization. HSDE conditions internally and ignores this. \
Default yes.",
)?;
r.add_string_option(
"qp_crossover",
"Run LP crossover to purify the IPM iterate to an exact vertex.",
"no",
&[
("yes", "After the IPM, pivot the interior iterate to an exact optimal vertex (active-set purification)."),
("no", "Return the interior-point iterate directly (default)."),
],
"Convex LP path only (pure LP, P=0); a no-op for genuine QPs. \
Correct (never-regress) but currently slow on the degenerate / \
large NETLIB LPs it targets and does not yet reach an exact \
`Optimal` vertex on the GEN family (issue #133), so it is off by \
default and offered as an opt-in for small, well-behaved LPs that \
want exact-vertex refinement. Default no.",
)?;
Ok(())
})();
if let Err(e) = convex_knobs {
eprintln!("pounce: failed to register convex LP/QP options: {e}");
return ExitCode::from(2);
}
if args.json_output.is_some() && matches!(args.json_detail, ReportDetail::Full) {
app.enable_iter_history();
}
let option_file_choice = match args.option_file_choice() {
Ok(c) => c,
Err(msg) => {
eprintln!("pounce: {msg}");
return ExitCode::from(2);
}
};
let mut option_file_read: Option<PathBuf> = None;
match &option_file_choice {
pounce_cli::cli::OptionFileChoice::Suppressed => {
if let Err(e) = app.initialize() {
eprintln!("pounce: initialize failed: {e}");
return ExitCode::from(2);
}
}
choice => {
let explicit = match choice {
pounce_cli::cli::OptionFileChoice::Named(p) => Some(p.as_path()),
_ => None,
};
match app.initialize_with_option_file(explicit) {
Ok(load) => {
for warning in &load.warnings {
eprintln!("pounce: warning: {warning}");
}
option_file_read = load.path;
}
Err(e) => {
eprintln!("pounce: failed to load options file: {}", e.message);
return ExitCode::from(2);
}
}
}
}
for (k, v) in &args.set_options {
let line = format!("{k} {v}\n");
if let Err(e) = app.options_mut().read_from_str(&line, true) {
eprintln!("pounce: failed to set {k}={v}: {e}");
return ExitCode::from(2);
}
}
let json_dbg = matches!(args.debug, Some(pounce_cli::cli::DebugMode::Json));
let restart_cell: pounce_cli::debug_repl::RestartCell = Rc::new(RefCell::new(None));
let mut debug_hook: Option<Rc<RefCell<pounce_cli::debug_repl::SolverDebugger>>> = None;
if let Some(mode) = args.debug {
if json_dbg {
let _ = app.options_mut().read_from_str("print_level 0\n", true);
}
let reg = Some(std::rc::Rc::clone(app.registered_options()));
let hook = Rc::new(RefCell::new(build_debugger(
mode,
args.debug_on_error,
args.debug_on_interrupt,
args.debug_script.as_deref(),
reg,
restart_cell.clone(),
)));
app.set_debug_hook(hook.clone());
debug_hook = Some(hook);
pounce_cli::debug_repl::interrupt::install();
pounce_cli::debug_repl::print_open_banner(mode);
let extra = if args.debug_on_error {
", on-error"
} else if args.debug_on_interrupt {
", on-interrupt"
} else {
""
};
eprintln!(
"pounce: interactive debugger enabled ({}{}). Type `help` at the prompt; Ctrl-C breaks in.",
match mode {
pounce_cli::cli::DebugMode::Repl => "repl",
pounce_cli::cli::DebugMode::Json => "json",
},
extra
);
}
let feral_cfg = pounce_algorithm::application::feral_config_from_options(app.options());
let bff_mint = move || -> InnerBackendFactoryFactory {
let feral_cfg = feral_cfg.clone();
Box::new(move || default_backend_factory(feral_cfg.clone()))
};
let resto_provider = make_default_restoration_factory_provider(
RestoAlgorithmBuilder::new(),
app.algorithm_builder_from_options(),
bff_mint,
);
app.set_restoration_factory_provider(resto_provider);
if let Some(value) = app.unimplemented_linear_solver() {
eprintln!(
"{}",
IpoptApplication::unimplemented_linear_solver_message(&value)
);
return ExitCode::from(2);
}
if let Some(msg) = app.unimplemented_option_refusal() {
eprintln!("{msg}");
return ExitCode::from(2);
}
for warning in app.unexploited_hint_warnings() {
eprintln!("{warning}");
}
let backend_tag = {
let (v, _) = app
.options()
.get_string_value("linear_solver", "")
.unwrap_or_else(|_| ("feral".to_string(), false));
if v.eq_ignore_ascii_case("ma57") {
#[cfg(feature = "ma57")]
{
"MA57 (HSL)"
}
#[cfg(not(feature = "ma57"))]
{
"FERAL (ma57 requested but not compiled)"
}
} else {
"FERAL"
}
};
let suppress_banner = app
.options()
.get_bool_value("sb", "")
.ok()
.and_then(|(v, f)| f.then_some(v))
.unwrap_or(false);
if !suppress_banner && !json_dbg {
print::print_logo();
print::print_banner(backend_tag);
}
if let Some(path) = &option_file_read {
if !suppress_banner && !json_dbg {
println!("Using option file \"{}\".\n", path.display());
}
}
let problem_desc: String = match &args.problem {
ProblemSource::Builtin(s) => format!("builtin:{s}"),
ProblemSource::NlFile(p) => format!("nl:{}", p.display()),
};
let sol_path: Option<PathBuf> = if args.no_sol {
None
} else if let Some(p) = &args.sol_output {
Some(p.clone())
} else {
match &args.problem {
ProblemSource::NlFile(p) => {
let mut s = p.clone();
s.set_extension("sol");
Some(s)
}
ProblemSource::Builtin(_) => None,
}
};
let mut nl_suffixes: Option<nl_reader::NlSuffixes> = None;
let mut nl_dims: Option<(usize, usize)> = None;
let mut nl_ampl_options: Vec<i64> = Vec::new();
let mut nl_class: Option<pounce_cli::dispatch::ProblemClass> = None;
let mut nl_expr_provider: Option<
Rc<RefCell<dyn pounce_nlp::expression_provider::ExpressionProvider>>,
> = None;
let inner_tnlp: Rc<RefCell<dyn TNLP>> = match &args.problem {
ProblemSource::Builtin(name) => match builtin::lookup(name) {
Some(t) => t,
None => {
eprintln!("pounce: unknown builtin problem '{name}'");
eprintln!("available: {}", builtin::list().join(", "));
return ExitCode::from(2);
}
},
ProblemSource::NlFile(path) => {
if !json_dbg {
println!("Reading {}...", path.display());
}
let t0 = std::time::Instant::now();
match nl_reader::read_nl_file(path) {
Ok(prob) => {
nl_suffixes = Some(prob.suffixes.clone());
nl_dims = Some((prob.n, prob.m));
nl_ampl_options = prob.ampl_options.clone();
let elapsed = t0.elapsed().as_secs_f64();
if let Some(hook) = debug_hook.as_ref() {
let book = pounce_cli::debug_repl::EquationBook::new(
prob.con_names.clone(),
nl_reader::render_all_constraint_equations(&prob),
);
let (jac_irow, jac_jcol) = nl_reader::constraint_jacobian_sparsity(&prob);
let probe = pounce_presolve::incidence::ProbeView {
n_vars: prob.n,
m_rows: prob.m,
jac_irow: &jac_irow,
jac_jcol: &jac_jcol,
jac_values: None,
g_l: &prob.g_l,
g_u: &prob.g_u,
linearity: None,
one_based: false,
eq_tol: 1e-12,
excluded_vars: None,
excluded_rows: None,
};
let inc = pounce_presolve::incidence::EqualityIncidence::from_probe(&probe);
let structure = pounce_cli::debug_repl::StructureBook::new(
inc,
prob.con_names.clone(),
prob.var_names.clone(),
);
let mut h = hook.borrow_mut();
h.set_equation_book(book);
h.set_structure_book(structure);
}
nl_class = Some(pounce_cli::dispatch::classify_problem(&prob));
let nl_rc = Rc::new(RefCell::new(nl_reader::NlTnlp::new(prob)));
nl_expr_provider = Some(Rc::clone(&nl_rc)
as Rc<RefCell<dyn pounce_nlp::expression_provider::ExpressionProvider>>);
let t: Rc<RefCell<dyn TNLP>> = nl_rc;
if let Some(info) = t.borrow_mut().get_nlp_info() {
if !json_dbg {
println!(
"Parsed {} vars, {} cons, jac_nnz={}, h_nnz={} in {:.2}s",
info.n, info.m, info.nnz_jac_g, info.nnz_h_lag, elapsed
);
}
}
t
}
Err(e) => {
eprintln!("pounce: failed to read {}: {e}", path.display());
return ExitCode::from(2);
}
}
}
};
let wants_sens = nl_suffixes
.as_ref()
.map(sens::is_sensitivity_input)
.unwrap_or(false);
let wants_nlp_postopt = wants_sens || args.compute_red_hessian;
let wants_user_scaling = app
.options()
.get_string_value("nlp_scaling_method", "")
.ok()
.and_then(|(v, set)| set.then_some(v))
.is_some_and(|v| v == "user-scaling")
&& nl_suffixes.as_ref().is_some_and(|s| {
s.obj_real.contains_key("scaling_factor")
|| s.con_real.contains_key("scaling_factor")
|| s.var_real.contains_key("scaling_factor")
});
let negative_obj_scaling_option = app
.options()
.get_numeric_value("obj_scaling_factor", "")
.ok()
.and_then(|(v, set)| set.then_some(v))
.is_some_and(|v| v < 0.0);
let negative_obj_scaling_suffix = wants_user_scaling
&& nl_suffixes.as_ref().is_some_and(|s| {
s.obj_real
.get("scaling_factor")
.and_then(|v| v.first())
.is_some_and(|&f| f < 0.0)
});
let maximize_via_obj_scaling = negative_obj_scaling_option || negative_obj_scaling_suffix;
let postopt_what = match (wants_sens, args.compute_red_hessian) {
(true, true) => {
"a parametric sensitivity step (sIPOPT sens_* suffixes) and a \
reduced-Hessian computation"
}
(true, false) => "a parametric sensitivity step (sIPOPT sens_* suffixes)",
_ => "a reduced-Hessian computation",
};
if let Some(mcfg) = &args.minima {
if wants_nlp_postopt {
eprintln!(
"pounce: warning: the .nl requests {postopt_what}, but --minima \
runs a multistart search that does not compute it; the request \
will be skipped. Run without --minima to obtain it."
);
}
app.set_presolve_already_applied(true);
return pounce_cli::minima::run(&mut app, &inner_tnlp, mcfg, &args, sol_path.as_deref());
}
{
use pounce_cli::dispatch::{ProblemClass, SolverChoice, SolverSelection, resolve_solver};
let sel_str = app
.options()
.get_string_value("solver_selection", "")
.map(|(v, _)| v)
.unwrap_or_else(|_| "auto".to_string());
let selection = match SolverSelection::parse(&sel_str) {
Some(s) => s,
None => {
eprintln!(
"pounce: invalid solver_selection '{sel_str}'; valid values: {}",
SolverSelection::VALUES.join(", ")
);
return ExitCode::from(2);
}
};
let class = match &args.problem {
ProblemSource::NlFile(_) => nl_class.unwrap_or(ProblemClass::Nlp),
ProblemSource::Builtin(_) => ProblemClass::Nlp,
};
let choice = match resolve_solver(class, selection) {
Ok(c) => c,
Err(msg) => {
eprintln!("pounce: {msg}");
return ExitCode::from(2);
}
};
let decline_convex_for_postopt =
wants_nlp_postopt && matches!(selection, SolverSelection::Auto);
let decline_convex_for_user_scaling =
wants_user_scaling && matches!(selection, SolverSelection::Auto);
let decline_convex_for_obj_scaling =
maximize_via_obj_scaling && matches!(selection, SolverSelection::Auto);
let decline_convex = decline_convex_for_postopt
|| decline_convex_for_user_scaling
|| decline_convex_for_obj_scaling;
let socp_nlp_fallback = matches!(selection, SolverSelection::Auto);
let lp_nlp_fallback = matches!(selection, SolverSelection::Auto)
&& debug_hook.is_none()
&& !max_iter_explicitly_set(&app);
if !suppress_banner && !json_dbg {
let described = if decline_convex {
SolverChoice::Nlp.describe()
} else {
choice.describe()
};
println!(
"Problem class: {}. Selected solver: {} [solver_selection={}].",
class.name(),
described,
sel_str
);
println!();
}
if matches!(
choice,
SolverChoice::LpIpm
| SolverChoice::QpIpm
| SolverChoice::SocpIpm
| SolverChoice::QpActiveSet
) {
app.run_derivative_test(&inner_tnlp);
if maximize_via_obj_scaling && !decline_convex_for_obj_scaling {
eprintln!(
"pounce: the objective scaling is negative (maximize) — via \
obj_scaling_factor or the .nl's `scaling_factor` suffix — \
but solver_selection={sel_str} forces the convex solver \
(pounce-convex), which minimizes and does not read that \
option — it would report the minimizer of the objective \
you asked to maximize. Use solver_selection=nlp or auto \
(which routes here automatically), or negate the \
objective in the model and drop obj_scaling_factor."
);
return ExitCode::from(2);
}
if wants_nlp_postopt {
if decline_convex_for_postopt {
eprintln!(
"pounce: note: this problem classifies as {} but the .nl \
requests {postopt_what}, which the convex solver \
(pounce-convex) does not provide; routing to the general \
NLP interior-point path so the request is honored.",
class.name()
);
} else {
eprintln!(
"pounce: warning: the .nl requests {postopt_what}, but \
solver_selection={sel_str} forces the convex solver \
(pounce-convex), which does not compute it; the request \
will be skipped. Use solver_selection=nlp or auto to \
obtain it."
);
}
}
if wants_user_scaling {
if decline_convex_for_user_scaling {
eprintln!(
"pounce: note: this problem classifies as {} but \
nlp_scaling_method=user-scaling asks for the .nl's \
`scaling_factor` suffixes to be applied, which the \
convex solver (pounce-convex) does not do; routing to \
the general NLP interior-point path so the scaling is \
honored.",
class.name()
);
} else {
eprintln!(
"pounce: warning: nlp_scaling_method=user-scaling asks \
for the .nl's `scaling_factor` suffixes to be applied, \
but solver_selection={sel_str} forces the convex solver \
(pounce-convex), which equilibrates internally and does \
not read them; the requested scaling will be skipped. \
Use solver_selection=nlp or auto to apply it."
);
}
}
if decline_convex_for_obj_scaling {
eprintln!(
"pounce: note: this problem classifies as {} but \
obj_scaling_factor is negative (maximize), which the \
convex solver (pounce-convex) cannot express; routing to \
the general NLP interior-point path so the objective \
sense is honored.",
class.name()
);
}
if decline_convex {
} else if let ProblemSource::NlFile(path) = &args.problem {
let prob = match nl_reader::read_nl_file(path) {
Ok(p) => p,
Err(e) => {
eprintln!(
"pounce: failed to re-read {} for the convex solver: {e}",
path.display()
);
return ExitCode::from(2);
}
};
let json_cfg = args.json_output.as_deref().map(|p| {
let input = InputDescriptor::NlFile {
path: path.clone(),
size_bytes: std::fs::metadata(path).ok().map(|m| m.len()),
};
(p, args.json_detail, input)
});
let convex_opts = convex_cli_opts(&app);
if matches!(choice, SolverChoice::SocpIpm) {
if let Some(code) = run_convex_socp(
&prob,
class,
sol_path.as_deref(),
json_cfg,
debug_hook.as_ref(),
args.ampl,
convex_opts,
socp_nlp_fallback,
) {
return code;
}
} else {
let opts = app.options();
let presolve_on = resolve_convex_presolve(
opts.get_string_value("qp_presolve", "").ok(),
opts.get_string_value("presolve", "").ok(),
);
let engine_overrides = active_set_overrides(&app);
if matches!(choice, SolverChoice::QpActiveSet) && debug_hook.is_some() {
eprintln!(
"pounce: note: the interactive debugger is IPM-only and does \
not engage on the active-set QP engine (solver_selection=\
qp-active-set); the solve runs without pausing. Use \
solver_selection=qp-ipm to debug a convex QP interactively."
);
}
if let Some(code) = run_convex_qp(
&prob,
class,
sol_path.as_deref(),
presolve_on,
json_cfg,
debug_hook.as_ref(),
args.ampl,
convex_opts,
matches!(choice, SolverChoice::QpActiveSet),
engine_overrides,
lp_nlp_fallback,
) {
return code;
}
}
}
}
let _ = choice;
}
let sens_active = nl_suffixes
.as_ref()
.map(sens::is_sensitivity_input)
.unwrap_or(false);
let nominal_capture: Rc<
RefCell<
Option<(
Vec<pounce_common::types::Number>,
Vec<pounce_common::types::Number>,
)>,
>,
> = Rc::new(RefCell::new(None));
let sens_capture: Rc<RefCell<Option<Vec<pounce_common::types::Number>>>> =
Rc::new(RefCell::new(None));
let bound_mult_capture: Rc<
RefCell<
Option<(
Vec<pounce_common::types::Number>,
Vec<pounce_common::types::Number>,
)>,
>,
> = Rc::new(RefCell::new(None));
let red_hessian_capture: Rc<RefCell<Option<sens::RedHessianResult>>> =
Rc::new(RefCell::new(None));
if args.json_output.is_some() || sol_path.is_some() || sens_active || args.compute_red_hessian {
let cap = Rc::clone(&nominal_capture);
let sens_cap = Rc::clone(&sens_capture);
let bmult_cap = Rc::clone(&bound_mult_capture);
let rh_cap = Rc::clone(&red_hessian_capture);
let suffixes_cb = nl_suffixes.clone();
let dims_cb = nl_dims;
let compute_rh = args.compute_red_hessian;
let rh_eigen = args.rh_eigendecomp;
let boundcheck_eps = args.sens_boundcheck.then_some(args.sens_bound_eps);
app.set_on_converged(Box::new(move |data, cq, nlp, pd| {
let curr = match data.borrow().curr.clone() {
Some(c) => c,
None => return,
};
let x_iterate = nlp.borrow().lift_x_to_full(&*curr.x);
let x = nlp.borrow().finalize_solution_x(&*curr.x);
let mut lambda = nlp
.borrow()
.finalize_solution_lambda(&*curr.y_c, &*curr.y_d);
if lambda.is_empty() {
let n_c = curr.y_c.dim() as usize;
let n_d = curr.y_d.dim() as usize;
lambda = Vec::with_capacity(n_c + n_d);
if let Some(dv) = curr
.y_c
.as_any()
.downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
{
lambda.extend_from_slice(&dv.expanded_values());
} else {
lambda.extend(std::iter::repeat(0.0).take(n_c));
}
if let Some(dv) = curr
.y_d
.as_any()
.downcast_ref::<pounce_linalg::dense_vector::DenseVector>()
{
lambda.extend_from_slice(&dv.expanded_values());
} else {
lambda.extend(std::iter::repeat(0.0).take(n_d));
}
}
*cap.borrow_mut() = Some((x.clone(), lambda));
let z_l_full = nlp.borrow().finalize_solution_z_l(&*curr.z_l);
let z_u_full = nlp.borrow().finalize_solution_z_u(&*curr.z_u);
if !z_l_full.is_empty() || !z_u_full.is_empty() {
*bmult_cap.borrow_mut() = Some((z_l_full, z_u_full));
}
if let Some(suffixes) = &suffixes_cb {
let (n_full, m_full) = dims_cb.unwrap_or((x.len(), 0));
if sens_active {
if let Some(xp) = sens::compute_sens_perturbed_x(
data,
cq,
nlp,
Rc::clone(&pd),
suffixes,
n_full,
m_full,
&x_iterate,
boundcheck_eps,
) {
*sens_cap.borrow_mut() = Some(xp);
}
}
if compute_rh {
match sens::try_compute_red_hessian(
data,
cq,
nlp,
Rc::clone(&pd),
suffixes,
rh_eigen,
) {
Some(r) => *rh_cap.borrow_mut() = Some(r),
None => eprintln!(
"pounce: --compute-red-hessian requested but the `red_hessian` \
suffix is missing or empty in the input .nl"
),
}
}
}
}));
}
let mut presolve_opts = match pounce_presolve::PresolveOptions::from_options_list(app.options())
{
Ok(o) => o,
Err(e) => {
eprintln!("pounce: presolve setup failed: {e}");
return ExitCode::from(2);
}
};
if (sens_active || args.compute_red_hessian) && presolve_opts.enabled {
eprintln!(
"pounce: disabling presolve — sensitivity / reduced-Hessian post-processing \
operates on the original (un-presolved) KKT system"
);
presolve_opts.enabled = false;
}
let presolve_handle = if presolve_opts.enabled {
let p = Rc::new(RefCell::new(match &nl_expr_provider {
Some(ep) => pounce_presolve::PresolveTnlp::with_expression_provider(
Rc::clone(&inner_tnlp),
Rc::clone(ep),
presolve_opts,
),
None => pounce_presolve::PresolveTnlp::new(Rc::clone(&inner_tnlp), presolve_opts),
}));
let _ = p.borrow_mut().get_nlp_info();
{
let h = p.borrow();
let tr = h.tighten_report();
let dropped = h.n_dropped_rows();
let licq = h
.licq_verdict()
.map(|v| format!("{v:?}"))
.unwrap_or_else(|| "off".into());
if !json_dbg {
println!(
"Presolve: tightened {} bounds ({} newly-finite), dropped {} redundant rows, LICQ={}",
tr.n_tightened, tr.n_new_finite, dropped, licq
);
}
if let Some(fr) = h.fbbt_report() {
if !json_dbg {
println!(
"Presolve FBBT: {} sweeps, {} variable tightenings (Σ|Δ|={:.3e})",
fr.iterations, fr.bound_updates, fr.total_tightening
);
}
if let Some(witness) = fr.infeasibility_witness {
eprintln!("pounce: FBBT detected infeasibility (witness constraint {witness})");
}
}
}
Some(p)
} else {
None
};
let elim_handle = match (&presolve_handle, presolve_opts.linear_eq_reduction) {
(Some(p), true) => {
let e = Rc::new(RefCell::new(pounce_presolve::LinearEqElimTnlp::new(
Rc::clone(p) as Rc<RefCell<dyn TNLP>>,
presolve_opts,
)));
let _ = e.borrow_mut().get_nlp_info();
if !json_dbg {
let h = e.borrow();
let r = h.report();
println!(
"Presolve linear-equality reduction: eliminated {} columns \
({} pinned, {} aggregated), dropped {} rows ({} redundant)",
h.n_eliminated_vars(),
r.n_constant_vars,
r.n_aggregated_vars,
h.n_eliminated_rows(),
r.n_redundant_rows,
);
}
Some(e)
}
_ => None,
};
let post_presolve: Rc<RefCell<dyn TNLP>> = match (&elim_handle, &presolve_handle) {
(Some(e), _) => Rc::clone(e) as Rc<RefCell<dyn TNLP>>,
(None, Some(p)) => Rc::clone(p) as Rc<RefCell<dyn TNLP>>,
(None, None) => Rc::clone(&inner_tnlp),
};
app.set_presolve_already_applied(true);
let counting = Rc::new(RefCell::new(CountingTnlp::new(Rc::clone(&post_presolve))));
let tnlp: Rc<RefCell<dyn TNLP>> = Rc::clone(&counting) as Rc<RefCell<dyn TNLP>>;
let diagnostics_handle = match build_diagnostics(
&args.dump_specs,
args.dump_dir.as_ref(),
args.dump_format.as_deref(),
) {
Ok(d) => d,
Err(msg) => {
eprintln!("pounce: {msg}");
return ExitCode::from(2);
}
};
if let Some(diag) = diagnostics_handle.as_ref() {
if !json_dbg {
println!(
"Diagnostics: dumping to {} ({} categor{} configured)",
diag.dump_dir().display(),
diag.config.categories.len(),
if diag.config.categories.len() == 1 {
"y"
} else {
"ies"
},
);
}
app.set_diagnostics(Rc::clone(diag));
}
let nlp_info_snapshot = tnlp.borrow_mut().get_nlp_info();
let mut solve_tnlp: Rc<RefCell<dyn TNLP>> = Rc::clone(&tnlp);
let mut status = loop {
let st = app.optimize_tnlp(Rc::clone(&solve_tnlp));
let req = restart_cell.borrow_mut().take();
let Some(req) = req else { break st };
for (k, v) in &req.options {
if let Err(e) = app.options_mut().read_from_str(&format!("{k} {v}\n"), true) {
eprintln!("pounce: re-solve could not set {k}={v}: {e}");
}
}
if let Some(snap) = req.warm {
let mu = snap.mu();
app.set_warm_start_iterate(snap);
let _ = app
.options_mut()
.read_from_str("warm_start_init_point yes\n", true);
if mu.is_finite() && mu > 0.0 {
let _ = app
.options_mut()
.read_from_str(&format!("warm_start_target_mu {mu}\n"), true);
}
}
solve_tnlp = Rc::new(RefCell::new(pounce_cli::seeded_tnlp::SeededTnlp::new(
Rc::clone(&tnlp),
req.seed_x,
)));
if let Some(hook) = debug_hook.as_ref() {
app.set_debug_hook(hook.clone());
}
eprintln!(
"pounce: re-solving from saved point with {} option override(s)…",
req.options.len()
);
};
let mut solve_stats = app.statistics();
let scaling_retry_enabled = app
.options()
.get_bool_value("feral_infeasibility_scaling_retry", "")
.map(|(v, _found)| v)
.unwrap_or(true);
let mu_retry_enabled = app
.options()
.get_bool_value("infeasibility_mu_strategy_retry", "")
.map(|(v, _found)| v)
.unwrap_or(true);
let already_mc64 = matches!(
pounce_algorithm::application::feral_config_from_options(app.options()).scaling,
pounce_feral::ScalingStrategy::Mc64Symmetric
);
let baseline_scaling =
match pounce_algorithm::application::feral_config_from_options(app.options()).scaling {
pounce_feral::ScalingStrategy::Auto => Some("auto"),
pounce_feral::ScalingStrategy::InfNorm => Some("infnorm"),
pounce_feral::ScalingStrategy::Mc64Symmetric => Some("mc64"),
pounce_feral::ScalingStrategy::Identity => Some("identity"),
pounce_feral::ScalingStrategy::External(_) => None,
};
let already_adaptive = app
.options()
.get_string_value("mu_strategy", "")
.map(|(v, _found)| v == "adaptive")
.unwrap_or(false);
let presolve_certified = presolve_handle
.as_ref()
.and_then(|p| p.borrow().certified_infeasible());
let rungs = second_opinion_rungs(SecondOpinionAvailability {
scaling_retry_enabled,
mu_retry_enabled,
already_mc64,
already_adaptive,
baseline_scaling,
});
if !rungs.is_empty()
&& debug_hook.is_none()
&& presolve_certified.is_none()
&& status == ApplicationReturnStatus::InfeasibleProblemDetected
{
eprintln!(
"pounce: local infeasibility — re-solving along {} different trajector{} before \
believing it (second-opinion ladder: {}).",
rungs.len(),
if rungs.len() == 1 { "y" } else { "ies" },
rungs.iter().map(|r| r.label).collect::<Vec<_>>().join(", "),
);
let mut retry_status = status;
let mut retry_stats = solve_stats.clone();
let mut tried: Vec<&'static str> = Vec::new();
for rung in &rungs {
eprintln!("pounce: second opinion — re-solving with {}…", rung.label);
for assignment in &rung.assignments {
let _ = app.options_mut().read_from_str(assignment, true);
}
let feral_cfg = pounce_algorithm::application::feral_config_from_options(app.options());
let bff_mint = move || -> InnerBackendFactoryFactory {
let feral_cfg = feral_cfg.clone();
Box::new(move || default_backend_factory(feral_cfg.clone()))
};
let resto_provider = make_default_restoration_factory_provider(
RestoAlgorithmBuilder::new(),
app.algorithm_builder_from_options(),
bff_mint,
);
app.set_restoration_factory_provider(resto_provider);
retry_status = app.optimize_tnlp(Rc::clone(&tnlp));
retry_stats = app.statistics();
tried.push(rung.label);
if scaling_retry_promoted(retry_status) {
eprintln!(
"pounce: {} re-solve recovered the problem — promoting ({retry_status:?}).",
rung.label
);
break;
}
eprintln!(
"pounce: {} re-solve did not recover ({retry_status:?}).",
rung.label
);
}
if !scaling_retry_promoted(retry_status) {
eprintln!(
"pounce: keeping the original local-infeasibility verdict; it survived {} \
independent re-solve(s) ({}).",
tried.len(),
tried.join(", "),
);
}
(status, solve_stats) =
resolve_scaling_retry_outcome(retry_status, solve_stats, retry_stats);
if !scaling_retry_promoted(retry_status)
&& app
.options()
.get_integer_value("print_level", "")
.map(|(v, _found)| v >= 1)
.unwrap_or(true)
{
println!();
println!("EXIT: {}", print::status_message(status));
println!();
println!(
"POUNCE {}: {}",
env!("CARGO_PKG_VERSION"),
print::status_message(status)
);
}
}
if !json_dbg
&& app
.options()
.get_integer_value("print_level", "")
.map(|(v, _found)| v >= 1)
.unwrap_or(true)
{
println!("Status: {}", status.upstream_name());
}
let counters = counting.borrow();
if json_dbg {
let ev = serde_json::json!({
"event": "terminated",
"status": format!("{status:?}"),
"status_message": print::status_message(status),
"iterations": solve_stats.iteration_count,
"objective": solve_stats.final_objective,
"evals": {
"obj": counters.n_obj.get(),
"obj_grad": counters.n_grad_f.get(),
"constr": counters.n_g.get(),
"constr_jac": counters.n_jac_g.get(),
"hess": counters.n_h.get(),
},
});
println!("{ev}");
}
drop(counters);
if nominal_capture.borrow().is_none() {
if let Some(xl) = counting.borrow().captured_solution() {
*nominal_capture.borrow_mut() = Some(xl);
}
}
let elim_reduced = elim_handle
.as_ref()
.map(|e| {
let h = e.borrow();
h.n_eliminated_vars() > 0 || h.n_eliminated_rows() > 0
})
.unwrap_or(false);
if let Some(p) = &presolve_handle {
let lifted = if p.borrow().n_dropped_rows() > 0 || elim_reduced {
p.borrow().finalized_full_solution()
} else {
None
};
if let Some((x_full, lam_full)) = lifted {
if let Some((x, lambda)) = nominal_capture.borrow_mut().as_mut() {
*lambda = lam_full;
if elim_reduced {
*x = x_full;
}
}
}
}
if let Some(d) = app.variable_scaling() {
if let Some((x, _lambda)) = nominal_capture.borrow_mut().as_mut() {
assert_eq!(
x.len(),
d.len(),
"scaling: captured {} variables but {} factors",
x.len(),
d.len()
);
for (xi, s) in x.iter_mut().zip(d.iter()) {
*xi /= s;
}
}
if let Some((z_l, z_u)) = bound_mult_capture.borrow_mut().as_mut() {
assert_eq!(
z_l.len(),
d.len(),
"scaling: captured {} bound multipliers but {} factors",
z_l.len(),
d.len()
);
assert_eq!(z_l.len(), z_u.len(), "z_L and z_U must be the same length");
for ((l, u), s) in z_l.iter_mut().zip(z_u.iter_mut()).zip(d.iter()) {
*l *= s;
*u *= s;
}
}
}
if elim_reduced {
if let Some(full) = elim_handle
.as_ref()
.and_then(|e| e.borrow().finalized_full_solution().cloned())
{
if let Some((z_l, z_u)) = bound_mult_capture.borrow_mut().as_mut() {
if z_l.len() != full.z_l.len() {
*z_l = full.z_l;
*z_u = full.z_u;
}
}
}
}
if let Some(rh) = red_hessian_capture.borrow().as_ref() {
sens::print_red_hessian_to_stderr(rh);
} else if args.compute_red_hessian {
eprintln!(
"pounce: --compute-red-hessian requested but the reduced Hessian \
was not produced (see warnings above)."
);
}
let mut sol_suffixes: Vec<nl_writer::SolSuffix> = Vec::new();
if let Some(xp) = sens_capture.borrow().clone() {
sol_suffixes.push(nl_writer::SolSuffix {
name: "sens_sol_state_1".to_string(),
target: nl_writer::SolSuffixTarget::Var,
values: nl_writer::SolSuffixValues::Real(xp),
});
}
if let Some((z_l_full, z_u_full)) = bound_mult_capture.borrow().clone() {
let z_u_neg: Vec<pounce_common::types::Number> = z_u_full.iter().map(|&z| -z).collect();
sol_suffixes.push(nl_writer::SolSuffix {
name: "ipopt_zL_out".to_string(),
target: nl_writer::SolSuffixTarget::Var,
values: nl_writer::SolSuffixValues::Real(z_l_full),
});
sol_suffixes.push(nl_writer::SolSuffix {
name: "ipopt_zU_out".to_string(),
target: nl_writer::SolSuffixTarget::Var,
values: nl_writer::SolSuffixValues::Real(z_u_neg),
});
}
if let Some(json_path) = &args.json_output {
let input = match &args.problem {
ProblemSource::Builtin(name) => InputDescriptor::Builtin { name: name.clone() },
ProblemSource::NlFile(p) => InputDescriptor::NlFile {
path: p.clone(),
size_bytes: std::fs::metadata(p).ok().map(|m| m.len()),
},
};
let mut builder = ReportBuilder::new(args.json_detail, input);
if let Some(info) = nlp_info_snapshot {
builder.problem.n_variables = info.n;
let n_dropped = presolve_handle
.as_ref()
.map(|p| p.borrow().n_dropped_rows())
.unwrap_or(0);
builder.problem.n_constraints = info.m + n_dropped;
builder.problem.n_objectives = 1; builder.problem.nnz_jac_g = Some(info.nnz_jac_g);
builder.problem.nnz_h_lag = Some(info.nnz_h_lag);
}
builder.solution.status = status;
builder.solution.solve_result_num = presolve_verdict(presolve_certified, status).1;
builder.solution.objective = solve_stats.final_objective;
if let Some((x, lambda)) = nominal_capture.borrow().clone() {
builder.solution.x = x;
builder.solution.lambda = lambda;
}
builder.ingest_stats(&solve_stats);
if let Some(linsol) = app.linear_solver_summary() {
builder.set_linear_solver_summary(linsol);
}
if matches!(args.json_detail, ReportDetail::Full) {
for s in &sol_suffixes {
builder
.solution
.suffixes
.push(sens::sol_suffix_to_report(s));
}
if let Some(rh) = red_hessian_capture.borrow().as_ref() {
builder.solution.suffixes.push(SolutionSuffix {
name: "_red_hessian".to_string(),
target: "problem".to_string(),
kind: "real".to_string(),
values: rh.hr.clone(),
int_values: Vec::new(),
});
builder.solution.suffixes.push(SolutionSuffix {
name: "_red_hessian_vars".to_string(),
target: "problem".to_string(),
kind: "int".to_string(),
values: Vec::new(),
int_values: rh.var_indices.iter().map(|&v| v as i32).collect(),
});
if let Some(w) = &rh.eigenvalues {
builder.solution.suffixes.push(SolutionSuffix {
name: "_red_hessian_eigenvalues".to_string(),
target: "problem".to_string(),
kind: "real".to_string(),
values: w.clone(),
int_values: Vec::new(),
});
}
if let Some(v) = &rh.eigenvectors {
builder.solution.suffixes.push(SolutionSuffix {
name: "_red_hessian_eigenvectors".to_string(),
target: "problem".to_string(),
kind: "real".to_string(),
values: v.clone(),
int_values: Vec::new(),
});
}
}
}
let report = builder.finish();
if let Err(e) = write_report_file(json_path, &report) {
eprintln!(
"pounce: failed to write JSON report to {}: {e}",
json_path.display()
);
} else {
eprintln!("pounce: wrote {}", json_path.display());
}
}
if let Some(sol_path) = &sol_path {
let (n, m_out) = nlp_info_snapshot
.as_ref()
.map(|i| (i.n as usize, i.m as usize))
.unwrap_or((0, 0));
let m = m_out
+ presolve_handle
.as_ref()
.map(|p| p.borrow().n_dropped_rows() as usize)
.unwrap_or(0);
let (x, lambda) = nominal_capture
.borrow()
.clone()
.unwrap_or_else(|| (vec![0.0; n], vec![0.0; m]));
let (message, srn) = presolve_verdict(presolve_certified, status);
let payload = nl_writer::SolutionFile {
message: &message,
x: &x,
mult_g: &lambda,
solve_result_num: srn,
suffixes: &sol_suffixes,
};
match nl_writer::write_sol_file_with_options(sol_path, &payload, &nl_ampl_options) {
Ok(_) => eprintln!("pounce: wrote {}", sol_path.display()),
Err(e) => eprintln!("pounce: failed to write {}: {e}", sol_path.display()),
}
}
if let Some(diag) = diagnostics_handle.as_ref() {
write_diagnostics_manifest(diag, &problem_desc, status);
write_diagnostics_timing(diag, &app);
}
nlp_exit_code(status, args.ampl)
}
fn nlp_exit_code(status: ApplicationReturnStatus, ampl: bool) -> ExitCode {
if nlp_solve_succeeded(status) || ampl {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn nlp_solve_succeeded(status: ApplicationReturnStatus) -> bool {
matches!(
status,
ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::SolvedToAcceptableLevel
)
}
fn build_debugger(
mode: pounce_cli::cli::DebugMode,
on_error: bool,
on_interrupt: bool,
script: Option<&std::path::Path>,
reg: Option<Rc<pounce_common::reg_options::RegisteredOptions>>,
cell: pounce_cli::debug_repl::RestartCell,
) -> pounce_cli::debug_repl::SolverDebugger {
use pounce_cli::debug_repl::SolverDebugger;
let dbg = if on_error {
SolverDebugger::on_error(mode, reg)
} else if on_interrupt {
SolverDebugger::on_interrupt(mode, reg)
} else {
SolverDebugger::new(mode, reg)
}
.with_restart(cell);
match script {
Some(p) => dbg.with_script(p.to_string_lossy().into_owned()),
None => dbg,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SecondOpinionRung {
label: &'static str,
assignments: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
struct SecondOpinionAvailability {
scaling_retry_enabled: bool,
mu_retry_enabled: bool,
already_mc64: bool,
already_adaptive: bool,
baseline_scaling: Option<&'static str>,
}
fn second_opinion_rungs(avail: SecondOpinionAvailability) -> Vec<SecondOpinionRung> {
let mut rungs = Vec::new();
if avail.scaling_retry_enabled && !avail.already_mc64 {
rungs.push(SecondOpinionRung {
label: "feral_scaling=mc64",
assignments: vec!["feral_scaling mc64\n".to_string()],
});
}
if let Some(baseline_scaling) = avail.baseline_scaling
&& avail.mu_retry_enabled
&& !avail.already_adaptive
{
rungs.push(SecondOpinionRung {
label: "mu_strategy=adaptive",
assignments: vec![
format!("feral_scaling {baseline_scaling}\n"),
"mu_strategy adaptive\n".to_string(),
],
});
}
rungs
}
fn scaling_retry_promoted(retry_status: ApplicationReturnStatus) -> bool {
matches!(
retry_status,
ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::SolvedToAcceptableLevel
)
}
fn resolve_scaling_retry_outcome(
retry_status: ApplicationReturnStatus,
original_stats: SolveStatistics,
retry_stats: SolveStatistics,
) -> (ApplicationReturnStatus, SolveStatistics) {
if scaling_retry_promoted(retry_status) {
(retry_status, retry_stats)
} else {
(
ApplicationReturnStatus::InfeasibleProblemDetected,
original_stats,
)
}
}
fn lp_declines_to_nlp(
class: pounce_cli::dispatch::ProblemClass,
status: pounce_convex::QpStatus,
allow_nlp_fallback: bool,
) -> bool {
use pounce_convex::QpStatus;
allow_nlp_fallback
&& class == pounce_cli::dispatch::ProblemClass::Lp
&& matches!(
status,
QpStatus::OptimalInaccurate | QpStatus::IterationLimit
)
}
fn max_iter_explicitly_set(app: &IpoptApplication) -> bool {
matches!(
app.options().get_integer_value("max_iter", ""),
Ok((_, true))
)
}
fn qp_status_to_ars(s: pounce_convex::QpStatus) -> ApplicationReturnStatus {
use pounce_convex::QpStatus;
match s {
QpStatus::Optimal => ApplicationReturnStatus::SolveSucceeded,
QpStatus::OptimalInaccurate => ApplicationReturnStatus::SolvedToAcceptableLevel,
QpStatus::PrimalInfeasible => ApplicationReturnStatus::InfeasibleProblemDetected,
QpStatus::DualInfeasible => ApplicationReturnStatus::DivergingIterates, QpStatus::IterationLimit => ApplicationReturnStatus::MaximumIterationsExceeded,
QpStatus::NumericalFailure => ApplicationReturnStatus::InternalError,
}
}
fn convex_status_report(s: pounce_convex::QpStatus) -> (&'static str, bool, i32) {
use pounce_convex::QpStatus;
match s {
QpStatus::Optimal => ("Optimal Solution Found.", true, 0),
QpStatus::OptimalInaccurate => {
("Solved to acceptable level (reduced accuracy).", true, 100)
}
QpStatus::PrimalInfeasible => ("Problem is primal infeasible.", false, 200),
QpStatus::DualInfeasible => ("Problem is unbounded (dual infeasible).", false, 300),
QpStatus::IterationLimit => ("Maximum iterations exceeded.", false, 400),
QpStatus::NumericalFailure => ("Numerical failure (no verified KKT point).", false, 500),
}
}
fn active_set_overrides(app: &IpoptApplication) -> pounce_convex::ActiveSetOverrides {
use pounce_qp::AntiCyclingChoice;
let mut o = pounce_convex::ActiveSetOverrides::default();
let opt = app.options();
if let Ok((v, true)) = opt.get_integer_value("sqp_qp_max_iter", "") {
if v >= 0 {
o.max_iter = Some(v as u32);
}
}
if let Ok((v, true)) = opt.get_string_value("sqp_qp_anti_cycling", "") {
o.anti_cycling = match v.as_str() {
"bland" => Some(AntiCyclingChoice::Bland),
"expand" => Some(AntiCyclingChoice::Expand),
"none" => Some(AntiCyclingChoice::None),
_ => None,
};
}
if let Ok((v, true)) = opt.get_numeric_value("sqp_qp_feas_tol", "") {
o.feas_tol = Some(v);
}
if let Ok((v, true)) = opt.get_numeric_value("sqp_qp_opt_tol", "") {
o.opt_tol = Some(v);
}
if let Ok((v, true)) = opt.get_numeric_value("sqp_qp_elastic_gamma", "") {
o.elastic_gamma = Some(v);
}
if let Ok((v, true)) = opt.get_string_value("sqp_qp_use_schur_updates", "") {
o.use_schur_updates = Some(v == "yes");
}
if let Ok((v, true)) = opt.get_string_value("sqp_qp_use_homotopy", "") {
o.use_homotopy = Some(v == "yes");
}
if let Ok((v, true)) = opt.get_integer_value("sqp_qp_max_schur_updates_before_refactor", "") {
if v >= 0 {
o.max_schur_updates_before_refactor = Some(v as u32);
}
}
o
}
fn convex_cli_opts(app: &IpoptApplication) -> pounce_convex::QpOptions {
let mut o = pounce_convex::QpOptions::default();
let opt = app.options();
if let Ok((v, true)) = opt.get_integer_value("max_iter", "") {
if v >= 0 {
o.max_iter = v as usize;
}
}
if let Ok((v, true)) = opt.get_numeric_value("tol", "") {
o.tol = v;
}
if let Ok((v, true)) = opt.get_numeric_value("qp_tau", "") {
o.tau = v;
o.tau_max = o.tau_max.max(v);
}
if let Ok((v, true)) = opt.get_numeric_value("qp_tau_max", "") {
o.tau_max = v;
}
if let Ok((v, true)) = opt.get_numeric_value("qp_reg", "") {
o.reg = v;
}
if let Ok((v, true)) = opt.get_numeric_value("qp_infeas_tol", "") {
o.infeas_tol = v;
}
if let Ok((v, true)) = opt.get_string_value("qp_hsde", "") {
o.use_hsde = v != "no";
}
if let Ok((v, true)) = opt.get_string_value("qp_equilibrate", "") {
o.equilibrate = v != "no";
}
if let Ok((v, true)) = opt.get_string_value("qp_crossover", "") {
o.crossover = v != "no";
}
o
}
fn resolve_convex_presolve(
qp_presolve: Option<(String, bool)>,
presolve: Option<(String, bool)>,
) -> bool {
match (qp_presolve, presolve) {
(Some((v, true)), _) => v != "no",
(_, Some((v, true))) => v != "no",
_ => true,
}
}
fn run_convex_qp(
prob: &nl_reader::NlProblem,
class: pounce_cli::dispatch::ProblemClass,
sol_path: Option<&std::path::Path>,
presolve_on: bool,
json_cfg: Option<(&std::path::Path, ReportDetail, InputDescriptor)>,
debug_hook: Option<&Rc<RefCell<pounce_cli::debug_repl::SolverDebugger>>>,
ampl: bool,
convex_opts: pounce_convex::QpOptions,
use_active_set: bool,
engine_overrides: pounce_convex::ActiveSetOverrides,
allow_nlp_fallback: bool,
) -> Option<ExitCode> {
use pounce_convex::active_set::solve_qp_active_set;
use pounce_convex::presolve::{FixpointExit, PresolveOutcome, presolve};
use pounce_convex::{QpOptions, QpStatus, solve_qp_ipm, solve_qp_ipm_debug};
let (qp, con_map, obj_nl_const) = match pounce_cli::qp_extract::extract_qp_with_map(prob) {
Some(q) => q,
None => {
eprintln!(
"pounce: internal error: {} not extractable as QP",
class.name()
);
return Some(ExitCode::from(2));
}
};
let obj_const = prob.obj_constant + obj_nl_const;
let sign = if prob.minimize { 1.0 } else { -1.0 };
let backend = || -> Box<dyn SparseSymLinearSolverInterface> {
Box::new(pounce_feral::FeralSolverInterface::new())
};
let t0 = std::time::Instant::now();
let trivial = |status| pounce_convex::QpSolution {
status,
x: vec![0.0; qp.n],
y: vec![0.0; qp.m_eq()],
z: vec![0.0; qp.m_ineq()],
z_lb: vec![0.0; qp.n],
z_ub: vec![0.0; qp.n],
obj: 0.0,
iters: 0,
iterates: Vec::new(),
};
let want_trace = matches!(&json_cfg, Some((_, ReportDetail::Full, _)));
let qp_opts = QpOptions {
collect_iterates: want_trace,
..convex_opts
};
let mut presolve_log: Vec<String> = Vec::new();
let sol = if qp_opts.max_iter == 0 {
trivial(QpStatus::IterationLimit)
} else if let Some(hook) = debug_hook.filter(|_| !use_active_set) {
let mut h = hook.borrow_mut();
solve_qp_ipm_debug(&qp, &qp_opts, &mut *h, backend)
} else if presolve_on {
match presolve(&qp) {
PresolveOutcome::Reduced(ps) => {
if let Some(trigger) = ps.discarded_infeasibility() {
presolve_log.push(format!(
"Presolve: discarded an unconfirmed infeasibility claim — \
{trigger}; solving normally"
));
}
let st = ps.stats();
if st.reduced_anything() {
let exit = match st.exit {
FixpointExit::Fixpoint => String::new(),
FixpointExit::RoundCap => {
format!(", cap-truncated after {} layers", st.rounds)
}
};
presolve_log.push(format!(
"Presolve: {} → {} vars, {} → {} rows (fixed {}, \
free-fixed {}, substituted {}, aggregated {}, \
forcing {}, dominated {}, tightened {}{})",
st.orig_vars,
st.reduced_vars,
st.orig_rows,
st.reduced_rows,
st.fixed_vars,
st.free_cols_fixed,
st.free_col_singletons,
st.aggregated_vars,
st.forcing_rows,
st.dominated_cols,
st.tightened_bounds,
exit,
));
}
let red = if use_active_set {
let mut mk = backend;
solve_qp_active_set(&ps.reduced, &qp_opts, &engine_overrides, &mut mk)
} else {
solve_qp_ipm(&ps.reduced, &qp_opts, backend)
};
ps.postsolve(&red)
}
PresolveOutcome::Infeasible(trigger) => {
presolve_log.push(format!("Presolve: proved primal infeasible — {trigger}"));
trivial(QpStatus::PrimalInfeasible)
}
PresolveOutcome::Unbounded => trivial(QpStatus::DualInfeasible),
}
} else if use_active_set {
let mut mk = backend;
solve_qp_active_set(&qp, &qp_opts, &engine_overrides, &mut mk)
} else {
solve_qp_ipm(&qp, &qp_opts, backend)
};
let elapsed = t0.elapsed().as_secs_f64();
if lp_declines_to_nlp(class, sol.status, allow_nlp_fallback) {
let res = sol.kkt_residuals(&qp);
eprintln!(
"pounce: note: the convex ({}) solve did not certify a KKT point \
after {} iterations in {elapsed:.3}s (KKT error {:.2e} against \
tol {:.1e}); an LP is also a valid NLP, so it is being re-solved \
on the general NLP interior-point path, which certifies the \
degenerate, rank-deficient LPs the interior path stalls on (gh \
#133). Use solver_selection=qp-ipm to see the convex result \
instead.",
class.name(),
sol.iters,
res.kkt_error(),
qp_opts.tol,
);
return None;
}
for line in &presolve_log {
println!("{line}");
}
let reported_obj = sign * sol.obj + obj_const;
let (msg, ok, srn) = convex_status_report(sol.status);
let engine = if use_active_set {
"active-set, pounce-qp"
} else {
"IPM, pounce-convex"
};
println!(
"POUNCE ({} {engine}): {msg} obj={reported_obj:.8} iters={} ({elapsed:.3}s)",
class.name(),
sol.iters,
);
if let Some(warn) = sol.scaling_diagnostic(&qp) {
eprintln!("pounce: {warn}");
}
let res = sol.kkt_residuals(&qp);
print::print_convex_summary(
sol.iters,
reported_obj,
res.primal_infeasibility,
res.dual_infeasibility,
res.complementarity,
res.kkt_error(),
);
let lambda = pounce_cli::qp_extract::recover_duals(prob, &con_map, &sol.y, &sol.z);
let (z_lb_raw, z_ub_raw) = pounce_cli::qp_extract::recover_bound_mults(prob, &sol);
let z_l_suffix: Vec<f64> = z_lb_raw.iter().map(|&z| sign * z).collect();
let z_u_suffix: Vec<f64> = z_ub_raw.iter().map(|&z| -sign * z).collect();
let qp_bound_suffixes = [
nl_writer::SolSuffix {
name: "ipopt_zL_out".to_string(),
target: nl_writer::SolSuffixTarget::Var,
values: nl_writer::SolSuffixValues::Real(z_l_suffix),
},
nl_writer::SolSuffix {
name: "ipopt_zU_out".to_string(),
target: nl_writer::SolSuffixTarget::Var,
values: nl_writer::SolSuffixValues::Real(z_u_suffix),
},
];
if let Some(path) = sol_path {
let payload = nl_writer::SolutionFile {
message: &format!("POUNCE {} IPM (pounce-convex): {msg}", class.name()),
x: &sol.x,
mult_g: &lambda,
solve_result_num: srn,
suffixes: &qp_bound_suffixes,
};
if let Err(e) = nl_writer::write_sol_file_with_options(path, &payload, &prob.ampl_options) {
eprintln!("pounce: failed to write {}: {e}", path.display());
}
}
if let Some((json_path, detail, input)) = json_cfg {
let mut builder = ReportBuilder::new(detail, input);
builder.problem.n_variables = qp.n as _;
builder.problem.n_constraints = lambda.len() as _;
builder.problem.n_objectives = 1;
builder.problem.minimize = prob.minimize;
builder.solution.status = qp_status_to_ars(sol.status);
builder.solution.solve_result_num = srn;
builder.solution.objective = reported_obj;
builder.solution.x = sol.x.clone();
builder.solution.lambda = lambda.clone();
builder.stats.iteration_count = sol.iters as _;
builder.stats.final_objective = reported_obj;
builder.stats.total_wallclock_time_secs = elapsed;
builder.stats.final_constr_viol = res.primal_infeasibility;
builder.stats.final_dual_inf = res.dual_infeasibility;
builder.stats.final_compl = res.complementarity;
builder.stats.final_kkt_error = res.kkt_error();
if matches!(detail, ReportDetail::Full) {
builder.iterations = sol
.iterates
.iter()
.map(|it| IterRecord {
iter: it.iter as _,
objective: it.objective,
inf_pr: it.primal_infeasibility,
inf_du: it.dual_infeasibility,
mu: it.mu,
alpha_primal: it.alpha_primal,
alpha_dual: it.alpha_dual,
..IterRecord::default()
})
.collect();
}
let report = builder.finish();
if let Err(e) = write_report_file(json_path, &report) {
eprintln!(
"pounce: failed to write JSON report to {}: {e}",
json_path.display()
);
} else {
eprintln!("pounce: wrote {}", json_path.display());
}
}
Some(convex_exit_code(ok, ampl))
}
fn run_convex_socp(
prob: &nl_reader::NlProblem,
class: pounce_cli::dispatch::ProblemClass,
sol_path: Option<&std::path::Path>,
json_cfg: Option<(&std::path::Path, ReportDetail, InputDescriptor)>,
debug_hook: Option<&Rc<RefCell<pounce_cli::debug_repl::SolverDebugger>>>,
ampl: bool,
convex_opts: pounce_convex::QpOptions,
allow_nlp_fallback: bool,
) -> Option<ExitCode> {
use pounce_convex::{QpOptions, solve_socp_ipm, solve_socp_ipm_debug};
let (qp, con_map, obj_nl_const, cones) =
match pounce_cli::qp_extract::extract_socp_with_map(prob) {
Some(q) => q,
None => {
eprintln!(
"pounce: internal error: {} not extractable as SOCP",
class.name()
);
return Some(ExitCode::from(2));
}
};
let obj_const = prob.obj_constant + obj_nl_const;
let sign = if prob.minimize { 1.0 } else { -1.0 };
let backend = || -> Box<dyn SparseSymLinearSolverInterface> {
Box::new(pounce_feral::FeralSolverInterface::new())
};
let want_trace = matches!(&json_cfg, Some((_, ReportDetail::Full, _)));
let qp_opts = QpOptions {
collect_iterates: want_trace,
..convex_opts
};
let t0 = std::time::Instant::now();
let sol = if qp_opts.max_iter == 0 {
pounce_convex::QpSolution {
status: pounce_convex::QpStatus::IterationLimit,
x: vec![0.0; qp.n],
y: vec![0.0; qp.m_eq()],
z: vec![0.0; qp.m_ineq()],
z_lb: vec![0.0; qp.n],
z_ub: vec![0.0; qp.n],
obj: 0.0,
iters: 0,
iterates: Vec::new(),
}
} else if let Some(hook) = debug_hook {
let mut h = hook.borrow_mut();
solve_socp_ipm_debug(&qp, &cones, &qp_opts, &mut *h, backend)
} else {
solve_socp_ipm(&qp, &cones, &qp_opts, backend)
};
let elapsed = t0.elapsed().as_secs_f64();
if allow_nlp_fallback && matches!(sol.status, pounce_convex::QpStatus::NumericalFailure) {
let res = sol.kkt_residuals_conic(&qp, &cones);
eprintln!(
"pounce: note: the conic ({}) solve returned no verified KKT point \
after {} iterations (KKT error {:.2e}); a convex QCQP is also a \
valid NLP, so it is being re-solved on the general NLP \
interior-point path. Use solver_selection=socp to see the conic \
result instead.",
class.name(),
sol.iters,
res.kkt_error(),
);
return None;
}
let reported_obj = sign * sol.obj + obj_const;
let (msg, ok, srn) = convex_status_report(sol.status);
println!(
"POUNCE ({} conic IPM, pounce-convex): {msg} obj={reported_obj:.8} iters={} ({elapsed:.3}s)",
class.name(),
sol.iters,
);
let res = sol.kkt_residuals_conic(&qp, &cones);
print::print_convex_summary(
sol.iters,
reported_obj,
res.primal_infeasibility,
res.dual_infeasibility,
res.complementarity,
res.kkt_error(),
);
let lambda = pounce_cli::qp_extract::recover_socp_duals(prob, &con_map, &sol.y, &sol.z);
let (z_lb_raw, z_ub_raw) = pounce_cli::qp_extract::recover_bound_mults(prob, &sol);
let z_l_suffix: Vec<f64> = z_lb_raw.iter().map(|&z| sign * z).collect();
let z_u_suffix: Vec<f64> = z_ub_raw.iter().map(|&z| -sign * z).collect();
let socp_bound_suffixes = [
nl_writer::SolSuffix {
name: "ipopt_zL_out".to_string(),
target: nl_writer::SolSuffixTarget::Var,
values: nl_writer::SolSuffixValues::Real(z_l_suffix),
},
nl_writer::SolSuffix {
name: "ipopt_zU_out".to_string(),
target: nl_writer::SolSuffixTarget::Var,
values: nl_writer::SolSuffixValues::Real(z_u_suffix),
},
];
if let Some(path) = sol_path {
let payload = nl_writer::SolutionFile {
message: &format!("POUNCE {} conic IPM (pounce-convex): {msg}", class.name()),
x: &sol.x,
mult_g: &lambda,
solve_result_num: srn,
suffixes: &socp_bound_suffixes,
};
if let Err(e) = nl_writer::write_sol_file(path, &payload) {
eprintln!("pounce: failed to write {}: {e}", path.display());
}
}
if let Some((json_path, detail, input)) = json_cfg {
let mut builder = ReportBuilder::new(detail, input);
builder.problem.n_variables = qp.n as _;
builder.problem.n_constraints = lambda.len() as _;
builder.problem.n_objectives = 1;
builder.problem.minimize = prob.minimize;
builder.solution.status = qp_status_to_ars(sol.status);
builder.solution.solve_result_num = srn;
builder.solution.objective = reported_obj;
builder.solution.x = sol.x.clone();
builder.solution.lambda = lambda.clone();
builder.stats.iteration_count = sol.iters as _;
builder.stats.final_objective = reported_obj;
builder.stats.total_wallclock_time_secs = elapsed;
builder.stats.final_constr_viol = res.primal_infeasibility;
builder.stats.final_dual_inf = res.dual_infeasibility;
builder.stats.final_compl = res.complementarity;
builder.stats.final_kkt_error = res.kkt_error();
if matches!(detail, ReportDetail::Full) {
builder.iterations = sol
.iterates
.iter()
.map(|it| IterRecord {
iter: it.iter as _,
objective: it.objective,
inf_pr: it.primal_infeasibility,
inf_du: it.dual_infeasibility,
mu: it.mu,
alpha_primal: it.alpha_primal,
alpha_dual: it.alpha_dual,
..IterRecord::default()
})
.collect();
}
let report = builder.finish();
if let Err(e) = write_report_file(json_path, &report) {
eprintln!(
"pounce: failed to write JSON report to {}: {e}",
json_path.display()
);
} else {
eprintln!("pounce: wrote {}", json_path.display());
}
}
Some(convex_exit_code(ok, ampl))
}
fn convex_exit_code(ok: bool, ampl: bool) -> ExitCode {
if ok || ampl {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn build_diagnostics(
dump_specs: &[(String, String)],
dump_dir: Option<&std::path::PathBuf>,
dump_format: Option<&str>,
) -> Result<Option<Rc<DiagnosticsState>>, String> {
if dump_specs.is_empty() {
if dump_dir.is_some() || dump_format.is_some() {
return Err(
"--dump-dir / --dump-format require at least one --dump <cat>[:spec]".to_string(),
);
}
return Ok(None);
}
let dump_dir = dump_dir.cloned().unwrap_or_else(|| {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
std::path::PathBuf::from(format!("pounce-dump-{secs}"))
});
let format = match dump_format {
Some(f) => DumpFormat::parse(f)?,
None => DumpFormat::Jsonl,
};
let mut config = DiagnosticsConfig::new(dump_dir);
config.format = format;
for (cat_str, spec_str) in dump_specs {
let cat = DiagCategory::parse(cat_str)?;
if cat == DiagCategory::Iterate {
let (filter, variant) = pounce_common::diagnostics::parse_iterate_spec(spec_str)?;
config = config
.with_category(cat, filter)
.with_iterate_variant(variant);
} else if cat == DiagCategory::Kkt {
let (filter, variant) = pounce_common::diagnostics::parse_kkt_spec(spec_str)?;
config = config.with_category(cat, filter).with_kkt_variant(variant);
} else {
let spec = IterSpec::parse(spec_str)?;
config = config.with_category(cat, spec);
}
}
let state = DiagnosticsState::new(config)
.map_err(|e| format!("could not create dump directory: {e}"))?;
Ok(Some(Rc::new(state)))
}
fn write_diagnostics_manifest(
diag: &DiagnosticsState,
problem_desc: &str,
status: ApplicationReturnStatus,
) {
let mut cats: Vec<String> = diag
.config
.categories
.iter()
.map(|(c, s)| format!("\"{}\":\"{:?}\"", c.as_str(), s))
.collect();
cats.sort();
let manifest = format!(
"{{\n \"pounce_version\": \"{ver}\",\n \"git\": \"{git}\",\n \"problem\": \"{problem}\",\n \"status\": \"{status:?}\",\n \"format\": \"{fmt:?}\",\n \"categories\": {{ {cats} }}\n}}\n",
ver = env!("CARGO_PKG_VERSION"),
git = env!("POUNCE_BUILD_GIT"),
problem = problem_desc,
fmt = diag.config.format,
cats = cats.join(", "),
);
let _ = diag.write_top_level("manifest.json", &manifest);
}
fn write_diagnostics_timing(diag: &DiagnosticsState, app: &IpoptApplication) {
let t = app.timing_stats();
let body = format!(
"{{\n \"overall_alg_secs\": {a:.6},\n \"linear_system_factorization_secs\": {f:.6},\n \"linear_system_back_solve_secs\": {b:.6}\n}}\n",
a = t.overall_alg.total_wallclock_time(),
f = t.linear_system_factorization.total_wallclock_time(),
b = t.linear_system_back_solve.total_wallclock_time(),
);
let _ = diag.write_top_level("timing.json", &body);
}
fn run_cite(args: &Args) -> ExitCode {
let report = match &args.cite_report {
Some(path) => {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!("pounce: failed to read {}: {e}", path.display());
return ExitCode::from(2);
}
};
match serde_json::from_str::<pounce_cli::solve_report::SolveReport>(&text) {
Ok(r) => Some(r),
Err(e) => {
eprintln!(
"pounce: {} is not a valid solve report: {e}",
path.display()
);
if path.extension().and_then(|e| e.to_str()) == Some("nl") {
eprintln!(
"pounce: --cite expects a solve-report JSON, not a model file. \
Run `pounce {} --json-output report.json` first, then \
`pounce --cite report.json` — or use bare `pounce --cite` for the core citations.",
path.display()
);
}
return ExitCode::from(2);
}
}
}
None => None,
};
let selected = pounce_cli::citations::select(report.as_ref());
if args.cite_bibtex {
print!("{}", pounce_cli::citations::render_bibtex(&selected));
} else {
print!("{}", pounce_cli::citations::render_human(&selected));
}
ExitCode::SUCCESS
}
fn print_about() {
let pkg_ver = env!("CARGO_PKG_VERSION");
let git = env!("POUNCE_BUILD_GIT");
let when = env!("POUNCE_BUILD_TIME");
let profile = env!("POUNCE_BUILD_PROFILE");
let target = env!("POUNCE_BUILD_TARGET");
let host = env!("POUNCE_BUILD_HOST");
let rustc = env!("POUNCE_BUILD_RUSTC");
println!("pounce {pkg_ver} (commit {git}, built {when})");
println!();
println!("Build:");
println!(" profile: {profile}");
println!(" target: {target}");
if host != target {
println!(" host: {host}");
}
println!(" rustc: {rustc}");
println!();
println!("Features:");
#[cfg(feature = "ma57")]
println!(" ma57: enabled");
#[cfg(not(feature = "ma57"))]
println!(" ma57: disabled (rebuild with --features ma57 to enable HSL MA57)");
println!();
println!("Linear solvers:");
println!(" feral FERAL pure-Rust sparse LDL^T (always built-in)");
#[cfg(feature = "ma57")]
println!(" ma57 HSL MA57 via libcoinhsl (compiled in)");
#[cfg(not(feature = "ma57"))]
println!(
" ma57 HSL MA57 via libcoinhsl (not compiled; resolves to FERAL at runtime)"
);
println!();
println!("Runtime paths:");
match std::env::current_exe() {
Ok(p) => println!(" executable: {}", p.display()),
Err(e) => println!(" executable: <unknown: {e}>"),
}
match std::env::current_dir() {
Ok(p) => println!(" cwd: {}", p.display()),
Err(e) => println!(" cwd: <unknown: {e}>"),
}
println!();
println!("Report bugs at {}/issues", env!("CARGO_PKG_REPOSITORY"));
}
fn default_backend_factory(feral_cfg: pounce_feral::FeralConfig) -> LinearBackendFactory {
Box::new(
move |choice: LinearSolverChoice| -> Box<dyn SparseSymLinearSolverInterface> {
match choice {
LinearSolverChoice::Feral => Box::new(
pounce_feral::FeralSolverInterface::with_config(feral_cfg.clone()),
),
LinearSolverChoice::Ma57 => {
#[cfg(feature = "ma57")]
{
Box::new(pounce_hsl::Ma57SolverInterface::new())
}
#[cfg(not(feature = "ma57"))]
{
Box::new(pounce_feral::FeralSolverInterface::with_config(
feral_cfg.clone(),
))
}
}
}
},
)
}
#[cfg(test)]
mod convex_status_tests {
use super::{convex_status_report, qp_status_to_ars};
use pounce_convex::QpStatus;
use pounce_nlp::return_codes::ApplicationReturnStatus;
#[test]
fn optimal_inaccurate_is_distinct_from_optimal() {
let (msg, ok, srn) = convex_status_report(QpStatus::OptimalInaccurate);
assert_eq!(srn, 100, "reduced-accuracy solve must use the 100 band");
assert!(ok, "a reduced-accuracy solve is still a usable success");
assert!(
msg.contains("acceptable"),
"message should signal reduced accuracy, got {msg:?}"
);
let (opt_msg, _, opt_srn) = convex_status_report(QpStatus::Optimal);
assert_eq!(opt_srn, 0);
assert_ne!(
srn, opt_srn,
"OptimalInaccurate must not share Optimal's solve_result_num"
);
assert_ne!(msg, opt_msg, "the two must read differently to the user");
assert_eq!(
qp_status_to_ars(QpStatus::OptimalInaccurate),
ApplicationReturnStatus::SolvedToAcceptableLevel
);
assert_eq!(
qp_status_to_ars(QpStatus::Optimal),
ApplicationReturnStatus::SolveSucceeded
);
}
}
#[cfg(test)]
mod lp_nlp_fallback_tests {
use super::lp_declines_to_nlp;
use pounce_cli::dispatch::ProblemClass;
use pounce_convex::QpStatus;
const ALL_STATUSES: [QpStatus; 6] = [
QpStatus::Optimal,
QpStatus::OptimalInaccurate,
QpStatus::PrimalInfeasible,
QpStatus::DualInfeasible,
QpStatus::IterationLimit,
QpStatus::NumericalFailure,
];
#[test]
fn an_uncertified_lp_is_handed_to_the_nlp_path() {
for status in [QpStatus::OptimalInaccurate, QpStatus::IterationLimit] {
assert!(
lp_declines_to_nlp(ProblemClass::Lp, status, true),
"{status:?} on an LP must reroute"
);
}
}
#[test]
fn a_certified_lp_is_never_rerouted() {
assert!(!lp_declines_to_nlp(
ProblemClass::Lp,
QpStatus::Optimal,
true
));
}
#[test]
fn verified_verdicts_and_numerical_failure_stand() {
for status in [
QpStatus::PrimalInfeasible,
QpStatus::DualInfeasible,
QpStatus::NumericalFailure,
] {
assert!(
!lp_declines_to_nlp(ProblemClass::Lp, status, true),
"{status:?} must not reroute"
);
}
}
#[test]
fn only_the_lp_class_reroutes() {
for class in [
ProblemClass::ConvexQp,
ProblemClass::ConvexQcqp,
ProblemClass::NonconvexQp,
ProblemClass::Nlp,
] {
for status in ALL_STATUSES {
assert!(
!lp_declines_to_nlp(class, status, true),
"{class:?}/{status:?} must not reroute"
);
}
}
}
#[test]
fn the_callers_gate_suppresses_every_case() {
for class in [ProblemClass::Lp, ProblemClass::ConvexQp] {
for status in ALL_STATUSES {
assert!(
!lp_declines_to_nlp(class, status, false),
"{class:?}/{status:?} must not reroute when the caller declines"
);
}
}
}
}
#[cfg(test)]
mod scaling_retry_tests {
use super::{
SecondOpinionAvailability, resolve_scaling_retry_outcome, scaling_retry_promoted,
second_opinion_rungs,
};
use pounce_nlp::SolveStatistics;
use pounce_nlp::return_codes::ApplicationReturnStatus;
fn avail() -> SecondOpinionAvailability {
SecondOpinionAvailability {
scaling_retry_enabled: true,
mu_retry_enabled: true,
already_mc64: false,
already_adaptive: false,
baseline_scaling: Some("auto"),
}
}
#[test]
fn default_ladder_is_scaling_then_barrier_strategy() {
let rungs = second_opinion_rungs(avail());
let labels: Vec<_> = rungs.iter().map(|r| r.label).collect();
assert_eq!(labels, ["feral_scaling=mc64", "mu_strategy=adaptive"]);
}
#[test]
fn barrier_rung_restores_the_baseline_scaling() {
for baseline in ["auto", "infnorm"] {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
baseline_scaling: Some(baseline),
..avail()
});
let barrier = rungs
.iter()
.find(|r| r.label == "mu_strategy=adaptive")
.expect("barrier rung present");
assert!(
barrier
.assignments
.iter()
.any(|a| a.trim() == format!("feral_scaling {baseline}")),
"barrier rung must reset the scaling to the baseline {baseline}, \
got {:?}",
barrier.assignments,
);
}
}
#[test]
fn rungs_already_satisfied_at_baseline_are_dropped() {
let only_barrier = second_opinion_rungs(SecondOpinionAvailability {
already_mc64: true,
..avail()
});
assert_eq!(
only_barrier.iter().map(|r| r.label).collect::<Vec<_>>(),
["mu_strategy=adaptive"],
);
let only_scaling = second_opinion_rungs(SecondOpinionAvailability {
already_adaptive: true,
..avail()
});
assert_eq!(
only_scaling.iter().map(|r| r.label).collect::<Vec<_>>(),
["feral_scaling=mc64"],
);
assert!(
second_opinion_rungs(SecondOpinionAvailability {
already_mc64: true,
already_adaptive: true,
..avail()
})
.is_empty(),
"nothing left to vary means no ladder at all",
);
}
#[test]
fn barrier_rung_is_dropped_when_the_baseline_scaling_has_no_tag() {
let rungs = second_opinion_rungs(SecondOpinionAvailability {
baseline_scaling: None,
..avail()
});
assert_eq!(
rungs.iter().map(|r| r.label).collect::<Vec<_>>(),
["feral_scaling=mc64"],
);
}
#[test]
fn each_rung_can_be_disabled_independently() {
assert_eq!(
second_opinion_rungs(SecondOpinionAvailability {
scaling_retry_enabled: false,
..avail()
})
.iter()
.map(|r| r.label)
.collect::<Vec<_>>(),
["mu_strategy=adaptive"],
);
assert_eq!(
second_opinion_rungs(SecondOpinionAvailability {
mu_retry_enabled: false,
..avail()
})
.iter()
.map(|r| r.label)
.collect::<Vec<_>>(),
["feral_scaling=mc64"],
);
assert!(
second_opinion_rungs(SecondOpinionAvailability {
scaling_retry_enabled: false,
mu_retry_enabled: false,
..avail()
})
.is_empty(),
);
}
fn stats_with_iters(n: i32) -> SolveStatistics {
SolveStatistics {
iteration_count: n,
final_objective: n as f64,
..SolveStatistics::default()
}
}
#[test]
fn failed_retry_keeps_original_status_and_stats() {
let original = stats_with_iters(7);
let retry = stats_with_iters(42);
for retry_status in [
ApplicationReturnStatus::InfeasibleProblemDetected,
ApplicationReturnStatus::MaximumIterationsExceeded,
ApplicationReturnStatus::RestorationFailed,
] {
assert!(!scaling_retry_promoted(retry_status));
let (status, stats) =
resolve_scaling_retry_outcome(retry_status, original.clone(), retry.clone());
assert_eq!(
status,
ApplicationReturnStatus::InfeasibleProblemDetected,
"a non-promoting retry ({retry_status:?}) keeps the original verdict"
);
assert_eq!(
stats.iteration_count, 7,
"stats must stay the original solve's, not the failed retry's"
);
assert_eq!(stats.final_objective, 7.0);
}
}
#[test]
fn promoted_retry_adopts_retry_status_and_stats() {
let original = stats_with_iters(7);
let retry = stats_with_iters(42);
for retry_status in [
ApplicationReturnStatus::SolveSucceeded,
ApplicationReturnStatus::SolvedToAcceptableLevel,
] {
assert!(scaling_retry_promoted(retry_status));
let (status, stats) =
resolve_scaling_retry_outcome(retry_status, original.clone(), retry.clone());
assert_eq!(status, retry_status, "a promoting retry adopts its verdict");
assert_eq!(
stats.iteration_count, 42,
"promoted: stats must be the retry solve's"
);
assert_eq!(stats.final_objective, 42.0);
}
}
}
#[cfg(test)]
mod nlp_exit_code_tests {
use super::nlp_solve_succeeded;
use pounce_nlp::return_codes::ApplicationReturnStatus as A;
#[test]
fn acceptable_level_counts_as_success() {
assert!(nlp_solve_succeeded(A::SolvedToAcceptableLevel));
assert!(nlp_solve_succeeded(A::SolveSucceeded));
}
#[test]
fn non_convergent_statuses_are_not_success() {
for s in [
A::InfeasibleProblemDetected,
A::MaximumIterationsExceeded,
A::RestorationFailed,
A::DivergingIterates,
A::MaximumCpuTimeExceeded,
A::InternalError,
] {
assert!(
!nlp_solve_succeeded(s),
"{s:?} must not count as a successful solve"
);
}
}
}
#[cfg(test)]
mod convex_presolve_tests {
use super::resolve_convex_presolve;
fn set(v: &str) -> Option<(String, bool)> {
Some((v.to_string(), true))
}
fn unset(v: &str) -> Option<(String, bool)> {
Some((v.to_string(), false))
}
#[test]
fn defaults_on_when_nothing_set() {
assert!(resolve_convex_presolve(None, None));
assert!(resolve_convex_presolve(unset("yes"), unset("yes")));
}
#[test]
fn explicit_presolve_is_honored() {
assert!(!resolve_convex_presolve(unset("yes"), set("no")));
assert!(resolve_convex_presolve(unset("yes"), set("yes")));
}
#[test]
fn explicit_qp_presolve_is_honored() {
assert!(!resolve_convex_presolve(set("no"), None));
assert!(resolve_convex_presolve(set("yes"), None));
}
#[test]
fn qp_presolve_wins_when_both_explicit() {
assert!(!resolve_convex_presolve(set("no"), set("yes")));
assert!(resolve_convex_presolve(set("yes"), set("no")));
}
#[test]
fn explicit_presolve_overrides_unset_qp_presolve() {
assert!(!resolve_convex_presolve(unset("yes"), set("no")));
}
}