Skip to main content

numra_optim/
qp.rs

1//! Active Set method for convex Quadratic Programming.
2//!
3//! Solves: minimize 1/2 x^T H x + c^T x
4//! subject to: A_ineq x <= b_ineq, A_eq x = b_eq, l <= x <= u.
5//!
6//! Requires H to be positive semi-definite (convex QP).
7//!
8//! Author: Moussa Leblouba
9//! Date: 8 February 2026
10//! Modified: 2 May 2026
11
12use crate::error::OptimError;
13use crate::types::{IterationRecord, OptimResult, OptimStatus};
14use numra_core::Scalar;
15use numra_linalg::{CholeskyFactorization, DenseMatrix, Matrix};
16
17/// Options for the QP solver.
18#[derive(Clone, Debug)]
19pub struct QPOptions<S: Scalar> {
20    pub max_iter: usize,
21    pub tol: S,
22    pub verbose: bool,
23}
24
25impl<S: Scalar> Default for QPOptions<S> {
26    fn default() -> Self {
27        Self {
28            max_iter: 10_000,
29            tol: S::from_f64(1e-10),
30            verbose: false,
31        }
32    }
33}
34
35// ── helpers ──
36
37fn dot<S: Scalar>(a: &[S], b: &[S]) -> S {
38    a.iter().zip(b.iter()).map(|(ai, bi)| *ai * *bi).sum::<S>()
39}
40
41fn norm<S: Scalar>(v: &[S]) -> S {
42    dot(v, v).sqrt()
43}
44
45/// Compute g = H * x + c.
46fn compute_gradient<
47    S: Scalar + faer::SimpleEntity + faer::Conjugate<Canonical = S> + faer::ComplexField,
48>(
49    h: &DenseMatrix<S>,
50    x: &[S],
51    c: &[S],
52    g: &mut [S],
53) {
54    h.mul_vec(x, g);
55    for (gi, ci) in g.iter_mut().zip(c.iter()) {
56        *gi += *ci;
57    }
58}
59
60/// Compute f = 0.5 * x^T H x + c^T x.
61fn compute_objective<
62    S: Scalar + faer::SimpleEntity + faer::Conjugate<Canonical = S> + faer::ComplexField,
63>(
64    h: &DenseMatrix<S>,
65    x: &[S],
66    c: &[S],
67) -> S {
68    let n = x.len();
69    let mut hx = vec![S::ZERO; n];
70    h.mul_vec(x, &mut hx);
71    S::from_f64(0.5) * dot(x, &hx) + dot(x, c)
72}
73
74/// Get the constraint row vector for constraint index `idx`.
75/// Indices 0..m_eq are equalities, m_eq..m_eq+m_ineq are inequalities.
76fn get_constraint_row<'a, S: Scalar>(
77    idx: usize,
78    m_eq: usize,
79    a_eq: &'a [Vec<S>],
80    a_ineq_all: &'a [Vec<S>],
81) -> &'a [S] {
82    if idx < m_eq {
83        &a_eq[idx]
84    } else {
85        &a_ineq_all[idx - m_eq]
86    }
87}
88
89/// Find an initial feasible point.
90///
91/// Strategy:
92/// 1. If no constraints: solve H x = -c for unconstrained minimum.
93/// 2. If only equalities: solve the equality-constrained KKT system.
94/// 3. If inequalities present: start from the equality-feasible point (or origin),
95///    then iteratively project onto violated inequality constraints.
96#[allow(clippy::too_many_arguments)]
97fn find_initial_feasible_point<
98    S: Scalar + faer::SimpleEntity + faer::Conjugate<Canonical = S> + faer::ComplexField,
99>(
100    h: &DenseMatrix<S>,
101    c: &[S],
102    n: usize,
103    a_eq: &[Vec<S>],
104    b_eq: &[S],
105    a_ineq_all: &[Vec<S>],
106    b_ineq_all: &[S],
107    tol: S,
108) -> Result<Vec<S>, OptimError> {
109    let m_eq = a_eq.len();
110    let m_ineq = a_ineq_all.len();
111
112    if m_eq == 0 && m_ineq == 0 {
113        // Unconstrained: solve H x = -c
114        let neg_c: Vec<S> = c.iter().map(|ci| -(*ci)).collect();
115        return h.solve(&neg_c).map_err(|_| OptimError::SingularMatrix);
116    }
117
118    // Start with equality-feasible point via KKT
119    let mut x0 = if m_eq > 0 {
120        // Solve the KKT system for equality-constrained minimum:
121        // [ H   A_eq^T ] [ x ]   [ -c   ]
122        // [ A_eq   0   ] [ lambda ] = [ b_eq ]
123        let kkt_size = n + m_eq;
124        let mut kkt = DenseMatrix::<S>::zeros(kkt_size, kkt_size);
125        // Fill H block
126        for i in 0..n {
127            for j in 0..n {
128                kkt.set(i, j, h.get(i, j));
129            }
130        }
131        // Fill A_eq and A_eq^T blocks
132        for (i, a_eq_row) in a_eq.iter().enumerate().take(m_eq) {
133            for (j, &val) in a_eq_row.iter().enumerate().take(n) {
134                kkt.set(n + i, j, val);
135                kkt.set(j, n + i, val);
136            }
137        }
138        // RHS
139        let mut rhs = vec![S::ZERO; kkt_size];
140        for (ri, ci) in rhs.iter_mut().zip(c.iter()).take(n) {
141            *ri = -(*ci);
142        }
143        rhs[n..(m_eq + n)].copy_from_slice(&b_eq[..m_eq]);
144        let sol = kkt.solve(&rhs).map_err(|_| OptimError::SingularMatrix)?;
145        sol[..n].to_vec()
146    } else {
147        vec![S::ZERO; n]
148    };
149
150    if m_ineq == 0 {
151        return Ok(x0);
152    }
153
154    // Project onto violated inequality constraints iteratively.
155    // When equality constraints are present, project along the null space of A_eq
156    // to maintain equality feasibility.
157    for _phase in 0..200 {
158        let mut worst_idx = None;
159        let mut worst_violation = tol;
160        for i in 0..m_ineq {
161            let residual = dot(&a_ineq_all[i], &x0) - b_ineq_all[i];
162            if residual > worst_violation {
163                worst_violation = residual;
164                worst_idx = Some(i);
165            }
166        }
167        if worst_idx.is_none() {
168            break; // All inequalities satisfied
169        }
170
171        let idx = worst_idx.unwrap();
172        let a = &a_ineq_all[idx];
173        let b_val = b_ineq_all[idx];
174
175        if m_eq > 0 {
176            // We need to find a direction d such that:
177            //   A_eq d = 0 (stay on equality constraints)
178            //   a^T d < 0  (reduce violation of the violated inequality)
179            // Solve the least-norm problem:
180            //   min ||d||^2 s.t. A_eq d = 0, a^T d = -(a^T x0 - b_val)
181            // Using KKT of this sub-problem with the augmented constraint set:
182            //   [ I   A_eq^T  a ] [ d  ]   [ 0          ]
183            //   [ A_eq  0     0 ] [ mu ]  = [ 0          ]
184            //   [ a^T   0     0 ] [ nu ]    [ b_val - a^T x0 ]
185            let aug_size = n + m_eq + 1;
186            let mut aug = DenseMatrix::<S>::zeros(aug_size, aug_size);
187            // I block
188            for i in 0..n {
189                aug.set(i, i, S::ONE);
190            }
191            // A_eq and A_eq^T
192            for (i, a_eq_row) in a_eq.iter().enumerate().take(m_eq) {
193                for (j, &val) in a_eq_row.iter().enumerate().take(n) {
194                    aug.set(n + i, j, val);
195                    aug.set(j, n + i, val);
196                }
197            }
198            // a and a^T
199            for (j, &val) in a.iter().enumerate().take(n) {
200                aug.set(n + m_eq, j, val);
201                aug.set(j, n + m_eq, val);
202            }
203            let mut rhs = vec![S::ZERO; aug_size];
204            rhs[n + m_eq] = b_val - dot(a, &x0);
205
206            if let Ok(sol) = aug.solve(&rhs) {
207                let d: Vec<S> = sol[..n].to_vec();
208                for j in 0..n {
209                    x0[j] += d[j];
210                }
211            } else {
212                // Fallback: simple projection (may break equalities)
213                let ax = dot(a, &x0);
214                let aa = dot(a, a);
215                if aa > S::from_f64(1e-20) {
216                    let shift = (ax - b_val) / aa;
217                    for j in 0..n {
218                        x0[j] -= shift * a[j];
219                    }
220                }
221            }
222        } else {
223            // No equalities: simple projection onto the hyperplane
224            let ax = dot(a, &x0);
225            let aa = dot(a, a);
226            if aa < S::from_f64(1e-20) {
227                continue;
228            }
229            let shift = (ax - b_val) / aa;
230            for j in 0..n {
231                x0[j] -= shift * a[j];
232            }
233        }
234    }
235
236    // Verify feasibility
237    let eq_violation: S = (0..m_eq)
238        .map(|i| (dot(&a_eq[i], &x0) - b_eq[i]).abs())
239        .fold(S::ZERO, |a, b| if b > a { b } else { a });
240    let ineq_violation: S = (0..m_ineq)
241        .map(|i| {
242            let v = dot(&a_ineq_all[i], &x0) - b_ineq_all[i];
243            if v > S::ZERO {
244                v
245            } else {
246                S::ZERO
247            }
248        })
249        .fold(S::ZERO, |a, b| if b > a { b } else { a });
250
251    let violation = if ineq_violation > eq_violation {
252        ineq_violation
253    } else {
254        eq_violation
255    };
256    if violation > S::from_f64(1e-4) {
257        return Err(OptimError::Infeasible {
258            violation: violation.to_f64(),
259        });
260    }
261
262    Ok(x0)
263}
264
265/// Line search along direction p from x: find maximum alpha in [0, 1]
266/// such that x + alpha * p satisfies all inequality constraints.
267/// Returns (alpha, blocking_constraint_index) where the blocking constraint
268/// is the one in a_ineq_all that becomes active.
269#[allow(clippy::too_many_arguments)]
270fn line_search_step<S: Scalar>(
271    x: &[S],
272    p: &[S],
273    _n: usize,
274    m_eq: usize,
275    a_ineq_all: &[Vec<S>],
276    b_ineq_all: &[S],
277    working_set: &[usize],
278    tol: S,
279) -> (S, Option<usize>) {
280    let m_ineq = a_ineq_all.len();
281    let mut alpha = S::ONE;
282    let mut blocking: Option<usize> = None;
283
284    for i in 0..m_ineq {
285        let global_idx = m_eq + i;
286        // Skip constraints already in the working set
287        if working_set.contains(&global_idx) {
288            continue;
289        }
290        let a = &a_ineq_all[i];
291        let ap = dot(a, p);
292        if ap <= tol {
293            // Direction does not move toward violation of this constraint
294            continue;
295        }
296        let ax = dot(a, x);
297        let bi = b_ineq_all[i];
298        let slack = bi - ax;
299        let alpha_i = if slack < S::ZERO { S::ZERO } else { slack / ap };
300        if alpha_i < alpha {
301            alpha = alpha_i;
302            blocking = Some(global_idx);
303        }
304    }
305
306    // Clamp alpha
307    if alpha < S::ZERO {
308        alpha = S::ZERO;
309    }
310
311    (alpha, blocking)
312}
313
314/// Compute the maximum constraint violation for the final result.
315fn compute_constraint_violation<S: Scalar>(
316    x: &[S],
317    a_eq_orig: &[Vec<S>],
318    b_eq_orig: &[S],
319    a_ineq_orig: &[Vec<S>],
320    b_ineq_orig: &[S],
321    bounds: &[Option<(S, S)>],
322) -> S {
323    let mut violation = S::ZERO;
324    for i in 0..a_eq_orig.len() {
325        let res = (dot(&a_eq_orig[i], x) - b_eq_orig[i]).abs();
326        if res > violation {
327            violation = res;
328        }
329    }
330    for i in 0..a_ineq_orig.len() {
331        let v = dot(&a_ineq_orig[i], x) - b_ineq_orig[i];
332        let res = if v > S::ZERO { v } else { S::ZERO };
333        if res > violation {
334            violation = res;
335        }
336    }
337    for (j, b) in bounds.iter().enumerate() {
338        if let Some((lo, hi)) = b {
339            if x[j] < *lo {
340                let d = *lo - x[j];
341                if d > violation {
342                    violation = d;
343                }
344            }
345            if x[j] > *hi {
346                let d = x[j] - *hi;
347                if d > violation {
348                    violation = d;
349                }
350            }
351        }
352    }
353    violation
354}
355
356/// Solve a convex QP using the primal active set method.
357///
358/// Minimizes: 1/2 x^T H x + c^T x
359/// subject to: A_ineq x <= b_ineq, A_eq x = b_eq, l <= x <= u.
360///
361/// # Arguments
362/// * `h_row_major` - Hessian matrix in row-major order (n x n), must be PSD.
363/// * `c` - Linear term (length n).
364/// * `n` - Number of variables.
365/// * `a_ineq` - Inequality constraint rows.
366/// * `b_ineq` - Inequality constraint RHS.
367/// * `a_eq` - Equality constraint rows.
368/// * `b_eq` - Equality constraint RHS.
369/// * `bounds` - Optional (lower, upper) bounds per variable.
370/// * `opts` - Solver options.
371#[allow(clippy::too_many_arguments)]
372pub fn active_set_qp_solve<
373    S: Scalar + faer::SimpleEntity + faer::Conjugate<Canonical = S> + faer::ComplexField,
374>(
375    h_row_major: &[S],
376    c: &[S],
377    n: usize,
378    a_ineq: &[Vec<S>],
379    b_ineq: &[S],
380    a_eq: &[Vec<S>],
381    b_eq: &[S],
382    bounds: &[Option<(S, S)>],
383    opts: &QPOptions<S>,
384) -> Result<OptimResult<S>, OptimError> {
385    let start = std::time::Instant::now();
386    let tol = opts.tol;
387
388    // Build H as DenseMatrix
389    let h = DenseMatrix::<S>::from_row_major(n, n, h_row_major);
390
391    // Validate that H is positive semi-definite via Cholesky factorization.
392    // Add a small regularization to handle PSD (not just PD) matrices.
393    let mut h_reg = DenseMatrix::<S>::zeros(n, n);
394    for i in 0..n {
395        for j in 0..n {
396            h_reg.set(i, j, h.get(i, j));
397        }
398        h_reg.set(i, i, h.get(i, i) + S::from_f64(1e-12));
399    }
400    if CholeskyFactorization::new(&h_reg).is_err() {
401        return Err(OptimError::QPNotPositiveSemiDefinite);
402    }
403
404    // Convert bounds to inequality constraints.
405    // Lower bound x_i >= l_i  =>  -x_i <= -l_i  (i.e. -e_i^T x <= -l_i)
406    // Upper bound x_i <= u_i  =>  e_i^T x <= u_i
407    let m_eq = a_eq.len();
408    let m_ineq_orig = a_ineq.len();
409    let mut a_ineq_all: Vec<Vec<S>> = a_ineq.to_vec();
410    let mut b_ineq_all: Vec<S> = b_ineq.to_vec();
411
412    // Track which inequality indices correspond to bounds for active_bounds reporting
413    // bound_info[i] = Some((var_idx, is_upper)) for converted bound constraints
414    let mut bound_info: Vec<Option<(usize, bool)>> = vec![None; m_ineq_orig];
415
416    for (j, b) in bounds.iter().enumerate() {
417        if let Some((lo, hi)) = b {
418            // Lower bound: -x_j <= -lo
419            let mut row_lo = vec![S::ZERO; n];
420            row_lo[j] = -S::ONE;
421            a_ineq_all.push(row_lo);
422            b_ineq_all.push(-(*lo));
423            bound_info.push(Some((j, false)));
424
425            // Upper bound: x_j <= hi
426            let mut row_hi = vec![S::ZERO; n];
427            row_hi[j] = S::ONE;
428            a_ineq_all.push(row_hi);
429            b_ineq_all.push(*hi);
430            bound_info.push(Some((j, true)));
431        }
432    }
433
434    let m_ineq_total = a_ineq_all.len();
435    let _m_total = m_eq + m_ineq_total;
436
437    // Find initial feasible point
438    let mut x = find_initial_feasible_point(&h, c, n, a_eq, b_eq, &a_ineq_all, &b_ineq_all, tol)?;
439
440    // Initialize working set: all equalities + active inequalities at x0
441    let mut working_set: Vec<usize> = (0..m_eq).collect();
442    for i in 0..m_ineq_total {
443        let residual = dot(&a_ineq_all[i], &x) - b_ineq_all[i];
444        if residual.abs() < tol * S::from_f64(10.0) {
445            let global_idx = m_eq + i;
446            if !working_set.contains(&global_idx) {
447                working_set.push(global_idx);
448            }
449        }
450    }
451
452    let mut g = vec![S::ZERO; n];
453    let mut history: Vec<IterationRecord<S>> = Vec::new();
454    let mut iterations = 0;
455
456    for iter in 0..opts.max_iter {
457        iterations = iter + 1;
458        compute_gradient(&h, &x, c, &mut g);
459        let f_val = compute_objective(&h, &x, c);
460        let g_norm = norm(&g);
461
462        if opts.verbose {
463            eprintln!(
464                "QP iter {}: f={:.6e}, ||g||={:.6e}, |W|={}",
465                iter,
466                f_val.to_f64(),
467                g_norm.to_f64(),
468                working_set.len()
469            );
470        }
471
472        history.push(IterationRecord {
473            iteration: iter,
474            objective: f_val,
475            gradient_norm: g_norm,
476            step_size: S::ZERO,
477            constraint_violation: S::ZERO,
478        });
479
480        // Build and solve KKT system for the equality QP subproblem:
481        //   min 1/2 p^T H p + g^T p  subject to A_W p = 0
482        // KKT: [ H  A_W^T ] [ p ] = [ -g ]
483        //      [ A_W  0   ] [ lam ] = [  0 ]
484
485        let n_w = working_set.len();
486
487        if n_w == 0 {
488            // No active constraints: solve H p = -g
489            let neg_g: Vec<S> = g.iter().map(|gi| -(*gi)).collect();
490            let p = h.solve(&neg_g).map_err(|_| OptimError::SingularMatrix)?;
491            let p_norm = norm(&p);
492
493            if p_norm < tol {
494                // Optimal
495                let f_final = compute_objective(&h, &x, c);
496                let cv = compute_constraint_violation(&x, a_eq, b_eq, a_ineq, b_ineq, bounds);
497                return Ok((OptimResult {
498                    constraint_violation: cv,
499                    history,
500                    ..OptimResult::unconstrained(
501                        x,
502                        f_final,
503                        g,
504                        iterations,
505                        iterations,
506                        iterations,
507                        true,
508                        "Optimal solution found".into(),
509                        OptimStatus::GradientConverged,
510                    )
511                })
512                .with_wall_time(start));
513            }
514
515            // Line search to maintain feasibility
516            let (alpha, blocking) =
517                line_search_step(&x, &p, n, m_eq, &a_ineq_all, &b_ineq_all, &working_set, tol);
518
519            for j in 0..n {
520                x[j] += alpha * p[j];
521            }
522
523            if let Some(blocking_idx) = blocking {
524                if alpha < S::ONE - tol {
525                    working_set.push(blocking_idx);
526                }
527            }
528
529            continue;
530        }
531
532        // Build KKT system
533        let kkt_size = n + n_w;
534        let mut kkt = DenseMatrix::<S>::zeros(kkt_size, kkt_size);
535
536        // H block
537        for i in 0..n {
538            for j in 0..n {
539                kkt.set(i, j, h.get(i, j));
540            }
541        }
542
543        // A_W and A_W^T blocks
544        for (wi, &cidx) in working_set.iter().enumerate() {
545            let a_row = get_constraint_row(cidx, m_eq, a_eq, &a_ineq_all);
546            for (j, &val) in a_row.iter().enumerate().take(n) {
547                kkt.set(n + wi, j, val);
548                kkt.set(j, n + wi, val);
549            }
550        }
551
552        // RHS: [-g; 0]
553        let mut rhs = vec![S::ZERO; kkt_size];
554        for i in 0..n {
555            rhs[i] = -g[i];
556        }
557
558        let sol = kkt.solve(&rhs).map_err(|_| OptimError::SingularMatrix)?;
559        let p: Vec<S> = sol[..n].to_vec();
560        let lambdas: Vec<S> = sol[n..].to_vec();
561        let p_norm = norm(&p);
562
563        if p_norm < tol {
564            // Step is zero: check Lagrange multipliers for inequality constraints
565            // If all multipliers for inequality constraints >= 0, we are optimal.
566            // Otherwise, remove the inequality constraint with the most negative multiplier.
567            let mut most_neg_lambda = -tol;
568            let mut most_neg_wi: Option<usize> = None;
569
570            for (wi, &cidx) in working_set.iter().enumerate() {
571                if cidx < m_eq {
572                    // Equality constraint: never remove
573                    continue;
574                }
575                if lambdas[wi] < most_neg_lambda {
576                    most_neg_lambda = lambdas[wi];
577                    most_neg_wi = Some(wi);
578                }
579            }
580
581            if most_neg_wi.is_none() {
582                // Optimal: all inequality multipliers non-negative
583                let f_final = compute_objective(&h, &x, c);
584                compute_gradient(&h, &x, c, &mut g);
585
586                // Extract multiplier information
587                let mut lambda_eq_out = Vec::new();
588                let mut lambda_ineq_out = Vec::new();
589                let mut active_bounds_out = Vec::new();
590
591                for (wi, &cidx) in working_set.iter().enumerate() {
592                    if cidx < m_eq {
593                        lambda_eq_out.push(lambdas[wi]);
594                    } else {
595                        let ineq_idx = cidx - m_eq;
596                        lambda_ineq_out.push(lambdas[wi]);
597                        if let Some((var_idx, _)) = bound_info[ineq_idx] {
598                            active_bounds_out.push(var_idx);
599                        }
600                    }
601                }
602                // Deduplicate active_bounds
603                active_bounds_out.sort_unstable();
604                active_bounds_out.dedup();
605
606                let cv = compute_constraint_violation(&x, a_eq, b_eq, a_ineq, b_ineq, bounds);
607
608                return Ok((OptimResult {
609                    lambda_eq: lambda_eq_out,
610                    lambda_ineq: lambda_ineq_out,
611                    active_bounds: active_bounds_out,
612                    constraint_violation: cv,
613                    history,
614                    ..OptimResult::unconstrained(
615                        x,
616                        f_final,
617                        g,
618                        iterations,
619                        iterations,
620                        iterations,
621                        true,
622                        "Optimal solution found".into(),
623                        OptimStatus::GradientConverged,
624                    )
625                })
626                .with_wall_time(start));
627            }
628
629            // Remove the constraint with most negative multiplier
630            working_set.remove(most_neg_wi.unwrap());
631        } else {
632            // p is nonzero: line search to maintain feasibility among inactive constraints
633            let (alpha, blocking) =
634                line_search_step(&x, &p, n, m_eq, &a_ineq_all, &b_ineq_all, &working_set, tol);
635
636            for j in 0..n {
637                x[j] += alpha * p[j];
638            }
639
640            if let Some(blocking_idx) = blocking {
641                if alpha < S::ONE - tol {
642                    working_set.push(blocking_idx);
643                }
644            }
645        }
646    }
647
648    // Max iterations reached
649    let f_final = compute_objective(&h, &x, c);
650    compute_gradient(&h, &x, c, &mut g);
651    let cv = compute_constraint_violation(&x, a_eq, b_eq, a_ineq, b_ineq, bounds);
652
653    Ok((OptimResult {
654        constraint_violation: cv,
655        history,
656        ..OptimResult::unconstrained(
657            x,
658            f_final,
659            g,
660            iterations,
661            iterations,
662            iterations,
663            false,
664            format!("QP active set: max iterations ({}) reached", opts.max_iter),
665            OptimStatus::MaxIterations,
666        )
667    })
668    .with_wall_time(start))
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn test_qp_unconstrained() {
677        // H = [[2, 0], [0, 2]], c = [-2, -4]
678        // Optimal: x = [1, 2], f = -5
679        let h = vec![2.0, 0.0, 0.0, 2.0];
680        let c = vec![-2.0, -4.0];
681        let opts = QPOptions::default();
682        let result = active_set_qp_solve(&h, &c, 2, &[], &[], &[], &[], &[], &opts).unwrap();
683        assert!(result.converged);
684        assert!((result.x[0] - 1.0).abs() < 1e-6, "x0={}", result.x[0]);
685        assert!((result.x[1] - 2.0).abs() < 1e-6, "x1={}", result.x[1]);
686        assert!((result.f - (-5.0)).abs() < 1e-6, "f={}", result.f);
687    }
688
689    #[test]
690    fn test_qp_with_inequality() {
691        // minimize 1/2 (x0^2 + x1^2), H = I, c = 0
692        // subject to: -x0 - x1 <= -1  (i.e. x0 + x1 >= 1)
693        // Optimal: x = [0.5, 0.5], f = 0.25
694        let h = vec![1.0, 0.0, 0.0, 1.0];
695        let c = vec![0.0, 0.0];
696        let a_ineq = vec![vec![-1.0, -1.0]];
697        let b_ineq = vec![-1.0];
698        let opts = QPOptions::default();
699        let result =
700            active_set_qp_solve(&h, &c, 2, &a_ineq, &b_ineq, &[], &[], &[], &opts).unwrap();
701        assert!(result.converged);
702        assert!((result.x[0] - 0.5).abs() < 1e-6, "x0={}", result.x[0]);
703        assert!((result.x[1] - 0.5).abs() < 1e-6, "x1={}", result.x[1]);
704        assert!((result.f - 0.25).abs() < 1e-6, "f={}", result.f);
705    }
706
707    #[test]
708    fn test_qp_with_equality() {
709        // minimize 1/2 (x0^2 + x1^2), subject to x0 + x1 = 1
710        // Optimal: x = [0.5, 0.5], f = 0.25
711        let h = vec![1.0, 0.0, 0.0, 1.0];
712        let c = vec![0.0, 0.0];
713        let a_eq = vec![vec![1.0, 1.0]];
714        let b_eq = vec![1.0];
715        let opts = QPOptions::default();
716        let result = active_set_qp_solve(&h, &c, 2, &[], &[], &a_eq, &b_eq, &[], &opts).unwrap();
717        assert!(result.converged);
718        assert!((result.x[0] - 0.5).abs() < 1e-6, "x0={}", result.x[0]);
719        assert!((result.x[1] - 0.5).abs() < 1e-6, "x1={}", result.x[1]);
720    }
721
722    #[test]
723    fn test_qp_with_bounds() {
724        // minimize 1/2 x^T I x + [-3, -3]^T x subject to 0 <= xi <= 1
725        // Unconstrained min at (3,3), bounded to (1,1)
726        let h = vec![1.0, 0.0, 0.0, 1.0];
727        let c = vec![-3.0, -3.0];
728        let bounds: Vec<Option<(f64, f64)>> = vec![Some((0.0, 1.0)), Some((0.0, 1.0))];
729        let opts = QPOptions::default();
730        let result = active_set_qp_solve(&h, &c, 2, &[], &[], &[], &[], &bounds, &opts).unwrap();
731        assert!(result.converged);
732        assert!((result.x[0] - 1.0).abs() < 1e-6, "x0={}", result.x[0]);
733        assert!((result.x[1] - 1.0).abs() < 1e-6, "x1={}", result.x[1]);
734    }
735
736    #[test]
737    fn test_qp_portfolio() {
738        // Markowitz portfolio: minimize 1/2 x^T Sigma x
739        // subject to: mu^T x >= target, sum(x) = 1, x >= 0
740        let h = vec![0.04, 0.006, 0.002, 0.006, 0.01, 0.004, 0.002, 0.004, 0.0225];
741        let c = vec![0.0, 0.0, 0.0];
742        // -0.12*x0 - 0.10*x1 - 0.07*x2 <= -0.10
743        let a_ineq = vec![vec![-0.12, -0.10, -0.07]];
744        let b_ineq = vec![-0.10];
745        let a_eq = vec![vec![1.0, 1.0, 1.0]];
746        let b_eq = vec![1.0];
747        let bounds: Vec<Option<(f64, f64)>> =
748            vec![Some((0.0, 1.0)), Some((0.0, 1.0)), Some((0.0, 1.0))];
749        let opts = QPOptions::default();
750        let result =
751            active_set_qp_solve(&h, &c, 3, &a_ineq, &b_ineq, &a_eq, &b_eq, &bounds, &opts).unwrap();
752        assert!(result.converged, "QP did not converge: {}", result.message);
753        let sum: f64 = result.x.iter().copied().sum();
754        assert!((sum - 1.0).abs() < 1e-6, "sum={}", sum);
755        let ret: f64 = 0.12 * result.x[0] + 0.10 * result.x[1] + 0.07 * result.x[2];
756        assert!(ret >= 0.10 - 1e-4, "return={}", ret);
757        for xi in &result.x {
758            assert!(*xi >= -1e-8, "negative weight: {}", xi);
759        }
760    }
761
762    #[test]
763    fn test_qp_non_psd_rejected() {
764        // H = [[1, 0], [0, -1]] is indefinite (not PSD)
765        let h = vec![1.0, 0.0, 0.0, -1.0];
766        let c = vec![0.0, 0.0];
767        let opts = QPOptions::default();
768        let result = active_set_qp_solve(&h, &c, 2, &[], &[], &[], &[], &[], &opts);
769        assert!(result.is_err());
770        let err = result.unwrap_err();
771        assert!(
772            matches!(err, crate::error::OptimError::QPNotPositiveSemiDefinite),
773            "expected QPNotPositiveSemiDefinite, got: {}",
774            err
775        );
776    }
777}