otspot-core 0.5.0

Core implementation for otspot (LP/QP/MIP solver) — published as a dependency of the otspot facade
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
//! LP問題定義モジュール
//!
//! 線形計画問題(LP)の構造定義・制約種別・ソルバー結果の表現を提供する。
//! 問題は標準形 `min c^T x  s.t.  Ax {<=,>=,=} b,  x in [lb, ub]` で定義される。

pub mod certificate;
use certificate::{BoundGapCertificate, OptimalCertificate};

use crate::error::SolverError;
use crate::options::WarmStartBasis;
use crate::sparse::CscMatrix;
use std::fmt;

/// Returns `true` if `(lb, ub)` is a valid bound pair.
///
/// Valid means: neither endpoint is NaN, `lb <= ub`, `lb < +∞`, and `ub > -∞`.
/// The last two conditions reject empty intervals that `lb > ub` misses when both
/// endpoints are the same infinity — e.g., `(+∞, +∞)` or `(-∞, -∞)`.
pub(crate) fn is_valid_bound_pair(lb: f64, ub: f64) -> bool {
    !lb.is_nan() && !ub.is_nan() && lb <= ub && lb < f64::INFINITY && ub > f64::NEG_INFINITY
}

/// LP問題における制約条件の種別
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConstraintType {
    /// 以下(<=)
    Le,
    /// 以上(>=)
    Ge,
    /// 等式(==)
    Eq,
}

/// Route taken by a solve call (populated per-result, race-free).
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum SolveRoute {
    /// Route not yet set (default for uninitialized results).
    #[default]
    Unknown,
    /// Called directly via `crate::lp::solve_lp_with`.
    LpDirect,
    /// LP forwarded from `solve_qp_with(Q=0)`.
    LpForwardedFromQp,
    /// QP solved via IPM (Q≠0).
    QpIpm,
}

/// Per-solve routing and warm-start statistics (race-free, per-result).
///
/// Replaces process-global `AtomicU64` counters so parallel tests observe
/// independent stats without reset/race issues.
#[derive(Debug, Clone, Default)]
pub struct SolveStats {
    /// Route taken for this solve.
    pub route: SolveRoute,
    /// Whether the solver stopped because the deadline (timeout_secs / deadline) was reached.
    ///
    /// `true` iff `result.status == SolveStatus::Timeout`. Deterministic sentinel for
    /// deadline-enforcement tests: assert this field instead of measuring wall time.
    pub deadline_triggered: bool,
    /// Whether the postsolve saddle-point Krylov IR was skipped because the solution
    /// already met the user tolerance (`kkt_already_pass`). Deterministic sentinel for
    /// the gate: removing the gate (always refine) flips this to `false`.
    pub postsolve_krylov_ir_skipped: bool,
    /// For LP solves via the QP dispatch (`LpForwardedFromQp`): `true` if the
    /// returned result came from the IPM, `false` if from simplex. Lets callers
    /// (e.g. benchmarks) label the actual route instead of a static size guess.
    pub lp_ipm_path: bool,
    /// LP simplex used the bounded-variable Eq+finite-UB Phase I path.
    ///
    /// This is a load-bearing route sentinel for large Netlib LPs whose upper
    /// bounds must stay as variable bounds instead of expanded UB rows.
    pub bounded_eq_ub_path: bool,
}

/// ソルバーの求解結果ステータス
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum SolveStatus {
    /// 最適解が求まった
    Optimal,
    /// 局所的最適解(非凸QP: 慣性修正付きIPMが収束したKKT点)
    ///
    /// Q行列が不定(indefinite)の場合、大域的最適性は保証されないが、
    /// KKT条件を満たす局所最適解またはサドル点が返される。
    /// 慣性修正(Gershgorin 評価から導出した δI 加算)により IPM を収束させた。
    LocallyOptimal,
    /// 問題が実行不可能(infeasible)
    Infeasible,
    /// 問題が非有界(unbounded)
    Unbounded,
    /// 反復回数上限に到達した(最適性未確認)
    MaxIterations,
    /// 解は見つかったが精度基準未達(偽Optimal検出: スケール解除後の残差超過)
    SuboptimalSolution,
    /// タイムアウト(timeout_secs を超過した)
    Timeout,
    /// 数値エラー(LDL分解失敗等、問題が数値的に解けない)
    NumericalError,
    /// Q行列が不定(非凸QP)。IPMはQ正半定値を前提とする。
    NonConvex(String),
    /// 非凸 QP の局所最適解 (= `solve_qp_global` 経由で incumbent あり、ε-global 証明なし)。
    ///
    /// BB driver が deadline / max_nodes / max_depth で打ち切られ、incumbent ある状態。
    /// `LocallyOptimal` (= IPM inertia 補正後の単発解) と区別して、caller が「探索打切」
    /// vs「単発 KKT 収束」を識別できる。`Optimal` には**含めない** (= global proof なし)。
    NonconvexLocal,
    /// 非凸 QP の大域 ε-最適解 (= `solve_qp_global` で gap_tol まで証明済み + Q が indefinite)。
    ///
    /// `Optimal` は「Q が PSD で IPM/BB が global 達成」専用に維持し、indefinite Q の場合は
    /// 本 variant で明示分離する (caller が「global 証明済」かを fact で判別)。
    NonconvexGlobal,
    /// Problem type not supported by this solver.
    ///
    /// Returned when the caller passes a problem that cannot be handled, e.g.
    /// a QCQP (quadratic constraints present) submitted to the QP/LP entry.
    NotSupported(String),
}

impl fmt::Display for SolveStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SolveStatus::Optimal => write!(f, "Optimal"),
            SolveStatus::LocallyOptimal => write!(f, "LocallyOptimal"),
            SolveStatus::Infeasible => write!(f, "Infeasible"),
            SolveStatus::Unbounded => write!(f, "Unbounded"),
            SolveStatus::MaxIterations => write!(f, "MaxIterations"),
            SolveStatus::SuboptimalSolution => write!(f, "SuboptimalSolution"),
            SolveStatus::Timeout => write!(f, "Timeout"),
            SolveStatus::NumericalError => write!(f, "NumericalError"),
            SolveStatus::NonConvex(msg) => write!(f, "NonConvex({})", msg),
            SolveStatus::NonconvexLocal => write!(f, "NonconvexLocal"),
            SolveStatus::NonconvexGlobal => write!(f, "NonconvexGlobal"),
            SolveStatus::NotSupported(msg) => write!(f, "NotSupported({})", msg),
        }
    }
}

/// LP/QP共通求解結果型
///
/// LP求解(Simplex等)と QP求解(AS/IPM/Concurrent)の両方で使用できる統一結果型。
/// LP固有フィールド(`reduced_costs`, `slack`, `warm_start_basis`)は QP求解時は空/None。
/// QP固有フィールド(`bound_duals`, `iterations`)は LP求解時は空/0。
#[derive(Debug, Clone)]
pub struct SolverResult {
    /// 求解ステータス
    pub status: SolveStatus,
    /// 最適目的関数値(最適解が存在する場合)
    pub objective: f64,
    /// 解ベクトル(最適解が存在する場合)
    pub solution: Vec<f64>,
    /// 双対変数ベクトル(各制約の影価格、最適解が存在する場合)
    pub dual_solution: Vec<f64>,
    // --- LP固有フィールド ---
    /// 被縮小費用ベクトル(各決定変数に対して、最適解が存在する場合)
    pub reduced_costs: Vec<f64>,
    /// スラック変数ベクトル(各制約のスラック b_i - a_i^T x、最適解が存在する場合)
    pub slack: Vec<f64>,
    /// warm-start用の基底情報(Optimal時のみ Some)
    pub warm_start_basis: Option<WarmStartBasis>,
    // --- QP固有フィールド ---
    /// Bound dual values (shadow prices for variable bounds).
    ///
    /// Maps to original variable indices via col_map.
    /// Empty if no bound constraints are active.
    ///
    /// - 除去変数 (presolveで固定された変数) の bound_dual = 0.0 (近似)
    /// - presolve tightening で追加された境界の dual は報告しない(元問題基準)
    /// - 配列順: `[lb_dual(j0), ..., lb_dual(j_{n_lb-1}), ub_dual(j0), ..., ub_dual(j_{n_ub-1})]`
    pub bound_duals: Vec<f64>,
    /// 反復回数(WSR実績回数)
    pub iterations: usize,
    /// 最終反復の残差実値 (pfeas, dfeas, duality_gap)。Optimal/MaxIterations時のみ Some。
    pub final_residuals: Option<(f64, f64, f64)>,
    /// 相対双対ギャップ (|p_obj - d_obj| / max(|p|,|d|,1))。
    /// IPPMM 内部の best-so-far に紐づく値。unscale_ipm_result の Suboptimal→Optimal 昇格ゲート用。
    /// None = 未計測(LP simplex 等 gap を持たない経路)。
    pub duality_gap_rel: Option<f64>,
    /// 各 phase の所要時間 (LP simplex 経路のみ、None なら未計測)。
    /// 「どこに時間が掛かっているか」事実観測用 (CLAUDE.md「順調に収束に向けて探索」)。
    pub timing_breakdown: Option<TimingBreakdown>,
    /// Postsolve が最終的に採用した y_orig の dfeas violation (bound-aware sup ノルム).
    /// LP simplex + presolve 経路のみ Some。caller (solve_with) が値が `PIVOT_TOL` を
    /// 超えるとき presolve=off で再解する fallback gate に使う (greenbea-class 問題対策)。
    pub postsolve_dfeas: Option<f64>,
    /// Per-solve routing and warm-start statistics (race-free).
    pub stats: SolveStats,
    /// Branch-and-bound gap certificate.
    ///
    /// Present iff the solver completed a B&B search with a fully authenticated gap
    /// (no `proof_uncertain` region, `within_gap` satisfied). `None` for direct LP/QP solves.
    pub bound_gap_cert: Option<BoundGapCertificate>,
    /// KKT optimality certificate — minted by `prove_optimal` on the B&B incumbent.
    ///
    /// Set when `finalize_proven` verifies all KKT conditions on the returned point.
    /// `None` for demoted (LocallyOptimal/NonconvexLocal) or non-B&B results.
    pub opt_cert: Option<OptimalCertificate>,
}

/// 各 phase 所要時間 (μs精度)。LP simplex と QP IPM の両経路で共用。
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct TimingBreakdown {
    // ── LP simplex 経路 ───────────────────────────────────────────────────────
    /// Presolve 全体 (run_presolve)
    pub presolve_us: u64,
    /// 縮約後 simplex 本体 (solve_without_presolve)
    pub solve_us: u64,
    /// Postsolve 総計 (run_postsolve / QP 経路では下記 4 field の合計)
    pub postsolve_us: u64,

    // ── QP IPM 経路: IPM 反復内部 ────────────────────────────────────────────
    /// KKT 行列 LDL 数値因子化の累計 (全 iteration 合計)
    pub ipm_factorize_us: u64,
    /// KKT solve (predictor/corrector/Gondzio) の累計
    pub ipm_solve_us: u64,
    /// LDL regularization retry の累計回数 (健全性プローブ失敗含む)
    pub ipm_reg_retries: u32,
    /// MINRES (iterative) backend が 1 回以上使われたか
    pub ipm_used_iterative: bool,

    // ── QP IPM 経路: postsolve 段階別 ────────────────────────────────────────
    /// postsolve_qp_with_dual_recovery (reduced → orig 空間写像)
    pub postsolve_map_us: u64,
    /// refine_postsolve_dual_lsq (元空間 y LSQ refine)
    pub postsolve_lsq_us: u64,
    /// refine_postsolve_recovery (Stage 0: SingletonRow 後退代入)
    pub postsolve_recovery_us: u64,
    /// refine_post_processing (Stage 1+2: primal projection / y-z refit)
    pub postsolve_refine_us: u64,
    /// refine_krylov_and_projection (saddle-point Krylov IR)
    pub postsolve_krylov_ir_us: u64,
}

impl Default for SolverResult {
    fn default() -> Self {
        SolverResult {
            status: SolveStatus::NumericalError,
            objective: 0.0,
            solution: vec![],
            dual_solution: vec![],
            reduced_costs: vec![],
            slack: vec![],
            warm_start_basis: None,
            bound_duals: vec![],
            iterations: 0,
            final_residuals: None,
            duality_gap_rel: None,
            timing_breakdown: None,
            postsolve_dfeas: None,
            stats: SolveStats::default(),
            bound_gap_cert: None,
            opt_cert: None,
        }
    }
}

impl fmt::Display for SolverResult {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Status: {}, Objective: {}", self.status, self.objective)
    }
}

/// 線形計画問題: min c^T x + obj_offset  s.t.  Ax {op} b,  x in [lb, ub]
///
/// 目的関数・制約行列・右辺ベクトル・変数上下限をまとめて保持する。
/// 制約種別(`<=`, `>=`, `=`)と変数ごとの上下限を個別に指定できる。
///
/// `obj_offset` は MPS/QPS の N 行 RHS (objective constant) に対応する。
/// `LpProblem::new` / `new_general` では 0.0 に初期化される。
#[derive(Debug, Clone)]
pub struct LpProblem {
    /// 目的関数係数ベクトル(長さ: `num_vars`)
    pub c: Vec<f64>,
    /// 制約行列(CSC形式、サイズ: `num_constraints` x `num_vars`)
    pub a: CscMatrix,
    /// 制約右辺ベクトル(長さ: `num_constraints`)
    pub b: Vec<f64>,
    /// 決定変数の数
    pub num_vars: usize,
    /// 制約式の数
    pub num_constraints: usize,
    /// 各制約の種別(長さ: `num_constraints`)
    pub constraint_types: Vec<ConstraintType>,
    /// 各変数の上下限 `(lower, upper)`(長さ: `num_vars`)
    pub bounds: Vec<(f64, f64)>,
    /// 問題名(オプション)
    pub name: Option<String>,
    /// 目的定数項 (MPS N-row RHS); 最適値 = c^T x* + obj_offset
    pub obj_offset: f64,
}

impl LpProblem {
    /// 新しいLP問題を検証付きで生成する(後方互換版)
    ///
    /// 標準形 `min c^T x  s.t.  Ax <= b,  x >= 0` を作成する。
    /// 全制約を `<=`、全変数の下限を 0・上限を `+∞` とする。
    ///
    /// # 引数
    /// * `c` - 目的関数係数ベクトル
    /// * `a` - 制約行列(CSC形式)
    /// * `b` - 制約右辺ベクトル
    ///
    /// # 戻り値
    /// * `Ok(LpProblem)` - 次元が有効な場合
    /// * `Err(String)` - 次元不一致などの検証エラー時
    pub fn new(c: Vec<f64>, a: CscMatrix, b: Vec<f64>) -> Result<Self, SolverError> {
        let num_vars = c.len();
        let num_constraints = b.len();

        // Set defaults for backward compatibility
        let constraint_types = vec![ConstraintType::Le; num_constraints];
        let bounds = vec![(0.0, f64::INFINITY); num_vars];
        let name = None;

        Self::new_general(c, a, b, constraint_types, bounds, name)
    }

    /// 制約種別と変数上下限を完全指定して新しいLP問題を生成する
    ///
    /// # 引数
    /// * `c` - 目的関数係数ベクトル
    /// * `a` - 制約行列(CSC形式)
    /// * `b` - 制約右辺ベクトル
    /// * `constraint_types` - 各制約の種別(`Le` / `Ge` / `Eq`)
    /// * `bounds` - 各変数の上下限 `(lower, upper)`
    /// * `name` - 問題名(オプション)
    ///
    /// # 戻り値
    /// * `Ok(LpProblem)` - 次元が有効な場合
    /// * `Err(String)` - 次元不一致などの検証エラー時
    pub fn new_general(
        c: Vec<f64>,
        a: CscMatrix,
        b: Vec<f64>,
        constraint_types: Vec<ConstraintType>,
        bounds: Vec<(f64, f64)>,
        name: Option<String>,
    ) -> Result<Self, SolverError> {
        // Validate dimensions
        if c.len() != a.ncols {
            return Err(SolverError::DimensionMismatch {
                field: "c",
                expected: a.ncols,
                got: c.len(),
            });
        }
        if b.len() != a.nrows {
            return Err(SolverError::DimensionMismatch {
                field: "b",
                expected: a.nrows,
                got: b.len(),
            });
        }
        if constraint_types.len() != b.len() {
            return Err(SolverError::DimensionMismatch {
                field: "constraint_types",
                expected: b.len(),
                got: constraint_types.len(),
            });
        }
        if bounds.len() != c.len() {
            return Err(SolverError::DimensionMismatch {
                field: "bounds",
                expected: c.len(),
                got: bounds.len(),
            });
        }
        for (i, &v) in c.iter().enumerate() {
            if !v.is_finite() {
                return Err(SolverError::NonFiniteCoefficient {
                    field: "c",
                    index: i,
                });
            }
        }
        for (i, &v) in b.iter().enumerate() {
            if !v.is_finite() {
                return Err(SolverError::NonFiniteCoefficient {
                    field: "b",
                    index: i,
                });
            }
        }
        for (i, &v) in a.values.iter().enumerate() {
            if !v.is_finite() {
                return Err(SolverError::NonFiniteCoefficient {
                    field: "A",
                    index: i,
                });
            }
        }
        for (i, &(lb, ub)) in bounds.iter().enumerate() {
            if !is_valid_bound_pair(lb, ub) {
                return Err(SolverError::InvalidBounds { index: i, lb, ub });
            }
        }

        Ok(LpProblem {
            num_vars: c.len(),
            num_constraints: b.len(),
            c,
            a,
            b,
            constraint_types,
            bounds,
            name,
            obj_offset: 0.0,
        })
    }
}

impl fmt::Display for LpProblem {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "LP: min c^T x, {} vars, {} constraints",
            self.num_vars, self.num_constraints
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::SolverError;

    #[test]
    fn test_lp_problem_new_valid() {
        // 2 variables, 2 constraints
        let c = vec![1.0, 2.0];
        let a = CscMatrix::new(2, 2);
        let b = vec![5.0, 6.0];

        let lp = LpProblem::new(c, a, b).unwrap();
        assert_eq!(lp.num_vars, 2);
        assert_eq!(lp.num_constraints, 2);
    }

    #[test]
    fn test_lp_problem_new_invalid_c_dimension() {
        // c.len() = 3, but a.ncols = 2
        let c = vec![1.0, 2.0, 3.0];
        let a = CscMatrix::new(2, 2);
        let b = vec![5.0, 6.0];

        let result = LpProblem::new(c, a, b);
        assert!(result.is_err());
        assert!(matches!(
            result.unwrap_err(),
            SolverError::DimensionMismatch { field: "c", .. }
        ));
    }

    #[test]
    fn test_lp_problem_new_invalid_b_dimension() {
        // b.len() = 3, but a.nrows = 2
        let c = vec![1.0, 2.0];
        let a = CscMatrix::new(2, 2);
        let b = vec![5.0, 6.0, 7.0];

        let result = LpProblem::new(c, a, b);
        assert!(result.is_err());
        assert!(matches!(
            result.unwrap_err(),
            SolverError::DimensionMismatch { field: "b", .. }
        ));
    }

    #[test]
    fn test_lp_problem_display() {
        let c = vec![1.0, 2.0];
        let a = CscMatrix::new(2, 2);
        let b = vec![5.0, 6.0];
        let lp = LpProblem::new(c, a, b).unwrap();

        let display = format!("{}", lp);
        assert_eq!(display, "LP: min c^T x, 2 vars, 2 constraints");
    }

    #[test]
    fn test_solve_status_display() {
        assert_eq!(format!("{}", SolveStatus::Optimal), "Optimal");
        assert_eq!(format!("{}", SolveStatus::Infeasible), "Infeasible");
        assert_eq!(format!("{}", SolveStatus::Unbounded), "Unbounded");
    }

    #[test]
    fn test_solver_result_display() {
        let result = SolverResult {
            status: SolveStatus::Optimal,
            objective: 42.5,
            solution: vec![1.0, 2.0],
            dual_solution: vec![],
            reduced_costs: vec![],
            slack: vec![],
            warm_start_basis: None,
            ..Default::default()
        };
        let display = format!("{}", result);
        assert_eq!(display, "Status: Optimal, Objective: 42.5");
    }

    #[test]
    fn solver_result_default_is_not_success() {
        let result = SolverResult::default();
        assert_eq!(result.status, SolveStatus::NumericalError);
        assert!(result.solution.is_empty());
    }

    fn make_lp(
        c: Vec<f64>,
        b: Vec<f64>,
        a_vals: Vec<f64>,
        bounds: Vec<(f64, f64)>,
    ) -> Result<LpProblem, SolverError> {
        let n = c.len();
        let m = b.len();
        let a = if a_vals.is_empty() {
            CscMatrix::new(m, n)
        } else {
            let rows = vec![0usize; n];
            let cols: Vec<usize> = (0..n).collect();
            CscMatrix::from_triplets(&rows, &cols, &a_vals, m, n).unwrap()
        };
        let ct = vec![ConstraintType::Le; m];
        LpProblem::new_general(c, a, b, ct, bounds, None)
    }

    #[test]
    fn lp_valid_accepted() {
        let res = make_lp(
            vec![1.0, 2.0],
            vec![5.0],
            vec![1.0, 1.0],
            vec![(0.0, f64::INFINITY), (0.0, 10.0)],
        );
        assert!(res.is_ok());
    }

    #[test]
    fn lp_nan_in_c_rejected() {
        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
        for bad in bad_vals {
            let res = make_lp(
                vec![bad, 1.0],
                vec![5.0],
                vec![1.0, 1.0],
                vec![(0.0, f64::INFINITY); 2],
            );
            assert!(
                matches!(
                    res,
                    Err(SolverError::NonFiniteCoefficient { field: "c", .. })
                ),
                "expected NonFiniteCoefficient for c={bad}"
            );
        }
    }

    #[test]
    fn lp_nan_in_b_rejected() {
        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
        for bad in bad_vals {
            let res = make_lp(
                vec![1.0, 2.0],
                vec![bad],
                vec![1.0, 1.0],
                vec![(0.0, f64::INFINITY); 2],
            );
            assert!(
                matches!(
                    res,
                    Err(SolverError::NonFiniteCoefficient { field: "b", .. })
                ),
                "expected NonFiniteCoefficient for b={bad}"
            );
        }
    }

    #[test]
    fn lp_nan_in_a_rejected() {
        let n = 2;
        let bad_vals = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
        for bad in bad_vals {
            // from_triplets drops NaN via DROP_TOL; inject bad value directly.
            let mut a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, n).unwrap();
            a.values[0] = bad;
            let res = LpProblem::new_general(
                vec![1.0, 2.0],
                a,
                vec![5.0],
                vec![ConstraintType::Le],
                vec![(0.0, f64::INFINITY); n],
                None,
            );
            assert!(
                matches!(
                    res,
                    Err(SolverError::NonFiniteCoefficient { field: "A", .. })
                ),
                "expected NonFiniteCoefficient for A val={bad}"
            );
        }
    }

    #[test]
    fn lp_nan_in_bounds_rejected() {
        let cases: Vec<(f64, f64)> = vec![(f64::NAN, 1.0), (0.0, f64::NAN), (f64::NAN, f64::NAN)];
        for (lb, ub) in cases {
            let res = make_lp(
                vec![1.0, 2.0],
                vec![5.0],
                vec![1.0, 1.0],
                vec![(lb, ub), (0.0, f64::INFINITY)],
            );
            assert!(
                matches!(res, Err(SolverError::InvalidBounds { index: 0, .. })),
                "expected InvalidBounds for ({lb},{ub})"
            );
        }
    }

    #[test]
    fn lp_lb_gt_ub_rejected() {
        let cases: Vec<(f64, f64)> = vec![
            (5.0, 1.0),
            (1.0, 0.0),
            (f64::INFINITY, f64::NEG_INFINITY),
            (0.1, 0.0),
        ];
        for (lb, ub) in cases {
            let res = make_lp(
                vec![1.0, 2.0],
                vec![5.0],
                vec![1.0, 1.0],
                vec![(lb, ub), (0.0, f64::INFINITY)],
            );
            assert!(
                matches!(res, Err(SolverError::InvalidBounds { .. })),
                "expected InvalidBounds for lb={lb} ub={ub}"
            );
        }
    }

    #[test]
    fn lp_inf_bounds_accepted() {
        let res = make_lp(
            vec![1.0, 2.0],
            vec![5.0],
            vec![1.0, 1.0],
            vec![(f64::NEG_INFINITY, f64::INFINITY), (0.0, f64::INFINITY)],
        );
        assert!(res.is_ok(), "±inf bounds should be valid");
    }

    // Bug C/D: (inf,inf) and (-inf,-inf) form empty intervals that lb>ub misses
    // because inf==inf and -inf==-inf. These must be rejected.
    #[test]
    fn lp_empty_interval_same_inf_rejected() {
        let cases: Vec<(f64, f64)> = vec![
            (f64::INFINITY, f64::INFINITY),
            (f64::NEG_INFINITY, f64::NEG_INFINITY),
        ];
        for (lb, ub) in cases {
            let res = make_lp(
                vec![1.0, 2.0],
                vec![5.0],
                vec![1.0, 1.0],
                vec![(lb, ub), (0.0, f64::INFINITY)],
            );
            assert!(
                matches!(res, Err(SolverError::InvalidBounds { index: 0, .. })),
                "expected InvalidBounds for ({lb},{ub})"
            );
        }
    }

    // Sentinel: valid half-infinite and fixed-var bounds must be accepted.
    // If bound validation is replaced by a no-op, fixed-var or half-inf cases
    // would still be Ok (no rejection) — these are *positive* sentinels that
    // confirm the validator doesn't over-reject.  The reject sentinels above
    // ensure no-op → FAIL.
    #[test]
    fn lp_valid_bound_variants_accepted() {
        let valid_cases: Vec<Vec<(f64, f64)>> = vec![
            vec![(f64::NEG_INFINITY, 5.0), (0.0, f64::INFINITY)],
            vec![(0.0, f64::INFINITY), (f64::NEG_INFINITY, f64::INFINITY)],
            vec![(3.0, 3.0), (0.0, 10.0)],
            vec![(0.0, 0.0), (0.0, 0.0)],
        ];
        for bounds in valid_cases {
            let res = make_lp(vec![1.0, 2.0], vec![5.0], vec![1.0, 1.0], bounds.clone());
            assert!(res.is_ok(), "expected Ok for bounds={bounds:?}");
        }
    }

    // Sentinel: lb=+inf (alone) and ub=-inf (alone) must each be rejected.
    #[test]
    fn lp_lone_inf_bound_rejected() {
        let cases: Vec<(f64, f64)> = vec![
            (f64::INFINITY, 5.0),
            (f64::INFINITY, f64::INFINITY),
            (0.0, f64::NEG_INFINITY),
            (f64::NEG_INFINITY, f64::NEG_INFINITY),
        ];
        for (lb, ub) in cases {
            let res = make_lp(
                vec![1.0, 2.0],
                vec![5.0],
                vec![1.0, 1.0],
                vec![(lb, ub), (0.0, f64::INFINITY)],
            );
            assert!(
                matches!(res, Err(SolverError::InvalidBounds { index: 0, .. })),
                "expected InvalidBounds for ({lb},{ub})"
            );
        }
    }
}