pounce-algorithm 0.10.0

Algorithm-side core for POUNCE (port of Ipopt's src/Algorithm/): IteratesVector, IpoptData, CalculatedQuantities, KKT solvers, line search, mu update, conv check, initializer, IpoptAlg main loop, AlgBuilder.
Documentation
//! Public-library presolve integration coverage. These tests configure
//! `IpoptApplication` only.

use pounce_algorithm::application::IpoptApplication;
use pounce_common::types::Number;
use pounce_nlp::SolverReturn;
use pounce_nlp::return_codes::ApplicationReturnStatus;
use pounce_nlp::tnlp::{
    BoundsInfo, IndexStyle, IpoptCq, IpoptData, Linearity, NlpInfo, Solution, SparsityRequest,
    StartingPoint, TNLP,
};
use pounce_presolve::{PresolveOptions, wrap_from_options, wrap_with_presolve};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug, Clone)]
struct FinalPayload {
    status: SolverReturn,
    x: Vec<Number>,
    z_l: Vec<Number>,
    g: Vec<Number>,
    lambda: Vec<Number>,
    obj: Number,
}

/// `x >= 2` tightens the original `x >= 0` lower bound, after which that
/// linear row is redundant.
#[derive(Default)]
struct TightenedRow {
    final_payload: Option<FinalPayload>,
    reject_warm_start: bool,
}

impl TNLP for TightenedRow {
    fn get_nlp_info(&mut self) -> Option<NlpInfo> {
        Some(NlpInfo {
            n: 1,
            m: 2,
            nnz_jac_g: 2,
            nnz_h_lag: 1,
            index_style: IndexStyle::C,
        })
    }

    fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
        b.x_l[0] = 0.0;
        b.x_u[0] = 10.0;
        b.g_l.copy_from_slice(&[2.0, -1.0e19]);
        b.g_u.copy_from_slice(&[1.0e19, 10.0]);
        true
    }

    fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
        if self.reject_warm_start && sp.init_z && sp.init_lambda {
            return false;
        }
        sp.x[0] = 3.0;
        if sp.init_z {
            sp.z_l[0] = 3.0;
            sp.z_u[0] = 0.0;
        }
        if sp.init_lambda {
            sp.lambda.copy_from_slice(&[7.0, 11.0]);
        }
        true
    }

    fn get_constraints_linearity(&mut self, types: &mut [Linearity]) -> bool {
        types[0] = Linearity::Linear;
        types[1] = Linearity::NonLinear;
        true
    }

    fn eval_f(&mut self, x: &[Number], _new_x: bool) -> Option<Number> {
        Some((x[0] - 1.0).powi(2))
    }

    fn eval_grad_f(&mut self, x: &[Number], _new_x: bool, grad: &mut [Number]) -> bool {
        grad[0] = 2.0 * (x[0] - 1.0);
        true
    }

    fn eval_g(&mut self, x: &[Number], _new_x: bool, g: &mut [Number]) -> bool {
        g.copy_from_slice(&[x[0], (x[0] - 2.0).powi(2)]);
        true
    }

    fn eval_jac_g(
        &mut self,
        x: Option<&[Number]>,
        _new_x: bool,
        mode: SparsityRequest<'_>,
    ) -> bool {
        match mode {
            SparsityRequest::Structure { irow, jcol } => {
                irow.copy_from_slice(&[0, 1]);
                jcol.copy_from_slice(&[0, 0]);
            }
            SparsityRequest::Values { values } => {
                let x = x.expect("Jacobian values need x");
                values.copy_from_slice(&[1.0, 2.0 * (x[0] - 2.0)]);
            }
        }
        true
    }

    fn eval_h(
        &mut self,
        _x: Option<&[Number]>,
        _new_x: bool,
        obj_factor: Number,
        lambda: Option<&[Number]>,
        _new_lambda: bool,
        mode: SparsityRequest<'_>,
    ) -> bool {
        match mode {
            SparsityRequest::Structure { irow, jcol } => {
                irow[0] = 0;
                jcol[0] = 0;
            }
            SparsityRequest::Values { values } => {
                values[0] = 2.0 * obj_factor + 2.0 * lambda.map_or(0.0, |v| v[1]);
            }
        }
        true
    }

    fn finalize_solution(&mut self, sol: Solution<'_>, _d: &IpoptData, _q: &IpoptCq) {
        self.final_payload = Some(FinalPayload {
            status: sol.status,
            x: sol.x.to_vec(),
            z_l: sol.z_l.to_vec(),
            g: sol.g.to_vec(),
            lambda: sol.lambda.to_vec(),
            obj: sol.obj_value,
        });
    }
}

fn solve(
    presolve: bool,
    warm_start: bool,
    max_iter: Option<i32>,
    presolve_already_applied: bool,
) -> (ApplicationReturnStatus, TightenedRow) {
    let mut app = IpoptApplication::new();
    app.options_mut()
        .set_string_value("presolve", if presolve { "yes" } else { "no" }, true, false)
        .unwrap();
    if warm_start {
        app.options_mut()
            .set_string_value("warm_start_init_point", "yes", true, false)
            .unwrap();
    }
    if let Some(value) = max_iter {
        app.options_mut()
            .set_integer_value("max_iter", value, true, false)
            .unwrap();
    }
    app.initialize().unwrap();
    app.set_presolve_already_applied(presolve_already_applied);

    let problem = Rc::new(RefCell::new(TightenedRow::default()));
    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::clone(&problem) as Rc<RefCell<dyn TNLP>>;
    let status = app.optimize_tnlp(tnlp);
    let result = std::mem::take(&mut *problem.borrow_mut());
    (status, result)
}

#[test]
fn library_presolve_off_preserves_unwrapped_solution_and_callback_shape() {
    let (off_status, off) = solve(false, false, None, false);
    let (on_status, on) = solve(true, false, None, false);

    assert!(
        matches!(
            off_status,
            ApplicationReturnStatus::SolveSucceeded
                | ApplicationReturnStatus::SolvedToAcceptableLevel
        ),
        "unexpected presolve=no status: {off_status:?}"
    );
    assert!(
        matches!(
            on_status,
            ApplicationReturnStatus::SolveSucceeded
                | ApplicationReturnStatus::SolvedToAcceptableLevel
        ),
        "unexpected presolve=yes status: {on_status:?}"
    );
    let off_final = off.final_payload.expect("presolve=no finalize_solution");
    let on_final = on.final_payload.expect("presolve=yes finalize_solution");
    assert!((off_final.x[0] - 2.0).abs() < 1e-5);
    assert!((on_final.x[0] - off_final.x[0]).abs() < 1e-5);
    assert!((on_final.obj - off_final.obj).abs() < 1e-5);
    assert_eq!(off_final.g.len(), 2);
    assert_eq!(off_final.lambda.len(), 2);
    assert_eq!(on_final.g.len(), 2, "postsolve restores the dropped row");
    assert_eq!(
        on_final.lambda.len(),
        2,
        "postsolve restores the dropped dual"
    );
    assert!((on_final.g[0] - 2.0).abs() < 1e-5);
    assert!(
        on_final.lambda[0].abs() < 1e-10,
        "dropped row dual = {}",
        on_final.lambda[0]
    );
    assert!(
        on_final.z_l[0] > 1.0,
        "tightened lower-bound dual = {}",
        on_final.z_l[0]
    );
}

#[test]
fn library_presolve_warm_started_solve_restores_original_payload() {
    let (status, problem) = solve(true, true, None, false);
    assert!(matches!(
        status,
        ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::SolvedToAcceptableLevel
    ));
    let final_payload = problem.final_payload.expect("finalize_solution");
    assert_eq!(final_payload.g.len(), 2);
    assert_eq!(final_payload.lambda.len(), 2);
    assert!(final_payload.lambda[0].abs() < 1e-10);
}

#[test]
fn refused_warm_start_is_an_invalid_problem_definition() {
    let mut app = IpoptApplication::new();
    app.options_mut()
        .set_string_value("warm_start_init_point", "yes", true, false)
        .unwrap();
    app.options_mut()
        .set_string_value("presolve", "yes", true, false)
        .unwrap();
    app.initialize().unwrap();

    let problem = Rc::new(RefCell::new(TightenedRow {
        reject_warm_start: true,
        ..TightenedRow::default()
    }));
    let tnlp: Rc<RefCell<dyn TNLP>> = Rc::clone(&problem) as Rc<RefCell<dyn TNLP>>;
    let status = app.optimize_tnlp(tnlp);

    assert_eq!(status, ApplicationReturnStatus::InvalidProblemDefinition);
    assert!(problem.borrow().final_payload.is_none());
}

#[test]
fn library_presolve_preserves_max_iter_failure_and_callback_shape() {
    let (status, problem) = solve(true, false, Some(0), false);
    assert_eq!(status, ApplicationReturnStatus::MaximumIterationsExceeded);
    let final_payload = problem
        .final_payload
        .expect("finalize_solution on early termination");
    assert_eq!(final_payload.status, SolverReturn::MaxiterExceeded);
    assert_eq!(final_payload.g.len(), 2);
    assert_eq!(final_payload.lambda.len(), 2);
}

#[test]
fn explicit_presolve_bypass_preserves_user_option_and_unwrapped_tnlp() {
    let (status, problem) = solve(true, false, None, true);
    assert!(matches!(
        status,
        ApplicationReturnStatus::SolveSucceeded | ApplicationReturnStatus::SolvedToAcceptableLevel
    ));
    let final_payload = problem.final_payload.expect("finalize_solution");
    assert_eq!(final_payload.lambda.len(), 2);
    assert!(
        final_payload.lambda[0].abs() > 1.0,
        "without generic wrapping, the active original row retains its dual: {}",
        final_payload.lambda[0]
    );
}

#[test]
fn manual_presolve_wrapper_is_not_wrapped_again_when_option_is_enabled() {
    let mut app = IpoptApplication::new();
    app.options_mut()
        .set_string_value("presolve", "yes", true, false)
        .unwrap();
    app.initialize().unwrap();

    let inner: Rc<RefCell<dyn TNLP>> = Rc::new(RefCell::new(TightenedRow::default()));
    let manual = wrap_with_presolve(
        inner,
        PresolveOptions {
            enabled: true,
            ..PresolveOptions::defaults()
        },
    )
    .unwrap();
    let automatic = wrap_from_options(Rc::clone(&manual), app.options()).unwrap();
    assert!(Rc::ptr_eq(&manual, &automatic));
}