Skip to main content

otspot_core/problem/
mod.rs

1//! LP問題定義モジュール
2//!
3//! 線形計画問題(LP)の構造定義・制約種別・ソルバー結果の表現を提供する。
4//! 問題は標準形 `min c^T x  s.t.  Ax {<=,>=,=} b,  x in [lb, ub]` で定義される。
5
6pub mod certificate;
7use certificate::{BoundGapCertificate, OptimalCertificate};
8
9use crate::error::SolverError;
10use crate::options::WarmStartBasis;
11use crate::sparse::CscMatrix;
12use std::fmt;
13
14/// LP問題における制約条件の種別
15#[non_exhaustive]
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum ConstraintType {
18    /// 以下(<=)
19    Le,
20    /// 以上(>=)
21    Ge,
22    /// 等式(==)
23    Eq,
24}
25
26/// Route taken by a solve call (populated per-result, race-free).
27#[non_exhaustive]
28#[derive(Debug, Clone, Copy, PartialEq, Default)]
29pub enum SolveRoute {
30    /// Route not yet set (default for uninitialized results).
31    #[default]
32    Unknown,
33    /// Called directly via `crate::lp::solve_lp_with`.
34    LpDirect,
35    /// LP forwarded from `solve_qp_with(Q=0)`.
36    LpForwardedFromQp,
37    /// QP solved via IPM (Q≠0).
38    QpIpm,
39}
40
41/// Per-solve routing and warm-start statistics (race-free, per-result).
42///
43/// Replaces process-global `AtomicU64` counters so parallel tests observe
44/// independent stats without reset/race issues.
45#[derive(Debug, Clone, Default)]
46pub struct SolveStats {
47    /// Route taken for this solve.
48    pub route: SolveRoute,
49    /// Whether the solver stopped because the deadline (timeout_secs / deadline) was reached.
50    ///
51    /// `true` iff `result.status == SolveStatus::Timeout`. Deterministic sentinel for
52    /// deadline-enforcement tests: assert this field instead of measuring wall time.
53    pub deadline_triggered: bool,
54    /// Whether the postsolve saddle-point Krylov IR was skipped because the solution
55    /// already met the user tolerance (`kkt_already_pass`). Deterministic sentinel for
56    /// the gate: removing the gate (always refine) flips this to `false`.
57    pub postsolve_krylov_ir_skipped: bool,
58    /// For LP solves via the QP dispatch (`LpForwardedFromQp`): `true` if the
59    /// returned result came from the IPM, `false` if from simplex. Lets callers
60    /// (e.g. benchmarks) label the actual route instead of a static size guess.
61    pub lp_ipm_path: bool,
62}
63
64/// ソルバーの求解結果ステータス
65#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq)]
67pub enum SolveStatus {
68    /// 最適解が求まった
69    Optimal,
70    /// 局所的最適解(非凸QP: 慣性修正付きIPMが収束したKKT点)
71    ///
72    /// Q行列が不定(indefinite)の場合、大域的最適性は保証されないが、
73    /// KKT条件を満たす局所最適解またはサドル点が返される。
74    /// 慣性修正(Gershgorin 評価から導出した δI 加算)により IPM を収束させた。
75    LocallyOptimal,
76    /// 問題が実行不可能(infeasible)
77    Infeasible,
78    /// 問題が非有界(unbounded)
79    Unbounded,
80    /// 反復回数上限に到達した(最適性未確認)
81    MaxIterations,
82    /// 解は見つかったが精度基準未達(偽Optimal検出: スケール解除後の残差超過)
83    SuboptimalSolution,
84    /// タイムアウト(timeout_secs を超過した)
85    Timeout,
86    /// 数値エラー(LDL分解失敗等、問題が数値的に解けない)
87    NumericalError,
88    /// Q行列が不定(非凸QP)。IPMはQ正半定値を前提とする。
89    NonConvex(String),
90    /// 非凸 QP の局所最適解 (= `solve_qp_global` 経由で incumbent あり、ε-global 証明なし)。
91    ///
92    /// BB driver が deadline / max_nodes / max_depth で打ち切られ、incumbent ある状態。
93    /// `LocallyOptimal` (= IPM inertia 補正後の単発解) と区別して、caller が「探索打切」
94    /// vs「単発 KKT 収束」を識別できる。`Optimal` には**含めない** (= global proof なし)。
95    NonconvexLocal,
96    /// 非凸 QP の大域 ε-最適解 (= `solve_qp_global` で gap_tol まで証明済み + Q が indefinite)。
97    ///
98    /// `Optimal` は「Q が PSD で IPM/BB が global 達成」専用に維持し、indefinite Q の場合は
99    /// 本 variant で明示分離する (caller が「global 証明済」かを fact で判別)。
100    NonconvexGlobal,
101    /// Problem type not supported by this solver.
102    ///
103    /// Returned when the caller passes a problem that cannot be handled, e.g.
104    /// a QCQP (quadratic constraints present) submitted to the QP/LP entry.
105    NotSupported(String),
106}
107
108impl fmt::Display for SolveStatus {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            SolveStatus::Optimal => write!(f, "Optimal"),
112            SolveStatus::LocallyOptimal => write!(f, "LocallyOptimal"),
113            SolveStatus::Infeasible => write!(f, "Infeasible"),
114            SolveStatus::Unbounded => write!(f, "Unbounded"),
115            SolveStatus::MaxIterations => write!(f, "MaxIterations"),
116            SolveStatus::SuboptimalSolution => write!(f, "SuboptimalSolution"),
117            SolveStatus::Timeout => write!(f, "Timeout"),
118            SolveStatus::NumericalError => write!(f, "NumericalError"),
119            SolveStatus::NonConvex(msg) => write!(f, "NonConvex({})", msg),
120            SolveStatus::NonconvexLocal => write!(f, "NonconvexLocal"),
121            SolveStatus::NonconvexGlobal => write!(f, "NonconvexGlobal"),
122            SolveStatus::NotSupported(msg) => write!(f, "NotSupported({})", msg),
123        }
124    }
125}
126
127/// LP/QP共通求解結果型
128///
129/// LP求解(Simplex等)と QP求解(AS/IPM/Concurrent)の両方で使用できる統一結果型。
130/// LP固有フィールド(`reduced_costs`, `slack`, `warm_start_basis`)は QP求解時は空/None。
131/// QP固有フィールド(`bound_duals`, `iterations`)は LP求解時は空/0。
132#[derive(Debug, Clone)]
133pub struct SolverResult {
134    /// 求解ステータス
135    pub status: SolveStatus,
136    /// 最適目的関数値(最適解が存在する場合)
137    pub objective: f64,
138    /// 解ベクトル(最適解が存在する場合)
139    pub solution: Vec<f64>,
140    /// 双対変数ベクトル(各制約の影価格、最適解が存在する場合)
141    pub dual_solution: Vec<f64>,
142    // --- LP固有フィールド ---
143    /// 被縮小費用ベクトル(各決定変数に対して、最適解が存在する場合)
144    pub reduced_costs: Vec<f64>,
145    /// スラック変数ベクトル(各制約のスラック b_i - a_i^T x、最適解が存在する場合)
146    pub slack: Vec<f64>,
147    /// warm-start用の基底情報(Optimal時のみ Some)
148    pub warm_start_basis: Option<WarmStartBasis>,
149    // --- QP固有フィールド ---
150    /// Bound dual values (shadow prices for variable bounds).
151    ///
152    /// Maps to original variable indices via col_map.
153    /// Empty if no bound constraints are active.
154    ///
155    /// - 除去変数 (presolveで固定された変数) の bound_dual = 0.0 (近似)
156    /// - presolve tightening で追加された境界の dual は報告しない(元問題基準)
157    /// - 配列順: `[lb_dual(j0), ..., lb_dual(j_{n_lb-1}), ub_dual(j0), ..., ub_dual(j_{n_ub-1})]`
158    pub bound_duals: Vec<f64>,
159    /// 反復回数(WSR実績回数)
160    pub iterations: usize,
161    /// 最終反復の残差実値 (pfeas, dfeas, duality_gap)。Optimal/MaxIterations時のみ Some。
162    pub final_residuals: Option<(f64, f64, f64)>,
163    /// 相対双対ギャップ (|p_obj - d_obj| / max(|p|,|d|,1))。
164    /// IPPMM 内部の best-so-far に紐づく値。unscale_ipm_result の Suboptimal→Optimal 昇格ゲート用。
165    /// None = 未計測(LP simplex 等 gap を持たない経路)。
166    pub duality_gap_rel: Option<f64>,
167    /// 各 phase の所要時間 (LP simplex 経路のみ、None なら未計測)。
168    /// 「どこに時間が掛かっているか」事実観測用 (CLAUDE.md「順調に収束に向けて探索」)。
169    pub timing_breakdown: Option<TimingBreakdown>,
170    /// Postsolve が最終的に採用した y_orig の dfeas violation (bound-aware sup ノルム).
171    /// LP simplex + presolve 経路のみ Some。caller (solve_with) が値が `PIVOT_TOL` を
172    /// 超えるとき presolve=off で再解する fallback gate に使う (greenbea-class 問題対策)。
173    pub postsolve_dfeas: Option<f64>,
174    /// Per-solve routing and warm-start statistics (race-free).
175    pub stats: SolveStats,
176    /// Branch-and-bound gap certificate.
177    ///
178    /// Present iff the solver completed a B&B search with a fully authenticated gap
179    /// (no `proof_uncertain` region, `within_gap` satisfied). `None` for direct LP/QP solves.
180    pub bound_gap_cert: Option<BoundGapCertificate>,
181    /// KKT optimality certificate — minted by `prove_optimal` on the B&B incumbent.
182    ///
183    /// Set when `finalize_proven` verifies all KKT conditions on the returned point.
184    /// `None` for demoted (LocallyOptimal/NonconvexLocal) or non-B&B results.
185    pub opt_cert: Option<OptimalCertificate>,
186}
187
188/// 各 phase 所要時間 (μs精度)。LP simplex と QP IPM の両経路で共用。
189#[derive(Debug, Clone, Copy, Default, PartialEq)]
190pub struct TimingBreakdown {
191    // ── LP simplex 経路 ───────────────────────────────────────────────────────
192    /// Presolve 全体 (run_presolve)
193    pub presolve_us: u64,
194    /// 縮約後 simplex 本体 (solve_without_presolve)
195    pub solve_us: u64,
196    /// Postsolve 総計 (run_postsolve / QP 経路では下記 4 field の合計)
197    pub postsolve_us: u64,
198
199    // ── QP IPM 経路: IPM 反復内部 ────────────────────────────────────────────
200    /// KKT 行列 LDL 数値因子化の累計 (全 iteration 合計)
201    pub ipm_factorize_us: u64,
202    /// KKT solve (predictor/corrector/Gondzio) の累計
203    pub ipm_solve_us: u64,
204    /// LDL regularization retry の累計回数 (健全性プローブ失敗含む)
205    pub ipm_reg_retries: u32,
206    /// MINRES (iterative) backend が 1 回以上使われたか
207    pub ipm_used_iterative: bool,
208
209    // ── QP IPM 経路: postsolve 段階別 ────────────────────────────────────────
210    /// postsolve_qp_with_dual_recovery (reduced → orig 空間写像)
211    pub postsolve_map_us: u64,
212    /// refine_postsolve_dual_lsq (元空間 y LSQ refine)
213    pub postsolve_lsq_us: u64,
214    /// refine_postsolve_recovery (Stage 0: SingletonRow 後退代入)
215    pub postsolve_recovery_us: u64,
216    /// refine_post_processing (Stage 1+2: primal projection / y-z refit)
217    pub postsolve_refine_us: u64,
218    /// refine_krylov_and_projection (saddle-point Krylov IR)
219    pub postsolve_krylov_ir_us: u64,
220}
221
222impl Default for SolverResult {
223    fn default() -> Self {
224        SolverResult {
225            status: SolveStatus::NumericalError,
226            objective: 0.0,
227            solution: vec![],
228            dual_solution: vec![],
229            reduced_costs: vec![],
230            slack: vec![],
231            warm_start_basis: None,
232            bound_duals: vec![],
233            iterations: 0,
234            final_residuals: None,
235            duality_gap_rel: None,
236            timing_breakdown: None,
237            postsolve_dfeas: None,
238            stats: SolveStats::default(),
239            bound_gap_cert: None,
240            opt_cert: None,
241        }
242    }
243}
244
245impl fmt::Display for SolverResult {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        write!(f, "Status: {}, Objective: {}", self.status, self.objective)
248    }
249}
250
251/// 線形計画問題: min c^T x  s.t.  Ax {op} b,  x in [lb, ub]
252///
253/// 目的関数・制約行列・右辺ベクトル・変数上下限をまとめて保持する。
254/// 制約種別(`<=`, `>=`, `=`)と変数ごとの上下限を個別に指定できる。
255#[derive(Debug, Clone)]
256pub struct LpProblem {
257    /// 目的関数係数ベクトル(長さ: `num_vars`)
258    pub c: Vec<f64>,
259    /// 制約行列(CSC形式、サイズ: `num_constraints` x `num_vars`)
260    pub a: CscMatrix,
261    /// 制約右辺ベクトル(長さ: `num_constraints`)
262    pub b: Vec<f64>,
263    /// 決定変数の数
264    pub num_vars: usize,
265    /// 制約式の数
266    pub num_constraints: usize,
267    /// 各制約の種別(長さ: `num_constraints`)
268    pub constraint_types: Vec<ConstraintType>,
269    /// 各変数の上下限 `(lower, upper)`(長さ: `num_vars`)
270    pub bounds: Vec<(f64, f64)>,
271    /// 問題名(オプション)
272    pub name: Option<String>,
273}
274
275impl LpProblem {
276    /// 新しいLP問題を検証付きで生成する(後方互換版)
277    ///
278    /// 標準形 `min c^T x  s.t.  Ax <= b,  x >= 0` を作成する。
279    /// 全制約を `<=`、全変数の下限を 0・上限を `+∞` とする。
280    ///
281    /// # 引数
282    /// * `c` - 目的関数係数ベクトル
283    /// * `a` - 制約行列(CSC形式)
284    /// * `b` - 制約右辺ベクトル
285    ///
286    /// # 戻り値
287    /// * `Ok(LpProblem)` - 次元が有効な場合
288    /// * `Err(String)` - 次元不一致などの検証エラー時
289    pub fn new(c: Vec<f64>, a: CscMatrix, b: Vec<f64>) -> Result<Self, SolverError> {
290        let num_vars = c.len();
291        let num_constraints = b.len();
292
293        // Set defaults for backward compatibility
294        let constraint_types = vec![ConstraintType::Le; num_constraints];
295        let bounds = vec![(0.0, f64::INFINITY); num_vars];
296        let name = None;
297
298        Self::new_general(c, a, b, constraint_types, bounds, name)
299    }
300
301    /// 制約種別と変数上下限を完全指定して新しいLP問題を生成する
302    ///
303    /// # 引数
304    /// * `c` - 目的関数係数ベクトル
305    /// * `a` - 制約行列(CSC形式)
306    /// * `b` - 制約右辺ベクトル
307    /// * `constraint_types` - 各制約の種別(`Le` / `Ge` / `Eq`)
308    /// * `bounds` - 各変数の上下限 `(lower, upper)`
309    /// * `name` - 問題名(オプション)
310    ///
311    /// # 戻り値
312    /// * `Ok(LpProblem)` - 次元が有効な場合
313    /// * `Err(String)` - 次元不一致などの検証エラー時
314    pub fn new_general(
315        c: Vec<f64>,
316        a: CscMatrix,
317        b: Vec<f64>,
318        constraint_types: Vec<ConstraintType>,
319        bounds: Vec<(f64, f64)>,
320        name: Option<String>,
321    ) -> Result<Self, SolverError> {
322        // Validate dimensions
323        if c.len() != a.ncols {
324            return Err(SolverError::DimensionMismatch {
325                field: "c",
326                expected: a.ncols,
327                got: c.len(),
328            });
329        }
330        if b.len() != a.nrows {
331            return Err(SolverError::DimensionMismatch {
332                field: "b",
333                expected: a.nrows,
334                got: b.len(),
335            });
336        }
337        if constraint_types.len() != b.len() {
338            return Err(SolverError::DimensionMismatch {
339                field: "constraint_types",
340                expected: b.len(),
341                got: constraint_types.len(),
342            });
343        }
344        if bounds.len() != c.len() {
345            return Err(SolverError::DimensionMismatch {
346                field: "bounds",
347                expected: c.len(),
348                got: bounds.len(),
349            });
350        }
351        for (i, &v) in c.iter().enumerate() {
352            if !v.is_finite() {
353                return Err(SolverError::NonFiniteCoefficient { field: "c", index: i });
354            }
355        }
356        for (i, &v) in b.iter().enumerate() {
357            if !v.is_finite() {
358                return Err(SolverError::NonFiniteCoefficient { field: "b", index: i });
359            }
360        }
361        for (i, &v) in a.values.iter().enumerate() {
362            if !v.is_finite() {
363                return Err(SolverError::NonFiniteCoefficient { field: "A", index: i });
364            }
365        }
366        for (i, &(lb, ub)) in bounds.iter().enumerate() {
367            if lb.is_nan() || ub.is_nan() || lb > ub {
368                return Err(SolverError::InvalidBounds { index: i, lb, ub });
369            }
370        }
371
372        Ok(LpProblem {
373            num_vars: c.len(),
374            num_constraints: b.len(),
375            c,
376            a,
377            b,
378            constraint_types,
379            bounds,
380            name,
381        })
382    }
383}
384
385impl fmt::Display for LpProblem {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        write!(
388            f,
389            "LP: min c^T x, {} vars, {} constraints",
390            self.num_vars, self.num_constraints
391        )
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::error::SolverError;
399
400    #[test]
401    fn test_lp_problem_new_valid() {
402        // 2 variables, 2 constraints
403        let c = vec![1.0, 2.0];
404        let a = CscMatrix::new(2, 2);
405        let b = vec![5.0, 6.0];
406
407        let lp = LpProblem::new(c, a, b).unwrap();
408        assert_eq!(lp.num_vars, 2);
409        assert_eq!(lp.num_constraints, 2);
410    }
411
412    #[test]
413    fn test_lp_problem_new_invalid_c_dimension() {
414        // c.len() = 3, but a.ncols = 2
415        let c = vec![1.0, 2.0, 3.0];
416        let a = CscMatrix::new(2, 2);
417        let b = vec![5.0, 6.0];
418
419        let result = LpProblem::new(c, a, b);
420        assert!(result.is_err());
421        assert!(matches!(
422            result.unwrap_err(),
423            SolverError::DimensionMismatch { field: "c", .. }
424        ));
425    }
426
427    #[test]
428    fn test_lp_problem_new_invalid_b_dimension() {
429        // b.len() = 3, but a.nrows = 2
430        let c = vec![1.0, 2.0];
431        let a = CscMatrix::new(2, 2);
432        let b = vec![5.0, 6.0, 7.0];
433
434        let result = LpProblem::new(c, a, b);
435        assert!(result.is_err());
436        assert!(matches!(
437            result.unwrap_err(),
438            SolverError::DimensionMismatch { field: "b", .. }
439        ));
440    }
441
442    #[test]
443    fn test_lp_problem_display() {
444        let c = vec![1.0, 2.0];
445        let a = CscMatrix::new(2, 2);
446        let b = vec![5.0, 6.0];
447        let lp = LpProblem::new(c, a, b).unwrap();
448
449        let display = format!("{}", lp);
450        assert_eq!(display, "LP: min c^T x, 2 vars, 2 constraints");
451    }
452
453    #[test]
454    fn test_solve_status_display() {
455        assert_eq!(format!("{}", SolveStatus::Optimal), "Optimal");
456        assert_eq!(format!("{}", SolveStatus::Infeasible), "Infeasible");
457        assert_eq!(format!("{}", SolveStatus::Unbounded), "Unbounded");
458    }
459
460    #[test]
461    fn test_solver_result_display() {
462        let result = SolverResult {
463            status: SolveStatus::Optimal,
464            objective: 42.5,
465            solution: vec![1.0, 2.0],
466            dual_solution: vec![],
467            reduced_costs: vec![],
468            slack: vec![],
469            warm_start_basis: None,
470            ..Default::default()
471        };
472        let display = format!("{}", result);
473        assert_eq!(display, "Status: Optimal, Objective: 42.5");
474    }
475
476    #[test]
477    fn solver_result_default_is_not_success() {
478        let result = SolverResult::default();
479        assert_eq!(result.status, SolveStatus::NumericalError);
480        assert!(result.solution.is_empty());
481    }
482
483    fn make_lp(c: Vec<f64>, b: Vec<f64>, a_vals: Vec<f64>, bounds: Vec<(f64, f64)>)
484        -> Result<LpProblem, SolverError>
485    {
486        let n = c.len();
487        let m = b.len();
488        let a = if a_vals.is_empty() {
489            CscMatrix::new(m, n)
490        } else {
491            let rows = vec![0usize; n];
492            let cols: Vec<usize> = (0..n).collect();
493            CscMatrix::from_triplets(&rows, &cols, &a_vals, m, n).unwrap()
494        };
495        let ct = vec![ConstraintType::Le; m];
496        LpProblem::new_general(c, a, b, ct, bounds, None)
497    }
498
499    #[test]
500    fn lp_valid_accepted() {
501        let res = make_lp(
502            vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0],
503            vec![(0.0, f64::INFINITY), (0.0, 10.0)],
504        );
505        assert!(res.is_ok());
506    }
507
508    #[test]
509    fn lp_nan_in_c_rejected() {
510        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
511        for bad in bad_vals {
512            let res = make_lp(vec![bad, 1.0], vec![5.0], vec![1.0, 1.0],
513                              vec![(0.0, f64::INFINITY); 2]);
514            assert!(
515                matches!(res, Err(SolverError::NonFiniteCoefficient { field: "c", .. })),
516                "expected NonFiniteCoefficient for c={bad}"
517            );
518        }
519    }
520
521    #[test]
522    fn lp_nan_in_b_rejected() {
523        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
524        for bad in bad_vals {
525            let res = make_lp(vec![1.0, 2.0], vec![bad], vec![1.0, 1.0],
526                              vec![(0.0, f64::INFINITY); 2]);
527            assert!(
528                matches!(res, Err(SolverError::NonFiniteCoefficient { field: "b", .. })),
529                "expected NonFiniteCoefficient for b={bad}"
530            );
531        }
532    }
533
534    #[test]
535    fn lp_nan_in_a_rejected() {
536        let n = 2;
537        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
538        for bad in bad_vals {
539            // from_triplets drops NaN via DROP_TOL; inject bad value directly.
540            let mut a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, n).unwrap();
541            a.values[0] = bad;
542            let res = LpProblem::new_general(
543                vec![1.0, 2.0], a, vec![5.0],
544                vec![ConstraintType::Le], vec![(0.0, f64::INFINITY); n], None,
545            );
546            assert!(
547                matches!(res, Err(SolverError::NonFiniteCoefficient { field: "A", .. })),
548                "expected NonFiniteCoefficient for A val={bad}"
549            );
550        }
551    }
552
553    #[test]
554    fn lp_nan_in_bounds_rejected() {
555        let cases: Vec<(f64, f64)> = vec![
556            (f64::NAN, 1.0),
557            (0.0, f64::NAN),
558            (f64::NAN, f64::NAN),
559        ];
560        for (lb, ub) in cases {
561            let res = make_lp(
562                vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0],
563                vec![(lb, ub), (0.0, f64::INFINITY)],
564            );
565            assert!(
566                matches!(res, Err(SolverError::InvalidBounds { index: 0, .. })),
567                "expected InvalidBounds for ({lb},{ub})"
568            );
569        }
570    }
571
572    #[test]
573    fn lp_lb_gt_ub_rejected() {
574        let cases: Vec<(f64, f64)> = vec![
575            (5.0, 1.0),
576            (1.0, 0.0),
577            (f64::INFINITY, f64::NEG_INFINITY),
578            (0.1, 0.0),
579        ];
580        for (lb, ub) in cases {
581            let res = make_lp(
582                vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0],
583                vec![(lb, ub), (0.0, f64::INFINITY)],
584            );
585            assert!(
586                matches!(res, Err(SolverError::InvalidBounds { .. })),
587                "expected InvalidBounds for lb={lb} ub={ub}"
588            );
589        }
590    }
591
592    #[test]
593    fn lp_inf_bounds_accepted() {
594        let res = make_lp(
595            vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0],
596            vec![(f64::NEG_INFINITY, f64::INFINITY), (0.0, f64::INFINITY)],
597        );
598        assert!(res.is_ok(), "±inf bounds should be valid");
599    }
600}