ledge-core 0.2.0

Factor-structured convex QP kernel for portfolio optimization
Documentation
//! Numerical solver integration tests.

use ledge_core::{
    check_kkt, generate_synthetic, DualVariables, FactorCovariance, FactorQuad, LinearConstraints,
    Matrix, QpProblem, SolveStatus, Solver, SolverError, SolverSettings, SyntheticConfig,
    WarmStart,
};

fn diagonal_problem(
    diagonal: Vec<f64>,
    linear: Vec<f64>,
    equalities: LinearConstraints,
    inequalities: LinearConstraints,
    lower_bounds: Vec<f64>,
    upper_bounds: Vec<f64>,
) -> QpProblem {
    let n = diagonal.len();
    QpProblem {
        quadratic: FactorQuad::new(
            Matrix::new(n, 0, Vec::new()).unwrap(),
            FactorCovariance::Diagonal(Vec::new()),
            diagonal,
        )
        .unwrap(),
        linear,
        l1: None,
        equalities,
        inequalities,
        lower_bounds,
        upper_bounds,
    }
}

#[test]
fn solves_unconstrained_diagonal_quadratic_with_known_solution() {
    let problem = diagonal_problem(
        vec![2.0, 4.0],
        vec![-2.0, 8.0],
        LinearConstraints::empty(2),
        LinearConstraints::empty(2),
        vec![f64::NEG_INFINITY; 2],
        vec![f64::INFINITY; 2],
    );

    let solution = Solver::default().solve(&problem, None).unwrap();

    assert_eq!(solution.status, SolveStatus::Solved);
    assert!((solution.x[0] - 1.0).abs() < 2.0e-5);
    assert!((solution.x[1] + 2.0).abs() < 2.0e-5);
    assert!(solution.residuals.primal < 1.0e-8);
    assert!(solution.residuals.dual < 1.0e-4);
}

#[test]
fn solves_budget_equality_and_reports_small_kkt_residuals() {
    let equalities =
        LinearConstraints::new(Matrix::new(1, 2, vec![1.0, 1.0]).unwrap(), vec![1.0]).unwrap();
    let problem = diagonal_problem(
        vec![1.0, 1.0],
        vec![0.0, 0.0],
        equalities,
        LinearConstraints::empty(2),
        vec![0.0; 2],
        vec![1.0; 2],
    );

    let solution = Solver::default().solve(&problem, None).unwrap();
    let independently_checked = check_kkt(&problem, &solution.x, &solution.dual).unwrap();

    assert_eq!(solution.status, SolveStatus::Solved);
    assert!((solution.x[0] - 0.5).abs() < 2.0e-5);
    assert!((solution.x[1] - 0.5).abs() < 2.0e-5);
    assert!(independently_checked.primal < 2.0e-5);
    assert!(independently_checked.dual < 2.0e-5);
    assert!(independently_checked.complementarity < 2.0e-5);
}

#[test]
fn enforces_upper_inequality_and_recovers_multiplier() {
    let inequalities =
        LinearConstraints::new(Matrix::new(1, 1, vec![1.0]).unwrap(), vec![1.0]).unwrap();
    let problem = diagonal_problem(
        vec![1.0],
        vec![-2.0],
        LinearConstraints::empty(1),
        inequalities,
        vec![f64::NEG_INFINITY],
        vec![f64::INFINITY],
    );

    let solution = Solver::default().solve(&problem, None).unwrap();

    assert_eq!(solution.status, SolveStatus::Solved);
    assert!((solution.x[0] - 1.0).abs() < 3.0e-5);
    assert!((solution.dual.inequalities[0] - 1.0).abs() < 3.0e-5);
    assert!(solution.residuals.primal < 3.0e-5);
    assert!(solution.residuals.dual < 3.0e-5);
}

#[test]
fn accepts_full_warm_start_and_reduces_iterations() {
    let inequalities =
        LinearConstraints::new(Matrix::new(1, 1, vec![1.0]).unwrap(), vec![1.0]).unwrap();
    let problem = diagonal_problem(
        vec![1.0],
        vec![-2.0],
        LinearConstraints::empty(1),
        inequalities,
        vec![f64::NEG_INFINITY],
        vec![f64::INFINITY],
    );
    let settings = SolverSettings {
        check_termination_every: 1,
        ..SolverSettings::default()
    };
    let solver = Solver::new(settings);
    let cold = solver.solve(&problem, None).unwrap();
    let warm = WarmStart {
        x: vec![1.0],
        equality_dual: Some(Vec::new()),
        inequality_dual: Some(vec![1.0]),
        bound_dual: Some(vec![0.0]),
        l1_dual: None,
    };
    let hot = solver.solve(&problem, Some(&warm)).unwrap();

    assert_eq!(hot.status, SolveStatus::Solved);
    assert!(hot.iterations < cold.iterations);
    assert!(hot.iterations <= 2);
}

#[test]
fn adaptive_rho_recovers_from_a_poor_initial_penalty() {
    let equalities =
        LinearConstraints::new(Matrix::new(1, 2, vec![1.0, 1.0]).unwrap(), vec![1.0]).unwrap();
    let problem = diagonal_problem(
        vec![1.0, 1.0],
        vec![0.0, 0.0],
        equalities,
        LinearConstraints::empty(2),
        vec![0.0; 2],
        vec![1.0; 2],
    );
    let solver = Solver::new(SolverSettings {
        rho: 1.0e-6,
        adaptive_rho_interval: 2,
        adaptive_rho_tolerance: 2.0,
        adaptive_rho_multiplier: 10.0,
        check_termination_every: 1,
        ..SolverSettings::default()
    });

    let solution = solver.solve(&problem, None).unwrap();

    assert_eq!(solution.status, SolveStatus::Solved);
    assert!(solution.rho_updates > 0);
    assert!(solution.final_rho > 1.0e-6);
    assert!(solution.residuals.primal < 2.0e-5);
}

#[test]
fn max_iterations_attaches_actionable_diagnostics() {
    let equalities =
        LinearConstraints::new(Matrix::new(1, 2, vec![1.0, 1.0]).unwrap(), vec![1.0]).unwrap();
    let problem = diagonal_problem(
        vec![1.0, 1.0],
        vec![0.0, 0.0],
        equalities,
        LinearConstraints::empty(2),
        vec![0.0; 2],
        vec![1.0; 2],
    );
    let solver = Solver::new(SolverSettings {
        max_iterations: 2,
        ..SolverSettings::default()
    });

    let solution = solver.solve(&problem, None).unwrap();

    assert_eq!(solution.status, SolveStatus::MaxIterations);
    let diagnostics = solution
        .diagnostics
        .expect("failed solves carry diagnostics");
    assert!(diagnostics.primal_tolerance > 0.0);
    assert!(diagnostics.dual_tolerance > 0.0);
    assert!(!diagnostics.hints.is_empty());
}

#[test]
fn solved_status_carries_no_diagnostics() {
    let problem = diagonal_problem(
        vec![2.0, 4.0],
        vec![-2.0, 8.0],
        LinearConstraints::empty(2),
        LinearConstraints::empty(2),
        vec![f64::NEG_INFINITY; 2],
        vec![f64::INFINITY; 2],
    );

    let solution = Solver::default().solve(&problem, None).unwrap();

    assert_eq!(solution.status, SolveStatus::Solved);
    assert!(solution.diagnostics.is_none());
}

#[test]
fn over_relaxation_reduces_iterations_and_agrees_with_plain_admm() {
    let instance = generate_synthetic(SyntheticConfig {
        assets: 500,
        factors: 10,
        inequalities: 3,
        seed: 42,
        budget: 1.0,
        max_weight: 0.02,
    })
    .unwrap();

    let relaxed = Solver::default().solve(&instance.problem, None).unwrap();
    let plain = Solver::new(SolverSettings {
        over_relaxation: 1.0,
        ..SolverSettings::default()
    })
    .solve(&instance.problem, None)
    .unwrap();

    assert_eq!(relaxed.status, SolveStatus::Solved);
    assert_eq!(plain.status, SolveStatus::Solved);
    assert!(
        relaxed.iterations < plain.iterations,
        "relaxed took {} iterations vs plain {}",
        relaxed.iterations,
        plain.iterations
    );

    let scale = 1.0 + plain.objective.abs();
    assert!((relaxed.objective - plain.objective).abs() <= 1.0e-4 * scale);
    let residuals = check_kkt(&instance.problem, &relaxed.x, &relaxed.dual).unwrap();
    assert!(residuals.primal < 1.0e-4);
    assert!(residuals.dual < 1.0e-4);
    assert!(residuals.complementarity < 1.0e-4);
}

#[test]
fn rejects_out_of_range_over_relaxation() {
    let problem = diagonal_problem(
        vec![1.0],
        vec![0.0],
        LinearConstraints::empty(1),
        LinearConstraints::empty(1),
        vec![f64::NEG_INFINITY],
        vec![f64::INFINITY],
    );
    for alpha in [0.0, -0.5, 2.0, 2.5, f64::NAN] {
        let error = Solver::new(SolverSettings {
            over_relaxation: alpha,
            ..SolverSettings::default()
        })
        .solve(&problem, None)
        .unwrap_err();
        assert!(
            matches!(error, SolverError::InvalidSettings(_)),
            "alpha={alpha} must be rejected"
        );
    }
}

#[test]
fn kkt_checker_detects_wrong_dual_sign() {
    let inequalities =
        LinearConstraints::new(Matrix::new(1, 1, vec![1.0]).unwrap(), vec![1.0]).unwrap();
    let problem = diagonal_problem(
        vec![1.0],
        vec![-2.0],
        LinearConstraints::empty(1),
        inequalities,
        vec![f64::NEG_INFINITY],
        vec![f64::INFINITY],
    );
    let residuals = check_kkt(
        &problem,
        &[1.0],
        &DualVariables {
            equalities: Vec::new(),
            inequalities: vec![-1.0],
            bounds: vec![0.0],
            l1: Vec::new(),
        },
    )
    .unwrap();

    assert!(residuals.dual >= 1.0);
}

#[test]
fn dense_factor_covariance_is_applied_without_materializing_q() {
    let quadratic = FactorQuad::new(
        Matrix::new(2, 2, vec![1.0, 0.0, 0.0, 1.0]).unwrap(),
        FactorCovariance::Dense(Matrix::new(2, 2, vec![2.0, 0.5, 0.5, 1.0]).unwrap()),
        vec![0.25, 0.75],
    )
    .unwrap();

    let product = quadratic.apply(&[2.0, -1.0]);

    assert!((product[0] - 4.0).abs() < 1.0e-14);
    assert!((product[1] + 0.75).abs() < 1.0e-14);
}

#[test]
fn rejects_indefinite_dense_factor_covariance_during_setup() {
    let quadratic = FactorQuad::new(
        Matrix::new(2, 2, vec![1.0, 0.0, 0.0, 1.0]).unwrap(),
        FactorCovariance::Dense(Matrix::new(2, 2, vec![1.0, 2.0, 2.0, 1.0]).unwrap()),
        vec![1.0, 1.0],
    )
    .unwrap();
    let problem = QpProblem {
        quadratic,
        linear: vec![0.0; 2],
        l1: None,
        equalities: LinearConstraints::empty(2),
        inequalities: LinearConstraints::empty(2),
        lower_bounds: vec![f64::NEG_INFINITY; 2],
        upper_bounds: vec![f64::INFINITY; 2],
    };

    let error = Solver::default().solve(&problem, None).unwrap_err();

    assert!(matches!(error, SolverError::NonPositiveSemidefiniteOmega));
}