1use crate::error::OptimError;
13use crate::types::{IterationRecord, OptimResult, OptimStatus};
14use numra_core::Scalar;
15use numra_linalg::{CholeskyFactorization, DenseMatrix, Matrix};
16
17#[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
35fn 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
45fn 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
60fn 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
74fn 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#[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 let neg_c: Vec<S> = c.iter().map(|ci| -(*ci)).collect();
115 return h.solve(&neg_c).map_err(|_| OptimError::SingularMatrix);
116 }
117
118 let mut x0 = if m_eq > 0 {
120 let kkt_size = n + m_eq;
124 let mut kkt = DenseMatrix::<S>::zeros(kkt_size, kkt_size);
125 for i in 0..n {
127 for j in 0..n {
128 kkt.set(i, j, h.get(i, j));
129 }
130 }
131 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 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 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; }
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 let aug_size = n + m_eq + 1;
186 let mut aug = DenseMatrix::<S>::zeros(aug_size, aug_size);
187 for i in 0..n {
189 aug.set(i, i, S::ONE);
190 }
191 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 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 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 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 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#[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 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 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 if alpha < S::ZERO {
308 alpha = S::ZERO;
309 }
310
311 (alpha, blocking)
312}
313
314fn 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#[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 let h = DenseMatrix::<S>::from_row_major(n, n, h_row_major);
390
391 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 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 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 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 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 let mut x = find_initial_feasible_point(&h, c, n, a_eq, b_eq, &a_ineq_all, &b_ineq_all, tol)?;
439
440 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 let n_w = working_set.len();
486
487 if n_w == 0 {
488 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 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 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 let kkt_size = n + n_w;
534 let mut kkt = DenseMatrix::<S>::zeros(kkt_size, kkt_size);
535
536 for i in 0..n {
538 for j in 0..n {
539 kkt.set(i, j, h.get(i, j));
540 }
541 }
542
543 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 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 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 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 let f_final = compute_objective(&h, &x, c);
584 compute_gradient(&h, &x, c, &mut g);
585
586 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 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 working_set.remove(most_neg_wi.unwrap());
631 } else {
632 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 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 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 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 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 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 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 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 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}