Skip to main content

otspot_core/qp/
problem.rs

1//! QP問題のデータ構造定義。
2
3use crate::problem::{ConstraintType, SolveStatus};
4use crate::sparse::CscMatrix;
5
6/// Quadratic term storage for a single QCQP constraint.
7///
8/// Uses COO (coordinate) format: symmetrized `(row, col, val)` triplets.
9/// Memory is O(nnz), unlike `CscMatrix` which requires O(n) for `col_ptr`.
10#[derive(Debug, Clone, Default)]
11pub struct QcqpMatrix {
12    /// Problem dimension (n×n symmetric matrix).
13    pub n: usize,
14    /// Symmetrized COO triplets: (row, col, value), 0-indexed.
15    pub triplets: Vec<(usize, usize, f64)>,
16}
17
18impl QcqpMatrix {
19    /// Creates an empty n×n matrix with no non-zero entries.
20    pub fn new(n: usize) -> Self {
21        Self { n, triplets: Vec::new() }
22    }
23
24    /// Returns the number of stored entries (after symmetrization).
25    pub fn nnz(&self) -> usize {
26        self.triplets.len()
27    }
28}
29
30#[non_exhaustive]
31#[derive(Debug)]
32pub enum QpProblemError {
33    DimensionMismatch(String),
34    /// Non-finite coefficient (NaN or ±∞) in the named field at the given index.
35    NonFiniteCoefficient { field: &'static str, index: usize },
36    /// Invalid variable bound: NaN or lb > ub at the given index.
37    InvalidBounds { index: usize, lb: f64, ub: f64 },
38    /// A triplet (row, col) index exceeds the matrix dimension.
39    TripletIndexOutOfBounds { constraint: usize, row: usize, col: usize, n: usize },
40}
41
42impl std::fmt::Display for QpProblemError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            QpProblemError::DimensionMismatch(msg) => write!(f, "dimension mismatch: {}", msg),
46            QpProblemError::NonFiniteCoefficient { field, index } => {
47                write!(f, "non-finite coefficient in {}: index {}", field, index)
48            }
49            QpProblemError::InvalidBounds { index, lb, ub } => {
50                write!(f, "invalid bounds at index {}: lb={} > ub={} or NaN", index, lb, ub)
51            }
52            QpProblemError::TripletIndexOutOfBounds { constraint, row, col, n } => {
53                write!(f, "triplet index out of bounds in constraint {}: ({},{}) >= n={}", constraint, row, col, n)
54            }
55        }
56    }
57}
58
59impl std::error::Error for QpProblemError {}
60
61/// min 1/2 x^T Q x + c^T x  s.t. Ax {<=,=,>=} b, lb <= x <= ub
62///
63/// When `quadratic_constraints` is non-empty the problem is a QCQP.
64/// Entry `k` holds the symmetric `n×n` matrix `Q_k` for the quadratic part
65/// of constraint `k`: `1/2 x^T Q_k x + a_k^T x {<=,=,>=} b_k`.
66/// An empty `QcqpMatrix` at index `k` means constraint `k` has no quadratic part.
67#[derive(Debug, Clone)]
68pub struct QpProblem {
69    pub q: CscMatrix,
70    pub c: Vec<f64>,
71    pub a: CscMatrix,
72    pub b: Vec<f64>,
73    pub bounds: Vec<(f64, f64)>,
74    pub num_vars: usize,
75    pub num_constraints: usize,
76    pub constraint_types: Vec<ConstraintType>,
77    /// Per-constraint quadratic matrices for QCQP, stored in COO format.
78    ///
79    /// Length is either 0 (pure QP/LP) or `num_constraints` (QCQP).
80    /// Entry `k` holds symmetrized triplets for `Q_k`; empty means no quadratic part.
81    pub quadratic_constraints: Vec<QcqpMatrix>,
82    /// 目的関数値 = 1/2 x^T Q x + c^T x + obj_offset
83    pub obj_offset: f64,
84}
85
86impl QpProblem {
87    pub fn new(
88        q: CscMatrix,
89        c: Vec<f64>,
90        a: CscMatrix,
91        b: Vec<f64>,
92        bounds: Vec<(f64, f64)>,
93        constraint_types: Vec<ConstraintType>,
94    ) -> Result<Self, QpProblemError> {
95        let n = c.len();
96        let m = b.len();
97        if q.nrows != n || q.ncols != n {
98            return Err(QpProblemError::DimensionMismatch(
99                format!("Q must be {}x{}, got {}x{}", n, n, q.nrows, q.ncols)
100            ));
101        }
102        if a.nrows != m || a.ncols != n {
103            return Err(QpProblemError::DimensionMismatch(
104                format!("A must be {}x{}, got {}x{}", m, n, a.nrows, a.ncols)
105            ));
106        }
107        if bounds.len() != n {
108            return Err(QpProblemError::DimensionMismatch(
109                format!("bounds length must be {}, got {}", n, bounds.len())
110            ));
111        }
112        if constraint_types.len() != m {
113            return Err(QpProblemError::DimensionMismatch(
114                format!("constraint_types length must be {}, got {}", m, constraint_types.len())
115            ));
116        }
117        for (i, &v) in c.iter().enumerate() {
118            if !v.is_finite() {
119                return Err(QpProblemError::NonFiniteCoefficient { field: "c", index: i });
120            }
121        }
122        for (i, &v) in b.iter().enumerate() {
123            if !v.is_finite() {
124                return Err(QpProblemError::NonFiniteCoefficient { field: "b", index: i });
125            }
126        }
127        for (i, &v) in q.values.iter().enumerate() {
128            if !v.is_finite() {
129                return Err(QpProblemError::NonFiniteCoefficient { field: "Q", index: i });
130            }
131        }
132        for (i, &v) in a.values.iter().enumerate() {
133            if !v.is_finite() {
134                return Err(QpProblemError::NonFiniteCoefficient { field: "A", index: i });
135            }
136        }
137        for (i, &(lb, ub)) in bounds.iter().enumerate() {
138            if lb.is_nan() || ub.is_nan() || lb > ub {
139                return Err(QpProblemError::InvalidBounds { index: i, lb, ub });
140            }
141        }
142        Ok(QpProblem { q, c, a, b, bounds, num_vars: n, num_constraints: m, constraint_types, quadratic_constraints: vec![], obj_offset: 0.0 })
143    }
144
145    /// 全制約 Le として構築するヘルパー。
146    pub fn new_all_le(
147        q: CscMatrix,
148        c: Vec<f64>,
149        a: CscMatrix,
150        b: Vec<f64>,
151        bounds: Vec<(f64, f64)>,
152    ) -> Result<Self, QpProblemError> {
153        let m = b.len();
154        Self::new(q, c, a, b, bounds, vec![ConstraintType::Le; m])
155    }
156
157    /// Q が全ゼロかどうかを検査する(LP退化ケース判定)
158    pub fn is_zero_q(&self) -> bool {
159        self.q.values.iter().all(|&v| v.abs() < 1e-12)
160    }
161
162    /// Returns `true` if the problem has at least one constraint with a non-zero quadratic term.
163    ///
164    /// Used as a guard: the QP/LP solver cannot handle QCQP and must reject such problems.
165    pub fn has_qcqp_constraints(&self) -> bool {
166        self.quadratic_constraints.iter().any(|q| !q.triplets.is_empty())
167    }
168
169    /// Set per-constraint quadratic matrices for QCQP, with validation.
170    ///
171    /// `qcs` must be either empty (pure QP) or have length equal to `num_constraints`.
172    /// Each `QcqpMatrix` must have `n == num_vars`, finite values, and triplet indices
173    /// within `[0, n)`.
174    pub fn set_quadratic_constraints(&mut self, qcs: Vec<QcqpMatrix>) -> Result<(), QpProblemError> {
175        if !qcs.is_empty() && qcs.len() != self.num_constraints {
176            return Err(QpProblemError::DimensionMismatch(format!(
177                "quadratic_constraints length must be 0 or {}, got {}",
178                self.num_constraints, qcs.len()
179            )));
180        }
181        for (k, qc) in qcs.iter().enumerate() {
182            if qc.n != self.num_vars {
183                return Err(QpProblemError::DimensionMismatch(format!(
184                    "quadratic_constraints[{}].n must be {}, got {}",
185                    k, self.num_vars, qc.n
186                )));
187            }
188            for &(row, col, v) in &qc.triplets {
189                if !v.is_finite() {
190                    return Err(QpProblemError::NonFiniteCoefficient {
191                        field: "quadratic_constraints",
192                        index: k,
193                    });
194                }
195                if row >= qc.n || col >= qc.n {
196                    return Err(QpProblemError::TripletIndexOutOfBounds {
197                        constraint: k,
198                        row,
199                        col,
200                        n: qc.n,
201                    });
202                }
203            }
204        }
205        self.quadratic_constraints = qcs;
206        Ok(())
207    }
208
209    /// Q が対角行列かどうかを検査する
210    pub fn is_diagonal_q(&self) -> bool {
211        for col in 0..self.num_vars {
212            let start = self.q.col_ptr[col];
213            let end = self.q.col_ptr[col + 1];
214            for k in start..end {
215                let row = self.q.row_ind[k];
216                if row != col && self.q.values[k].abs() > 1e-12 {
217                    return false;
218                }
219            }
220        }
221        true
222    }
223
224}
225
226impl crate::problem::SolverResult {
227    pub fn infeasible() -> Self {
228        crate::problem::SolverResult {
229            status: SolveStatus::Infeasible,
230            objective: f64::INFINITY,
231            solution: vec![],
232            dual_solution: vec![],
233            bound_duals: vec![],
234
235            iterations: 0,
236            ..Default::default()
237        }
238    }
239
240    pub fn unbounded() -> Self {
241        crate::problem::SolverResult {
242            status: SolveStatus::Unbounded,
243            objective: f64::NEG_INFINITY,
244            solution: vec![],
245            dual_solution: vec![],
246            bound_duals: vec![],
247            iterations: 0,
248            ..Default::default()
249        }
250    }
251
252    pub fn max_iterations(x: Vec<f64>, obj: f64, iters: usize) -> Self {
253        crate::problem::SolverResult {
254            status: SolveStatus::MaxIterations,
255            objective: obj,
256            solution: x,
257            dual_solution: vec![],
258            bound_duals: vec![],
259            iterations: iters,
260            ..Default::default()
261        }
262    }
263
264    pub fn numerical_error() -> Self {
265        crate::problem::SolverResult {
266            status: SolveStatus::NumericalError,
267            objective: f64::INFINITY,
268            solution: vec![],
269            dual_solution: vec![],
270            bound_duals: vec![],
271
272            iterations: 0,
273            ..Default::default()
274        }
275    }
276
277    pub fn not_supported(msg: impl Into<String>) -> Self {
278        crate::problem::SolverResult {
279            status: SolveStatus::NotSupported(msg.into()),
280            objective: f64::INFINITY,
281            solution: vec![],
282            dual_solution: vec![],
283            bound_duals: vec![],
284            iterations: 0,
285            ..Default::default()
286        }
287    }
288}
289
290pub use crate::options::QpWarmStart;
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::problem::ConstraintType;
296    use crate::sparse::CscMatrix;
297
298    fn make_qp(
299        c: Vec<f64>,
300        b: Vec<f64>,
301        q_vals: Vec<f64>,
302        a_vals: Vec<f64>,
303        bounds: Vec<(f64, f64)>,
304    ) -> Result<QpProblem, QpProblemError> {
305        let n = c.len();
306        let m = b.len();
307        let q = if q_vals.is_empty() {
308            CscMatrix::new(n, n)
309        } else {
310            let idx: Vec<usize> = (0..n).collect();
311            crate::sparse::CscMatrix::from_triplets(&idx, &idx, &q_vals, n, n).unwrap()
312        };
313        let a = if a_vals.is_empty() {
314            CscMatrix::new(m, n)
315        } else {
316            let rows = vec![0usize; n];
317            let cols: Vec<usize> = (0..n).collect();
318            crate::sparse::CscMatrix::from_triplets(&rows, &cols, &a_vals, m, n).unwrap()
319        };
320        let ct = vec![ConstraintType::Le; m];
321        QpProblem::new(q, c, a, b, bounds, ct)
322    }
323
324    #[test]
325    fn valid_qp_accepted() {
326        let res = make_qp(
327            vec![1.0, 2.0],
328            vec![5.0],
329            vec![],
330            vec![1.0, 1.0],
331            vec![(0.0, f64::INFINITY), (0.0, 10.0)],
332        );
333        assert!(res.is_ok());
334    }
335
336    // --- NaN / ±inf in c ---
337    #[test]
338    fn nan_in_c_rejected() {
339        let cases = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
340        for bad in cases {
341            let res = make_qp(vec![bad, 1.0], vec![5.0], vec![], vec![1.0, 1.0],
342                              vec![(0.0, f64::INFINITY); 2]);
343            assert!(
344                matches!(res, Err(QpProblemError::NonFiniteCoefficient { field: "c", .. })),
345                "expected NonFiniteCoefficient for c={bad}"
346            );
347        }
348    }
349
350    // --- NaN / ±inf in b ---
351    #[test]
352    fn nan_in_b_rejected() {
353        let cases = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
354        for bad in cases {
355            let res = make_qp(vec![1.0, 2.0], vec![bad], vec![], vec![1.0, 1.0],
356                              vec![(0.0, f64::INFINITY); 2]);
357            assert!(
358                matches!(res, Err(QpProblemError::NonFiniteCoefficient { field: "b", .. })),
359                "expected NonFiniteCoefficient for b={bad}"
360            );
361        }
362    }
363
364    // --- NaN / ±inf in Q values ---
365    // Note: from_triplets drops NaN via DROP_TOL (NaN.abs() > tol == false).
366    // Inject bad values directly to test validation independent of that path.
367    #[test]
368    fn nan_in_q_rejected() {
369        let n = 2;
370        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
371        for bad in bad_vals {
372            let mut q = CscMatrix::from_triplets(&[0], &[0], &[1.0], n, n).unwrap();
373            q.values[0] = bad; // inject bad value directly
374            let a = CscMatrix::new(1, n);
375            let c = vec![1.0, 2.0];
376            let b = vec![5.0];
377            let bounds = vec![(0.0, f64::INFINITY); n];
378            let ct = vec![ConstraintType::Le];
379            let res = QpProblem::new(q, c, a, b, bounds, ct);
380            assert!(
381                matches!(res, Err(QpProblemError::NonFiniteCoefficient { field: "Q", .. })),
382                "expected NonFiniteCoefficient for Q val={bad}"
383            );
384        }
385    }
386
387    // --- NaN / ±inf in A values ---
388    #[test]
389    fn nan_in_a_rejected() {
390        let n = 2;
391        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
392        for bad in bad_vals {
393            let q = CscMatrix::new(n, n);
394            let mut a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, n).unwrap();
395            a.values[0] = bad; // inject bad value directly
396            let c = vec![1.0, 2.0];
397            let b = vec![5.0];
398            let bounds = vec![(0.0, f64::INFINITY); n];
399            let ct = vec![ConstraintType::Le];
400            let res = QpProblem::new(q, c, a, b, bounds, ct);
401            assert!(
402                matches!(res, Err(QpProblemError::NonFiniteCoefficient { field: "A", .. })),
403                "expected NonFiniteCoefficient for A val={bad}"
404            );
405        }
406    }
407
408    // --- NaN in bounds ---
409    #[test]
410    fn nan_in_bounds_rejected() {
411        let cases: Vec<(f64, f64)> = vec![
412            (f64::NAN, 1.0),
413            (0.0, f64::NAN),
414            (f64::NAN, f64::NAN),
415        ];
416        for (lb, ub) in cases {
417            let res = make_qp(
418                vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
419                vec![(lb, ub), (0.0, f64::INFINITY)],
420            );
421            assert!(
422                matches!(res, Err(QpProblemError::InvalidBounds { index: 0, .. })),
423                "expected InvalidBounds for ({lb},{ub})"
424            );
425        }
426    }
427
428    // --- lb > ub ---
429    #[test]
430    fn lb_gt_ub_rejected() {
431        let cases: Vec<(f64, f64)> = vec![
432            (5.0, 1.0),
433            (1.0, 0.0),
434            (f64::INFINITY, f64::NEG_INFINITY),
435            (0.1, 0.0),
436        ];
437        for (lb, ub) in cases {
438            let res = make_qp(
439                vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
440                vec![(lb, ub), (0.0, f64::INFINITY)],
441            );
442            assert!(
443                matches!(res, Err(QpProblemError::InvalidBounds { .. })),
444                "expected InvalidBounds for lb={lb} ub={ub}"
445            );
446        }
447    }
448
449    // --- inf bounds are valid ---
450    #[test]
451    fn inf_bounds_accepted() {
452        let res = make_qp(
453            vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
454            vec![(f64::NEG_INFINITY, f64::INFINITY), (0.0, f64::INFINITY)],
455        );
456        assert!(res.is_ok(), "±inf bounds should be valid");
457    }
458
459    // --- dimension mismatch still caught ---
460    #[test]
461    fn dimension_mismatch_still_caught() {
462        let n = 2;
463        let q = CscMatrix::new(n, n);
464        let a = CscMatrix::new(1, n);
465        let c = vec![1.0, 2.0, 3.0]; // wrong length
466        let b = vec![5.0];
467        let bounds = vec![(0.0, f64::INFINITY); n];
468        let ct = vec![ConstraintType::Le];
469        let res = QpProblem::new(q, c, a, b, bounds, ct);
470        assert!(matches!(res, Err(QpProblemError::DimensionMismatch(_))));
471    }
472
473    // --- set_quadratic_constraints ---
474
475    #[test]
476    fn set_quadratic_constraints_length_mismatch_rejected() {
477        let mut prob = make_qp(
478            vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
479            vec![(0.0, f64::INFINITY); 2],
480        ).unwrap();
481        // num_constraints = 1, so length 2 is invalid
482        let qcs = vec![QcqpMatrix::new(2), QcqpMatrix::new(2)];
483        let res = prob.set_quadratic_constraints(qcs);
484        assert!(matches!(res, Err(QpProblemError::DimensionMismatch(_))));
485    }
486
487    #[test]
488    fn set_quadratic_constraints_nan_rejected() {
489        let mut prob = make_qp(
490            vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
491            vec![(0.0, f64::INFINITY); 2],
492        ).unwrap();
493        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
494        for bad in bad_vals {
495            let mut qc = QcqpMatrix::new(2);
496            qc.triplets.push((0, 0, bad));
497            let res = prob.set_quadratic_constraints(vec![qc]);
498            assert!(
499                matches!(res, Err(QpProblemError::NonFiniteCoefficient { field: "quadratic_constraints", .. })),
500                "expected NonFiniteCoefficient for val={bad}"
501            );
502        }
503    }
504
505    #[test]
506    fn set_quadratic_constraints_empty_accepted() {
507        let mut prob = make_qp(
508            vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
509            vec![(0.0, f64::INFINITY); 2],
510        ).unwrap();
511        assert!(prob.set_quadratic_constraints(vec![]).is_ok());
512    }
513
514    #[test]
515    fn set_quadratic_constraints_valid_accepted() {
516        let mut prob = make_qp(
517            vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
518            vec![(0.0, f64::INFINITY); 2],
519        ).unwrap();
520        let mut qc = QcqpMatrix::new(2);
521        qc.triplets.push((0, 0, 2.0));
522        qc.triplets.push((1, 1, 3.0));
523        assert!(prob.set_quadratic_constraints(vec![qc]).is_ok());
524    }
525
526    // --- sentinel: n mismatch rejected (no-op rewrite → FAIL) ---
527    #[test]
528    fn set_quadratic_constraints_n_mismatch_rejected() {
529        let mut prob = make_qp(
530            vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
531            vec![(0.0, f64::INFINITY); 2],
532        ).unwrap();
533        // num_vars=2, but QcqpMatrix.n=3 — must be rejected
534        let mut qc = QcqpMatrix::new(3);
535        qc.triplets.push((0, 0, 1.0));
536        let res = prob.set_quadratic_constraints(vec![qc]);
537        assert!(
538            matches!(res, Err(QpProblemError::DimensionMismatch(_))),
539            "n mismatch must be rejected"
540        );
541    }
542
543    // --- sentinel: OOB triplet row index rejected (no-op rewrite → FAIL) ---
544    #[test]
545    fn set_quadratic_constraints_triplet_row_oob_rejected() {
546        let mut prob = make_qp(
547            vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
548            vec![(0.0, f64::INFINITY); 2],
549        ).unwrap();
550        let mut qc = QcqpMatrix::new(2);
551        qc.triplets.push((2, 0, 1.0)); // row=2 >= n=2
552        let res = prob.set_quadratic_constraints(vec![qc]);
553        assert!(
554            matches!(res, Err(QpProblemError::TripletIndexOutOfBounds { row: 2, col: 0, n: 2, .. })),
555            "row OOB must be rejected"
556        );
557    }
558
559    // --- sentinel: OOB triplet col index rejected (no-op rewrite → FAIL) ---
560    #[test]
561    fn set_quadratic_constraints_triplet_col_oob_rejected() {
562        let mut prob = make_qp(
563            vec![1.0, 2.0], vec![5.0], vec![], vec![1.0, 1.0],
564            vec![(0.0, f64::INFINITY); 2],
565        ).unwrap();
566        let mut qc = QcqpMatrix::new(2);
567        qc.triplets.push((0, 5, 1.0)); // col=5 >= n=2
568        let res = prob.set_quadratic_constraints(vec![qc]);
569        assert!(
570            matches!(res, Err(QpProblemError::TripletIndexOutOfBounds { col: 5, n: 2, .. })),
571            "col OOB must be rejected"
572        );
573    }
574
575    // --- sentinel: CscMatrix accessor returns correct data (no-op rewrite → FAIL) ---
576    #[test]
577    fn csc_accessor_returns_correct_data() {
578        let rows = vec![0usize, 1];
579        let cols = vec![0usize, 1];
580        let vals = vec![3.0f64, 7.0];
581        let m = CscMatrix::from_triplets(&rows, &cols, &vals, 2, 2).unwrap();
582        assert_eq!(m.nrows(), 2);
583        assert_eq!(m.ncols(), 2);
584        assert_eq!(m.nnz(), 2);
585        assert_eq!(m.col_ptr().len(), 3);
586        assert_eq!(m.row_ind().len(), 2);
587        assert_eq!(m.values().len(), 2);
588        let (ri, vs) = m.get_column(0).unwrap();
589        assert_eq!(ri, &[0]);
590        assert_eq!(vs, &[3.0]);
591        let (ri, vs) = m.get_column(1).unwrap();
592        assert_eq!(ri, &[1]);
593        assert_eq!(vs, &[7.0]);
594    }
595}