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;
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    /// 主実行可能性残差 (||Ax - b||_inf)。final_residuals と同値。デバッグ可視性向上用。
164    pub pfeas: Option<f64>,
165    /// 双対実行可能性残差。final_residuals と同値。デバッグ可視性向上用。
166    pub dfeas: Option<f64>,
167    /// 双対ギャップ (mu)。final_residuals と同値。デバッグ可視性向上用。
168    pub gap: Option<f64>,
169    /// 相対双対ギャップ (|p_obj - d_obj| / max(|p|,|d|,1))。
170    /// IPPMM 内部の best-so-far に紐づく値。unscale_ipm_result の Suboptimal→Optimal 昇格ゲート用。
171    /// None = 未計測(LP simplex 等 gap を持たない経路)。
172    pub duality_gap_rel: Option<f64>,
173    /// 各 phase の所要時間 (LP simplex 経路のみ、None なら未計測)。
174    /// 「どこに時間が掛かっているか」事実観測用 (CLAUDE.md「順調に収束に向けて探索」)。
175    pub timing_breakdown: Option<TimingBreakdown>,
176    /// Postsolve が最終的に採用した y_orig の dfeas violation (bound-aware sup ノルム).
177    /// LP simplex + presolve 経路のみ Some。caller (solve_with) が値が `PIVOT_TOL` を
178    /// 超えるとき presolve=off で再解する fallback gate に使う (greenbea-class 問題対策)。
179    pub postsolve_dfeas: Option<f64>,
180    /// Per-solve routing and warm-start statistics (race-free).
181    pub stats: SolveStats,
182    /// Branch-and-bound gap certificate.
183    ///
184    /// Present iff the solver completed a B&B search with a fully authenticated gap
185    /// (no `proof_uncertain` region, `within_gap` satisfied). `None` for direct LP/QP solves.
186    pub bound_gap_cert: Option<BoundGapCertificate>,
187}
188
189/// 各 phase 所要時間 (μs精度)。LP simplex と QP IPM の両経路で共用。
190#[derive(Debug, Clone, Copy, Default, PartialEq)]
191pub struct TimingBreakdown {
192    // ── LP simplex 経路 ───────────────────────────────────────────────────────
193    /// Presolve 全体 (run_presolve)
194    pub presolve_us: u64,
195    /// 縮約後 simplex 本体 (solve_without_presolve)
196    pub solve_us: u64,
197    /// Postsolve 総計 (run_postsolve / QP 経路では下記 4 field の合計)
198    pub postsolve_us: u64,
199
200    // ── QP IPM 経路: IPM 反復内部 ────────────────────────────────────────────
201    /// KKT 行列 LDL 数値因子化の累計 (全 iteration 合計)
202    pub ipm_factorize_us: u64,
203    /// KKT solve (predictor/corrector/Gondzio) の累計
204    pub ipm_solve_us: u64,
205    /// LDL regularization retry の累計回数 (健全性プローブ失敗含む)
206    pub ipm_reg_retries: u32,
207    /// MINRES (iterative) backend が 1 回以上使われたか
208    pub ipm_used_iterative: bool,
209
210    // ── QP IPM 経路: postsolve 段階別 ────────────────────────────────────────
211    /// postsolve_qp_with_dual_recovery (reduced → orig 空間写像)
212    pub postsolve_map_us: u64,
213    /// refine_postsolve_dual_lsq (元空間 y LSQ refine)
214    pub postsolve_lsq_us: u64,
215    /// refine_postsolve_recovery (Stage 0: SingletonRow 後退代入)
216    pub postsolve_recovery_us: u64,
217    /// refine_post_processing (Stage 1+2: primal projection / y-z refit)
218    pub postsolve_refine_us: u64,
219    /// refine_krylov_and_projection (saddle-point Krylov IR)
220    pub postsolve_krylov_ir_us: u64,
221}
222
223impl Default for SolverResult {
224    fn default() -> Self {
225        SolverResult {
226            status: SolveStatus::NumericalError,
227            objective: 0.0,
228            solution: vec![],
229            dual_solution: vec![],
230            reduced_costs: vec![],
231            slack: vec![],
232            warm_start_basis: None,
233            bound_duals: vec![],
234            iterations: 0,
235            final_residuals: None,
236            pfeas: None,
237            dfeas: None,
238            gap: None,
239            duality_gap_rel: None,
240            timing_breakdown: None,
241            postsolve_dfeas: None,
242            stats: SolveStats::default(),
243            bound_gap_cert: None,
244        }
245    }
246}
247
248impl fmt::Display for SolverResult {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        write!(f, "Status: {}, Objective: {}", self.status, self.objective)
251    }
252}
253
254/// 線形計画問題: min c^T x  s.t.  Ax {op} b,  x in [lb, ub]
255///
256/// 目的関数・制約行列・右辺ベクトル・変数上下限をまとめて保持する。
257/// 制約種別(`<=`, `>=`, `=`)と変数ごとの上下限を個別に指定できる。
258#[derive(Debug, Clone)]
259pub struct LpProblem {
260    /// 目的関数係数ベクトル(長さ: `num_vars`)
261    pub c: Vec<f64>,
262    /// 制約行列(CSC形式、サイズ: `num_constraints` x `num_vars`)
263    pub a: CscMatrix,
264    /// 制約右辺ベクトル(長さ: `num_constraints`)
265    pub b: Vec<f64>,
266    /// 決定変数の数
267    pub num_vars: usize,
268    /// 制約式の数
269    pub num_constraints: usize,
270    /// 各制約の種別(長さ: `num_constraints`)
271    pub constraint_types: Vec<ConstraintType>,
272    /// 各変数の上下限 `(lower, upper)`(長さ: `num_vars`)
273    pub bounds: Vec<(f64, f64)>,
274    /// 問題名(オプション)
275    pub name: Option<String>,
276}
277
278impl LpProblem {
279    /// 新しいLP問題を検証付きで生成する(後方互換版)
280    ///
281    /// 標準形 `min c^T x  s.t.  Ax <= b,  x >= 0` を作成する。
282    /// 全制約を `<=`、全変数の下限を 0・上限を `+∞` とする。
283    ///
284    /// # 引数
285    /// * `c` - 目的関数係数ベクトル
286    /// * `a` - 制約行列(CSC形式)
287    /// * `b` - 制約右辺ベクトル
288    ///
289    /// # 戻り値
290    /// * `Ok(LpProblem)` - 次元が有効な場合
291    /// * `Err(String)` - 次元不一致などの検証エラー時
292    pub fn new(c: Vec<f64>, a: CscMatrix, b: Vec<f64>) -> Result<Self, SolverError> {
293        let num_vars = c.len();
294        let num_constraints = b.len();
295
296        // Set defaults for backward compatibility
297        let constraint_types = vec![ConstraintType::Le; num_constraints];
298        let bounds = vec![(0.0, f64::INFINITY); num_vars];
299        let name = None;
300
301        Self::new_general(c, a, b, constraint_types, bounds, name)
302    }
303
304    /// 制約種別と変数上下限を完全指定して新しいLP問題を生成する
305    ///
306    /// # 引数
307    /// * `c` - 目的関数係数ベクトル
308    /// * `a` - 制約行列(CSC形式)
309    /// * `b` - 制約右辺ベクトル
310    /// * `constraint_types` - 各制約の種別(`Le` / `Ge` / `Eq`)
311    /// * `bounds` - 各変数の上下限 `(lower, upper)`
312    /// * `name` - 問題名(オプション)
313    ///
314    /// # 戻り値
315    /// * `Ok(LpProblem)` - 次元が有効な場合
316    /// * `Err(String)` - 次元不一致などの検証エラー時
317    pub fn new_general(
318        c: Vec<f64>,
319        a: CscMatrix,
320        b: Vec<f64>,
321        constraint_types: Vec<ConstraintType>,
322        bounds: Vec<(f64, f64)>,
323        name: Option<String>,
324    ) -> Result<Self, SolverError> {
325        // Validate dimensions
326        if c.len() != a.ncols {
327            return Err(SolverError::DimensionMismatch {
328                field: "c",
329                expected: a.ncols,
330                got: c.len(),
331            });
332        }
333        if b.len() != a.nrows {
334            return Err(SolverError::DimensionMismatch {
335                field: "b",
336                expected: a.nrows,
337                got: b.len(),
338            });
339        }
340        if constraint_types.len() != b.len() {
341            return Err(SolverError::DimensionMismatch {
342                field: "constraint_types",
343                expected: b.len(),
344                got: constraint_types.len(),
345            });
346        }
347        if bounds.len() != c.len() {
348            return Err(SolverError::DimensionMismatch {
349                field: "bounds",
350                expected: c.len(),
351                got: bounds.len(),
352            });
353        }
354        for (i, &v) in c.iter().enumerate() {
355            if !v.is_finite() {
356                return Err(SolverError::NonFiniteCoefficient { field: "c", index: i });
357            }
358        }
359        for (i, &v) in b.iter().enumerate() {
360            if !v.is_finite() {
361                return Err(SolverError::NonFiniteCoefficient { field: "b", index: i });
362            }
363        }
364        for (i, &v) in a.values.iter().enumerate() {
365            if !v.is_finite() {
366                return Err(SolverError::NonFiniteCoefficient { field: "A", index: i });
367            }
368        }
369        for (i, &(lb, ub)) in bounds.iter().enumerate() {
370            if lb.is_nan() || ub.is_nan() || lb > ub {
371                return Err(SolverError::InvalidBounds { index: i, lb, ub });
372            }
373        }
374
375        Ok(LpProblem {
376            num_vars: c.len(),
377            num_constraints: b.len(),
378            c,
379            a,
380            b,
381            constraint_types,
382            bounds,
383            name,
384        })
385    }
386}
387
388impl fmt::Display for LpProblem {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        write!(
391            f,
392            "LP: min c^T x, {} vars, {} constraints",
393            self.num_vars, self.num_constraints
394        )
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::error::SolverError;
402
403    #[test]
404    fn test_lp_problem_new_valid() {
405        // 2 variables, 2 constraints
406        let c = vec![1.0, 2.0];
407        let a = CscMatrix::new(2, 2);
408        let b = vec![5.0, 6.0];
409
410        let lp = LpProblem::new(c, a, b).unwrap();
411        assert_eq!(lp.num_vars, 2);
412        assert_eq!(lp.num_constraints, 2);
413    }
414
415    #[test]
416    fn test_lp_problem_new_invalid_c_dimension() {
417        // c.len() = 3, but a.ncols = 2
418        let c = vec![1.0, 2.0, 3.0];
419        let a = CscMatrix::new(2, 2);
420        let b = vec![5.0, 6.0];
421
422        let result = LpProblem::new(c, a, b);
423        assert!(result.is_err());
424        assert!(matches!(
425            result.unwrap_err(),
426            SolverError::DimensionMismatch { field: "c", .. }
427        ));
428    }
429
430    #[test]
431    fn test_lp_problem_new_invalid_b_dimension() {
432        // b.len() = 3, but a.nrows = 2
433        let c = vec![1.0, 2.0];
434        let a = CscMatrix::new(2, 2);
435        let b = vec![5.0, 6.0, 7.0];
436
437        let result = LpProblem::new(c, a, b);
438        assert!(result.is_err());
439        assert!(matches!(
440            result.unwrap_err(),
441            SolverError::DimensionMismatch { field: "b", .. }
442        ));
443    }
444
445    #[test]
446    fn test_lp_problem_display() {
447        let c = vec![1.0, 2.0];
448        let a = CscMatrix::new(2, 2);
449        let b = vec![5.0, 6.0];
450        let lp = LpProblem::new(c, a, b).unwrap();
451
452        let display = format!("{}", lp);
453        assert_eq!(display, "LP: min c^T x, 2 vars, 2 constraints");
454    }
455
456    #[test]
457    fn test_solve_status_display() {
458        assert_eq!(format!("{}", SolveStatus::Optimal), "Optimal");
459        assert_eq!(format!("{}", SolveStatus::Infeasible), "Infeasible");
460        assert_eq!(format!("{}", SolveStatus::Unbounded), "Unbounded");
461    }
462
463    #[test]
464    fn test_solver_result_display() {
465        let result = SolverResult {
466            status: SolveStatus::Optimal,
467            objective: 42.5,
468            solution: vec![1.0, 2.0],
469            dual_solution: vec![],
470            reduced_costs: vec![],
471            slack: vec![],
472            warm_start_basis: None,
473            ..Default::default()
474        };
475        let display = format!("{}", result);
476        assert_eq!(display, "Status: Optimal, Objective: 42.5");
477    }
478
479    #[test]
480    fn solver_result_default_is_not_success() {
481        let result = SolverResult::default();
482        assert_eq!(result.status, SolveStatus::NumericalError);
483        assert!(result.solution.is_empty());
484    }
485
486    fn make_lp(c: Vec<f64>, b: Vec<f64>, a_vals: Vec<f64>, bounds: Vec<(f64, f64)>)
487        -> Result<LpProblem, SolverError>
488    {
489        let n = c.len();
490        let m = b.len();
491        let a = if a_vals.is_empty() {
492            CscMatrix::new(m, n)
493        } else {
494            let rows = vec![0usize; n];
495            let cols: Vec<usize> = (0..n).collect();
496            CscMatrix::from_triplets(&rows, &cols, &a_vals, m, n).unwrap()
497        };
498        let ct = vec![ConstraintType::Le; m];
499        LpProblem::new_general(c, a, b, ct, bounds, None)
500    }
501
502    #[test]
503    fn lp_valid_accepted() {
504        let res = make_lp(
505            vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0],
506            vec![(0.0, f64::INFINITY), (0.0, 10.0)],
507        );
508        assert!(res.is_ok());
509    }
510
511    #[test]
512    fn lp_nan_in_c_rejected() {
513        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
514        for bad in bad_vals {
515            let res = make_lp(vec![bad, 1.0], vec![5.0], vec![1.0, 1.0],
516                              vec![(0.0, f64::INFINITY); 2]);
517            assert!(
518                matches!(res, Err(SolverError::NonFiniteCoefficient { field: "c", .. })),
519                "expected NonFiniteCoefficient for c={bad}"
520            );
521        }
522    }
523
524    #[test]
525    fn lp_nan_in_b_rejected() {
526        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
527        for bad in bad_vals {
528            let res = make_lp(vec![1.0, 2.0], vec![bad], vec![1.0, 1.0],
529                              vec![(0.0, f64::INFINITY); 2]);
530            assert!(
531                matches!(res, Err(SolverError::NonFiniteCoefficient { field: "b", .. })),
532                "expected NonFiniteCoefficient for b={bad}"
533            );
534        }
535    }
536
537    #[test]
538    fn lp_nan_in_a_rejected() {
539        let n = 2;
540        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
541        for bad in bad_vals {
542            // from_triplets drops NaN via DROP_TOL; inject bad value directly.
543            let mut a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, n).unwrap();
544            a.values[0] = bad;
545            let res = LpProblem::new_general(
546                vec![1.0, 2.0], a, vec![5.0],
547                vec![ConstraintType::Le], vec![(0.0, f64::INFINITY); n], None,
548            );
549            assert!(
550                matches!(res, Err(SolverError::NonFiniteCoefficient { field: "A", .. })),
551                "expected NonFiniteCoefficient for A val={bad}"
552            );
553        }
554    }
555
556    #[test]
557    fn lp_nan_in_bounds_rejected() {
558        let cases: Vec<(f64, f64)> = vec![
559            (f64::NAN, 1.0),
560            (0.0, f64::NAN),
561            (f64::NAN, f64::NAN),
562        ];
563        for (lb, ub) in cases {
564            let res = make_lp(
565                vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0],
566                vec![(lb, ub), (0.0, f64::INFINITY)],
567            );
568            assert!(
569                matches!(res, Err(SolverError::InvalidBounds { index: 0, .. })),
570                "expected InvalidBounds for ({lb},{ub})"
571            );
572        }
573    }
574
575    #[test]
576    fn lp_lb_gt_ub_rejected() {
577        let cases: Vec<(f64, f64)> = vec![
578            (5.0, 1.0),
579            (1.0, 0.0),
580            (f64::INFINITY, f64::NEG_INFINITY),
581            (0.1, 0.0),
582        ];
583        for (lb, ub) in cases {
584            let res = make_lp(
585                vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0],
586                vec![(lb, ub), (0.0, f64::INFINITY)],
587            );
588            assert!(
589                matches!(res, Err(SolverError::InvalidBounds { .. })),
590                "expected InvalidBounds for lb={lb} ub={ub}"
591            );
592        }
593    }
594
595    #[test]
596    fn lp_inf_bounds_accepted() {
597        let res = make_lp(
598            vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0],
599            vec![(f64::NEG_INFINITY, f64::INFINITY), (0.0, f64::INFINITY)],
600        );
601        assert!(res.is_ok(), "±inf bounds should be valid");
602    }
603}