Skip to main content

otspot_model/
model.rs

1//! High-level algebraic modeling API for linear programs.
2//!
3//! # Example
4//! ```
5//! use otspot_model::{Model, constraint};
6//!
7//! let mut model = Model::new("production");
8//! let x = model.add_var("x", 0.0, f64::INFINITY);
9//! let y = model.add_var("y", 0.0, 10.0);
10//! model.add_constraint(constraint!((2.0 * x + 3.0 * y) <= 12.0));
11//! model.add_constraint(constraint!((x + y) >= 3.0));
12//! model.minimize(x + 2.0 * y);
13//! let result = model.solve().unwrap();
14//! println!("x = {}", result[x]);
15//! ```
16
17use crate::constraint::{Constraint, ConstraintSense};
18use crate::expression::Expression;
19use crate::quad_expr::{quad_to_csc, QuadExpr};
20use crate::variable::{VarKind, Variable};
21
22use crate::variable::VariableDefinition;
23
24use otspot_core::options::Tolerance;
25use otspot_core::problem::{ConstraintType, LpProblem, SolveStatus};
26use otspot_core::sparse::CscMatrix;
27use std::collections::BTreeMap;
28use std::fmt;
29use std::ops::Index;
30use std::sync::atomic::{AtomicU64, Ordering};
31
32static NEXT_MODEL_ID: AtomicU64 = AtomicU64::new(1);
33
34// ---------------------------------------------------------------------------
35// Optimization sense
36// ---------------------------------------------------------------------------
37
38#[derive(Debug, Clone, Copy, PartialEq)]
39enum OptimizationSense {
40    Minimize,
41    Maximize,
42}
43
44// ---------------------------------------------------------------------------
45// Model
46// ---------------------------------------------------------------------------
47
48/// A linear programming model built using the algebraic modeling API.
49pub struct Model {
50    model_id: u64,
51    name: Option<String>,
52    variables: Vec<VariableDefinition>,
53    constraints: Vec<Constraint>,
54    objective: Option<Expression>,
55    sense: OptimizationSense,
56    /// Quadratic objective Q matrix for QP problems (None = LP mode).
57    /// Convention: min 1/2 x^T Q x + c^T x  ("1/2あり" standard).
58    quadratic_objective: Option<CscMatrix>,
59    /// Error message from the most recent DSL quad conversion failure (NaN/OOB
60    /// coefficient that `quad_to_csc` rejected).  Cleared at the **start** of
61    /// every `apply_objective` call so a subsequent valid `minimize`/`maximize`
62    /// always resets it.  Checked by `validate_objective` as the sole indicator
63    /// that the current DSL quad is invalid.
64    quad_dsl_error: Option<String>,
65    /// Constant term extracted from the objective expression (e.g. the `+4.0`
66    /// in `minimize(x*x - 4.0*x + 4.0)`).  Added to every `signed_obj` result
67    /// after the maximize sign-flip, independent of `obj_offset`.
68    /// Reset on every `minimize`/`maximize` call to avoid double-counting.
69    obj_expr_constant: f64,
70    invalid_inputs: BTreeMap<&'static str, String>,
71    /// Timeout for QP solve in seconds (None = unlimited).
72    timeout_secs: Option<f64>,
73    /// Ruiz スケーリング有効/無効(None = default true)
74    use_ruiz_scaling: Option<bool>,
75    /// 収束精度プリセット(None = デフォルト Medium = 1e-6)
76    tolerance: Option<Tolerance>,
77    /// Presolve 有効/無効(None = SolverOptions::default() に従う = true)
78    presolve: Option<bool>,
79    /// 並列 thread 上限。None = SolverOptions::default() = 1 (serial)。
80    threads: Option<usize>,
81    /// 目的関数定数オフセット (QP: 1/2 x^T Q x + c^T x + offset, LP: c^T x + offset)
82    obj_offset: f64,
83}
84
85impl Model {
86    /// Create a new, empty model with the given name.
87    pub fn new(name: &str) -> Self {
88        Model {
89            model_id: NEXT_MODEL_ID.fetch_add(1, Ordering::Relaxed),
90            name: Some(name.to_string()),
91            variables: Vec::new(),
92            constraints: Vec::new(),
93            objective: None,
94            sense: OptimizationSense::Minimize,
95            quadratic_objective: None,
96            quad_dsl_error: None,
97            obj_expr_constant: 0.0,
98            invalid_inputs: BTreeMap::new(),
99            timeout_secs: None,
100            use_ruiz_scaling: None,
101            tolerance: None,
102            presolve: None,
103            threads: None,
104            obj_offset: 0.0,
105        }
106    }
107
108    /// Set a timeout for QP solve operations.
109    pub fn set_timeout(&mut self, secs: f64) {
110        if let Err(err) = self.try_set_timeout(secs) {
111            self.record_input_error("timeout", err);
112        }
113    }
114
115    /// Set a timeout for solve operations, returning an error for invalid input.
116    pub fn try_set_timeout(&mut self, secs: f64) -> Result<&mut Self, ModelError> {
117        validate_timeout(secs)?;
118        self.timeout_secs = Some(secs);
119        self.invalid_inputs.remove("timeout");
120        Ok(self)
121    }
122
123    /// 並列 thread 上限を設定する。0 は 1 に補正、default は SolverOptions の 1。
124    /// LP / QP / 非凸 multistart すべてに影響する共通設定。
125    pub fn set_threads(&mut self, n: usize) -> &mut Self {
126        self.threads = Some(n.max(1));
127        self
128    }
129
130    /// Ruiz equilibration スケーリングの有効/無効を設定する(デフォルト: true)
131    pub fn set_use_ruiz_scaling(&mut self, flag: bool) {
132        self.use_ruiz_scaling = Some(flag);
133    }
134
135    /// 精度プリセットを設定する。
136    pub fn set_tolerance(&mut self, tol: Tolerance) -> &mut Self {
137        self.tolerance = Some(tol);
138        self
139    }
140
141    /// Presolve の有効/無効を設定する(デフォルト: true)。
142    pub fn set_presolve(&mut self, flag: bool) -> &mut Self {
143        self.presolve = Some(flag);
144        self
145    }
146
147    /// Add a decision variable to the model.
148    ///
149    /// Records an error (deferred to `solve()`) if `lb > ub` or either bound is NaN.
150    /// Use [`try_add_var`](Self::try_add_var) to get an immediate `Result`.
151    pub fn add_var(&mut self, name: &str, lb: f64, ub: f64) -> Variable {
152        match validate_bounds(lb, ub) {
153            Ok(()) => self.push_var(name, lb, ub, VarKind::Continuous),
154            Err(err) => {
155                self.record_input_error("variable_bounds", err);
156                self.push_var(name, 0.0, 0.0, VarKind::Continuous)
157            }
158        }
159    }
160
161    /// Add a decision variable, returning an error for invalid bounds.
162    pub fn try_add_var(&mut self, name: &str, lb: f64, ub: f64) -> Result<Variable, ModelError> {
163        validate_bounds(lb, ub)?;
164        Ok(self.push_var(name, lb, ub, VarKind::Continuous))
165    }
166
167    /// Add an integer decision variable (must take integral values within `[lb, ub]`).
168    ///
169    /// Records an error (deferred to `solve()`) if `lb > ub` or either bound is NaN.
170    /// Use [`try_add_int_var`](Self::try_add_int_var) to get an immediate `Result`.
171    ///
172    /// Presence of any integer/binary variable routes `solve()` through the
173    /// MILP/MIQP branch-and-bound solver instead of the continuous LP/QP path.
174    pub fn add_int_var(&mut self, name: &str, lb: f64, ub: f64) -> Variable {
175        match validate_bounds(lb, ub) {
176            Ok(()) => self.push_var(name, lb, ub, VarKind::Integer),
177            Err(err) => {
178                self.record_input_error("variable_bounds", err);
179                self.push_var(name, 0.0, 0.0, VarKind::Integer)
180            }
181        }
182    }
183
184    /// Add an integer decision variable, returning an error for invalid bounds.
185    pub fn try_add_int_var(&mut self, name: &str, lb: f64, ub: f64) -> Result<Variable, ModelError> {
186        validate_bounds(lb, ub)?;
187        Ok(self.push_var(name, lb, ub, VarKind::Integer))
188    }
189
190    /// Add a binary decision variable (integer, fixed to the `{0, 1}` box).
191    pub fn add_binary_var(&mut self, name: &str) -> Variable {
192        self.push_var(name, 0.0, 1.0, VarKind::Binary)
193    }
194
195    /// Return the name of a variable as given to [`add_var`](Self::add_var).
196    pub fn var_name(&self, var: Variable) -> &str {
197        &self.variables[var.index].name
198    }
199
200    fn push_var(&mut self, name: &str, lb: f64, ub: f64, kind: VarKind) -> Variable {
201        let index = self.variables.len();
202        self.variables.push(VariableDefinition {
203            name: name.to_string(),
204            lower_bound: lb,
205            upper_bound: ub,
206            kind,
207        });
208        Variable { index, model_id: self.model_id }
209    }
210
211    /// Add a constraint to the model.
212    pub fn add_constraint(&mut self, c: Constraint) -> &mut Self {
213        self.constraints.push(c);
214        self
215    }
216
217    /// Set the objective to minimize the given expression.
218    ///
219    /// Accepts any linear [`Expression`] or quadratic [`QuadExpr`] (e.g. `x * x`,
220    /// `x * y`, `x.pow2()`).  When quadratic terms are present the Q matrix is
221    /// built automatically using the 1/2 xᵀQx convention and the model is routed
222    /// through the QP solver.
223    pub fn minimize(&mut self, obj: impl Into<QuadExpr>) -> &mut Self {
224        self.apply_objective(obj.into(), OptimizationSense::Minimize)
225    }
226
227    /// Set the objective to maximize the given expression.
228    ///
229    /// See [`minimize`](Self::minimize) for details on quadratic support.
230    /// For maximization the Q matrix is negated internally (Q → -Q).
231    pub fn maximize(&mut self, obj: impl Into<QuadExpr>) -> &mut Self {
232        self.apply_objective(obj.into(), OptimizationSense::Maximize)
233    }
234
235    // -----------------------------------------------------------------------
236    // Quad-objective state machine — three private chokepoints.
237    //
238    // The quad state pair (`quadratic_objective` / `quad_dsl_error`) MUST
239    // only be mutated through the three functions below.  The companion
240    // `validate_objective` pure-function checks the *current* stored state at
241    // solve time so that setter-ordering bugs (P2-a … P2-i) cannot produce
242    // silent wrong results.
243    // -----------------------------------------------------------------------
244
245    /// Validate the current objective state.  Called once in `solve()` before
246    /// building the LP/QP/MIP problem so that the **actual objective being
247    /// solved** is always verified, regardless of setter order.
248    ///
249    /// Checks (pure function — no side effects):
250    /// - `quad_dsl_error` is None (no stale DSL quad conversion failure)
251    /// - Linear coefficient values are finite
252    /// - Linear variable `model_id`s match this model
253    /// - `obj_expr_constant` is finite
254    /// - Q matrix values (if present) are finite
255    fn validate_objective(&self) -> Result<(), ModelError> {
256        if let Some(msg) = &self.quad_dsl_error {
257            return Err(ModelError::InvalidInput(msg.clone()));
258        }
259        let Some(obj) = self.objective.as_ref() else {
260            return Ok(()); // NoObjective handled separately by caller
261        };
262        for (&var, &coef) in &obj.coefficients {
263            if var.model_id != self.model_id {
264                return Err(ModelError::InvalidInput(format!(
265                    "objective: linear term (var {}) belongs to model {}, not this model ({})",
266                    var.index, var.model_id, self.model_id
267                )));
268            }
269            if !coef.is_finite() {
270                return Err(ModelError::InvalidInput(format!(
271                    "objective: non-finite linear coefficient for var {}: {coef}",
272                    var.index
273                )));
274            }
275        }
276        if !self.obj_expr_constant.is_finite() {
277            return Err(ModelError::InvalidInput(format!(
278                "objective: non-finite constant term: {}",
279                self.obj_expr_constant
280            )));
281        }
282        if let Some(q) = &self.quadratic_objective {
283            if let Some(&v) = q.values().iter().find(|v| !v.is_finite()) {
284                return Err(ModelError::InvalidInput(format!(
285                    "objective: non-finite Q coefficient: {v}"
286                )));
287            }
288        }
289        Ok(())
290    }
291
292    /// Install a Q matrix and clear any stale DSL quad error.
293    fn install_quad(&mut self, q: CscMatrix) {
294        self.quadratic_objective = Some(q);
295        self.quad_dsl_error = None;
296    }
297
298    /// Record a DSL quad failure.  Clears Q and records the error for
299    /// `validate_objective` to surface at solve time.
300    fn fail_dsl_quad(&mut self, msg: String) {
301        self.quadratic_objective = None;
302        self.quad_dsl_error = Some(msg);
303    }
304
305    /// Transition to a pure-linear objective: clear any stored Q matrix.
306    fn replace_with_linear_objective(&mut self) {
307        self.quadratic_objective = None;
308    }
309
310    /// Return an error message if `q` contains any variable from a different
311    /// model, checking **both** linear coefficients and quad pairs (P2-d/P2-f).
312    /// Returns `None` when all variables belong to this model.
313    fn find_foreign_var_error(&self, q: &QuadExpr) -> Option<String> {
314        // Linear part: coefficient keys are Variable structs with model_id.
315        // The c-vector build reconstructs Variables with self.model_id, so
316        // foreign vars would be silently dropped — detect and reject instead.
317        if let Some(&v) = q.linear.coefficients.keys().find(|v| v.model_id != self.model_id) {
318            return Some(format!(
319                "linear term (var {}) belongs to model {}, not this model ({})",
320                v.index, v.model_id, self.model_id
321            ));
322        }
323        // Quad part
324        if let Some(&(va, vb)) = q.quad.keys().find(|(va, vb)| {
325            va.model_id != self.model_id || vb.model_id != self.model_id
326        }) {
327            return Some(format!(
328                "quad term ({},{}) belongs to model(s) {}/{}, not this model ({})",
329                va.index, vb.index, va.model_id, vb.model_id, self.model_id
330            ));
331        }
332        None
333    }
334
335    /// Shared implementation for `minimize`/`maximize`.
336    fn apply_objective(&mut self, q: QuadExpr, sense: OptimizationSense) -> &mut Self {
337        // Clear any stale DSL quad error from a previous call.  This is the
338        // key invariant: every `minimize`/`maximize` call starts with a clean
339        // slate so that a subsequent valid quad always replaces an earlier
340        // invalid one (P2-i root fix).
341        self.quad_dsl_error = None;
342        // Validate ALL variables (linear + quad) up front so foreign vars are
343        // always rejected, whether the expression is pure-linear, pure-quad, or
344        // mixed (P2-d: quad-only; P2-f: linear part was silently dropped).
345        if let Some(msg) = self.find_foreign_var_error(&q) {
346            self.fail_dsl_quad(msg);
347        } else if !q.quad.is_empty() {
348            match quad_to_csc(&q.quad, self.variables.len()) {
349                Ok(csc) => self.install_quad(csc),
350                Err(msg) => self.fail_dsl_quad(msg),
351            }
352        } else {
353            self.replace_with_linear_objective();
354        }
355        // Fold the objective constant (e.g. `+4.0` in `x*x - 4x + 4.0`) into
356        // obj_expr_constant.  Reset on every call so re-minimize never
357        // double-counts.
358        self.obj_expr_constant = q.linear.constant;
359        self.objective = Some(q.linear);
360        self.sense = sense;
361        self
362    }
363
364    /// Set the quadratic objective Q matrix for QP problems.
365    ///
366    /// **Convention** ("1/2あり"): the objective is min 1/2 x^T Q x + c^T x
367    /// 目的関数の定数オフセットを設定する。
368    /// `objective_value = (1/2 x^T Q x +) c^T x + offset` として最終結果に加算される。
369    pub fn set_obj_offset(&mut self, offset: f64) -> &mut Self {
370        self.obj_offset = offset;
371        self
372    }
373
374    /// Solve the model and return the result.
375    ///
376    /// # Errors
377    /// * `ModelError::NoObjective` if `minimize` or `maximize` was not called.
378    /// * `ModelError::SolveError` if the solver returns Infeasible or Unbounded.
379    pub fn solve(&mut self) -> Result<ModelResult, ModelError> {
380        if let Some(msg) = self.invalid_inputs.values().next() {
381            return Err(ModelError::InvalidInput(msg.clone()));
382        }
383
384        let obj_expr = self.objective.as_ref().ok_or(ModelError::NoObjective)?;
385
386        // Validate the current objective against the actual stored state.
387        // This is the primary defense for all P2-* objective bugs: foreign
388        // variables, non-finite coefficients, non-finite constant, non-finite Q.
389        // Called here so it covers LP, QP, and MIP build paths with one check.
390        self.validate_objective()?;
391
392        let num_vars = self.variables.len();
393        let num_constraints = self.constraints.len();
394
395        // --- Build objective vector c ---
396        let mid = self.model_id;
397        let mut c: Vec<f64> = (0..num_vars)
398            .map(|i| obj_expr.coefficient(Variable { index: i, model_id: mid }))
399            .collect();
400
401        // For maximization, negate c (solver minimizes by default)
402        if self.sense == OptimizationSense::Maximize {
403            for ci in &mut c {
404                *ci = -*ci;
405            }
406        }
407
408        // --- Build constraint matrix A (triplets) ---
409        let mut trip_rows: Vec<usize> = Vec::new();
410        let mut trip_cols: Vec<usize> = Vec::new();
411        let mut trip_vals: Vec<f64> = Vec::new();
412        let mut b: Vec<f64> = Vec::new();
413        let mut constraint_types: Vec<ConstraintType> = Vec::new();
414
415        for (i, con) in self.constraints.iter().enumerate() {
416            for (&var, &coeff) in &con.lhs.coefficients {
417                trip_rows.push(i);
418                trip_cols.push(var.index);
419                trip_vals.push(coeff);
420            }
421            // lhs has no constant (normalized), rhs is con.rhs
422            b.push(con.rhs);
423            constraint_types.push(match con.sense {
424                ConstraintSense::Le => ConstraintType::Le,
425                ConstraintSense::Ge => ConstraintType::Ge,
426                ConstraintSense::Eq => ConstraintType::Eq,
427            });
428        }
429
430        // Handle zero-constraint case (empty matrix)
431        let a = if num_constraints == 0 {
432            CscMatrix::new(0, num_vars)
433        } else {
434            CscMatrix::from_triplets(
435                &trip_rows,
436                &trip_cols,
437                &trip_vals,
438                num_constraints,
439                num_vars,
440            )
441            .map_err(|e| ModelError::Internal(e.to_string()))?
442        };
443
444        // --- Variable bounds ---
445        let bounds: Vec<(f64, f64)> = self
446            .variables
447            .iter()
448            .map(|v| (v.lower_bound, v.upper_bound))
449            .collect();
450
451        // --- MIP path: any integer/binary variable routes through branch-and-bound ---
452        // (degenerate "no integer var" falls through to the existing LP/QP paths below.)
453        let integer_vars: Vec<usize> = self
454            .variables
455            .iter()
456            .enumerate()
457            .filter(|(_, v)| v.kind != VarKind::Continuous)
458            .map(|(i, _)| i)
459            .collect();
460        if !integer_vars.is_empty() {
461            return self.solve_mip_internal(c, a, b, constraint_types, bounds, integer_vars);
462        }
463
464        // --- QP path ---
465        if let Some(ref q_orig) = self.quadratic_objective.clone() {
466            return self.solve_qp_internal(c, a, b, bounds, q_orig.clone(), num_constraints);
467        }
468
469        // --- LP path (existing) ---
470        let problem = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
471            .map_err(map_lp_build_err)?;
472
473        let mut lp_opts = otspot_core::options::SolverOptions::default();
474        if let Some(t) = self.timeout_secs {
475            lp_opts.timeout_secs = Some(t);
476        }
477        if let Some(tol) = self.tolerance {
478            lp_opts.tolerance = Some(tol);
479        }
480        if let Some(flag) = self.presolve {
481            lp_opts.presolve = flag;
482        }
483        if let Some(n) = self.threads {
484            lp_opts.threads = n;
485        }
486        let solver_result = otspot_core::lp::solve_lp_with(&problem, &lp_opts);
487
488        // SolverResult の dual/rc/slack は extract_dual_info によって
489        // 元の制約空間 (Eq/Ge/Le) と変数空間 (bounds 込み) で復元済み。
490        let lp_extras = |sr: &otspot_core::problem::SolverResult| {
491            let dual = if sr.dual_solution.is_empty() {
492                None
493            } else {
494                Some(sr.dual_solution.clone())
495            };
496            let rc = if sr.reduced_costs.is_empty() {
497                None
498            } else {
499                Some(sr.reduced_costs.clone())
500            };
501            let slack = if sr.slack.is_empty() {
502                None
503            } else {
504                Some(sr.slack.clone())
505            };
506            (dual, rc, slack)
507        };
508
509        let signed_obj = |raw: f64| -> f64 {
510            let oriented = if self.sense == OptimizationSense::Maximize {
511                -raw
512            } else {
513                raw
514            };
515            oriented + self.obj_offset + self.obj_expr_constant
516        };
517        let lp_model_id = self.model_id;
518        let build_ok = |sr: otspot_core::problem::SolverResult| {
519            let (dual, rc, slack) = lp_extras(&sr);
520            let status = sr.status.clone();
521            ModelResult {
522                model_id: lp_model_id,
523                status: status.clone(),
524                proof: SolutionProof::from_status(&status),
525                objective_value: signed_obj(sr.objective),
526                solution: sr.solution,
527                dual_solution: dual,
528                reduced_costs: rc,
529                slack,
530                bound_duals: sr.bound_duals,
531                stats: sr.stats,
532            }
533        };
534
535        match solver_result.status {
536            SolveStatus::Optimal => Ok(build_ok(solver_result)),
537            SolveStatus::MaxIterations => {
538                if solver_result.solution.is_empty() {
539                    Err(ModelError::SolveError(SolveError::MaxIterations))
540                } else {
541                    Ok(build_ok(solver_result))
542                }
543            }
544            SolveStatus::SuboptimalSolution => {
545                if solver_result.solution.is_empty() {
546                    Err(ModelError::Timeout)
547                } else {
548                    Ok(build_ok(solver_result))
549                }
550            }
551            SolveStatus::Timeout => Err(ModelError::Timeout),
552            SolveStatus::LocallyOptimal
553            | SolveStatus::NonconvexLocal
554            | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
555                "Unexpected nonconvex status on LP path".to_string(),
556            )),
557            s => Err(classify_status_error(s)
558                .unwrap_or_else(|| ModelError::Internal("unhandled LP status".to_string()))),
559        }
560    }
561
562    /// Build a `QpProblem` from the model (constraint matrix + maximize Q→-Q
563    /// negation + offset removal). Shared by the QP and MIQP paths. `c` is already
564    /// negated by `solve()` for maximize.
565    fn build_qp_problem(
566        &self,
567        c: Vec<f64>,
568        bounds: Vec<(f64, f64)>,
569        q_orig: CscMatrix,
570    ) -> Result<otspot_core::qp::QpProblem, ModelError> {
571        use otspot_core::qp::QpProblem;
572
573        let num_vars = self.variables.len();
574
575        // maximize QP: negate Q (Q→-Q), c is already negated by solve()
576        let qp_q = if self.sense == OptimizationSense::Maximize {
577            q_orig.scale_values(-1.0)
578        } else {
579            q_orig
580        };
581
582        // --- 制約型変換: Le/Ge/Eq をそのまま QpProblem に渡す ---
583        // QP/IPMソルバーは ConstraintType をネイティブに処理する(to_all_le() 廃止済み)。
584        let mut qp_trip_rows: Vec<usize> = Vec::new();
585        let mut qp_trip_cols: Vec<usize> = Vec::new();
586        let mut qp_trip_vals: Vec<f64> = Vec::new();
587        let mut qp_b: Vec<f64> = Vec::new();
588        let mut qp_constraint_types: Vec<ConstraintType> = Vec::new();
589        let mut qp_row = 0usize;
590
591        for con in &self.constraints {
592            for (&var, &coeff) in &con.lhs.coefficients {
593                qp_trip_rows.push(qp_row);
594                qp_trip_cols.push(var.index);
595                qp_trip_vals.push(coeff);
596            }
597            qp_b.push(con.rhs);
598            qp_constraint_types.push(match con.sense {
599                ConstraintSense::Le => ConstraintType::Le,
600                ConstraintSense::Ge => ConstraintType::Ge,
601                ConstraintSense::Eq => ConstraintType::Eq,
602            });
603            qp_row += 1;
604        }
605
606        let m_qp = qp_row;
607        let qp_a = if m_qp == 0 {
608            CscMatrix::new(0, num_vars)
609        } else {
610            CscMatrix::from_triplets(&qp_trip_rows, &qp_trip_cols, &qp_trip_vals, m_qp, num_vars)
611                .map_err(|e| ModelError::Internal(e.to_string()))?
612        };
613
614        let mut qp_problem = QpProblem::new(qp_q, c, qp_a, qp_b, bounds, qp_constraint_types)
615            .map_err(map_qp_build_err)?;
616        // offset は signed_obj で post-solve 加算するため solver には渡さない。
617        qp_problem.obj_offset = 0.0;
618        Ok(qp_problem)
619    }
620
621    /// QP内部求解ロジック(QpProblem構築・求解・結果変換)
622    fn solve_qp_internal(
623        &self,
624        c: Vec<f64>,
625        _lp_a: CscMatrix,
626        _lp_b: Vec<f64>,
627        bounds: Vec<(f64, f64)>,
628        q_orig: CscMatrix,
629        num_model_constraints: usize,
630    ) -> Result<ModelResult, ModelError> {
631        let qp_problem = self.build_qp_problem(c, bounds, q_orig)?;
632
633        let mut opts = otspot_core::options::SolverOptions::default();
634        if let Some(t) = self.timeout_secs {
635            opts.timeout_secs = Some(t);
636        }
637        if let Some(flag) = self.use_ruiz_scaling {
638            opts.use_ruiz_scaling = flag;
639        }
640        if let Some(tol) = self.tolerance {
641            opts.tolerance = Some(tol);
642        }
643        if let Some(n) = self.threads {
644            opts.threads = n;
645        }
646        let qp_result = otspot_core::qp::solve_qp_with(&qp_problem, &opts);
647        let qp_stats = qp_result.stats.clone();
648
649        // dual_solution: Le=そのまま / Ge=符号反転済み / Eq=μ1-μ2 折り畳み済み。
650        let fold_dual = |sol: &[f64]| -> Option<Vec<f64>> {
651            if sol.len() == num_model_constraints {
652                Some(sol.to_vec())
653            } else if !sol.is_empty() && num_model_constraints > 0 {
654                let take = num_model_constraints.min(sol.len());
655                Some(sol[..take].to_vec())
656            } else {
657                None
658            }
659        };
660
661        let signed_obj = |raw: f64| -> f64 {
662            let oriented = if self.sense == OptimizationSense::Maximize {
663                -raw
664            } else {
665                raw
666            };
667            oriented + self.obj_offset + self.obj_expr_constant
668        };
669        let qp_model_id = self.model_id;
670        let build_ok = |status: SolveStatus,
671                        raw_obj: f64,
672                        sol: Vec<f64>,
673                        dual: Option<Vec<f64>>,
674                        bd: Vec<f64>| {
675            let proof = SolutionProof::from_status(&status);
676            ModelResult {
677                model_id: qp_model_id,
678                status,
679                proof,
680                objective_value: signed_obj(raw_obj),
681                solution: sol,
682                dual_solution: dual,
683                reduced_costs: None,
684                slack: None,
685                bound_duals: bd,
686                stats: qp_stats.clone(),
687            }
688        };
689
690        match qp_result.status {
691            SolveStatus::Optimal => Ok(build_ok(
692                SolveStatus::Optimal,
693                qp_result.objective,
694                qp_result.solution,
695                fold_dual(&qp_result.dual_solution),
696                qp_result.bound_duals,
697            )),
698            SolveStatus::MaxIterations => {
699                if qp_result.solution.is_empty() {
700                    Err(ModelError::SolveError(SolveError::MaxIterations))
701                } else {
702                    Ok(build_ok(
703                        SolveStatus::MaxIterations,
704                        qp_result.objective,
705                        qp_result.solution,
706                        fold_dual(&qp_result.dual_solution),
707                        qp_result.bound_duals,
708                    ))
709                }
710            }
711            SolveStatus::SuboptimalSolution => {
712                // apply_api_boundary_conversion が通常 Optimal/Timeout に変換済み。予備パス。
713                if qp_result.solution.is_empty() {
714                    Err(ModelError::Timeout)
715                } else {
716                    Ok(build_ok(
717                        SolveStatus::SuboptimalSolution,
718                        qp_result.objective,
719                        qp_result.solution,
720                        fold_dual(&qp_result.dual_solution),
721                        qp_result.bound_duals,
722                    ))
723                }
724            }
725            SolveStatus::Timeout => Err(ModelError::Timeout),
726            // LocallyOptimal / NonconvexLocal / NonconvexGlobal: global proof なしだが解あり。
727            // Model API では caller が status で品質を判断する。NonconvexGlobal は global 証明済。
728            status @ (SolveStatus::LocallyOptimal
729            | SolveStatus::NonconvexLocal
730            | SolveStatus::NonconvexGlobal) => Ok(build_ok(
731                status,
732                qp_result.objective,
733                qp_result.solution,
734                fold_dual(&qp_result.dual_solution),
735                qp_result.bound_duals,
736            )),
737            s => Err(classify_status_error(s)
738                .unwrap_or_else(|| ModelError::Internal("unhandled QP status".to_string()))),
739        }
740    }
741
742    /// MILP/MIQP 内部求解: 整数変数があるとき `solve()` から dispatch される。
743    ///
744    /// 二次目的なし → MILP (各 B&B node で LP relaxation)。二次目的あり → **凸** MIQP
745    /// (各 node で QP relaxation)。非凸 (Q 非PSD) は `solve_miqp` が `NonConvex` を返し、
746    /// ここで明示エラーに変換する (silent 誤答禁止)。
747    fn solve_mip_internal(
748        &self,
749        c: Vec<f64>,
750        a: CscMatrix,
751        b: Vec<f64>,
752        constraint_types: Vec<ConstraintType>,
753        bounds: Vec<(f64, f64)>,
754        integer_vars: Vec<usize>,
755    ) -> Result<ModelResult, ModelError> {
756        let mut opts = otspot_core::options::SolverOptions::default();
757        if let Some(t) = self.timeout_secs {
758            opts.timeout_secs = Some(t);
759        }
760        if let Some(tol) = self.tolerance {
761            opts.tolerance = Some(tol);
762        }
763        if let Some(n) = self.threads {
764            opts.threads = n;
765        }
766        let cfg = otspot_core::options::MipConfig::default();
767
768        let result = if let Some(ref q_orig) = self.quadratic_objective.clone() {
769            // MIQP: convex QP relaxation per node.
770            if let Some(flag) = self.use_ruiz_scaling {
771                opts.use_ruiz_scaling = flag;
772            }
773            let qp = self.build_qp_problem(c, bounds, q_orig.clone())?;
774            let miqp = otspot_core::mip::MiqpProblem::new(qp, integer_vars.clone())
775                .map_err(|e| ModelError::Internal(e.to_string()))?;
776            otspot_core::mip::solve_miqp(&miqp, &opts, &cfg)
777        } else {
778            // MILP: LP relaxation per node.
779            if let Some(flag) = self.presolve {
780                opts.presolve = flag;
781            }
782            let lp = LpProblem::new_general(c, a, b, constraint_types, bounds, self.name.clone())
783                .map_err(map_lp_build_err)?;
784            let milp = otspot_core::mip::MilpProblem::new(lp, integer_vars.clone())
785                .map_err(|e| ModelError::Internal(e.to_string()))?;
786            otspot_core::mip::solve_milp(&milp, &opts, &cfg)
787        };
788
789        self.finish_mip(result, &integer_vars)
790    }
791
792    /// Convert a MIP `SolverResult` to a `ModelResult`: apply objective sign /
793    /// offset, round integer components, and map the status. Shared by MILP/MIQP.
794    fn finish_mip(
795        &self,
796        result: otspot_core::problem::SolverResult,
797        integer_vars: &[usize],
798    ) -> Result<ModelResult, ModelError> {
799        let signed_obj = |raw: f64| -> f64 {
800            let oriented = if self.sense == OptimizationSense::Maximize {
801                -raw
802            } else {
803                raw
804            };
805            oriented + self.obj_offset + self.obj_expr_constant
806        };
807
808        // 整数変数成分を厳密整数に丸める (relaxation 解の 1e-6 級 noise を除去)。
809        let round_integers = |mut sol: Vec<f64>| -> Vec<f64> {
810            for &j in integer_vars {
811                if j < sol.len() {
812                    sol[j] = sol[j].round();
813                }
814            }
815            sol
816        };
817
818        let mip_model_id = self.model_id;
819        let build_ok = |sr: otspot_core::problem::SolverResult| {
820            let status = sr.status.clone();
821            ModelResult {
822                model_id: mip_model_id,
823                status: status.clone(),
824                proof: SolutionProof::from_status(&status),
825                objective_value: signed_obj(sr.objective),
826                solution: round_integers(sr.solution),
827                dual_solution: None,
828                reduced_costs: None,
829                slack: None,
830                bound_duals: vec![],
831                stats: sr.stats,
832            }
833        };
834
835        match result.status {
836            SolveStatus::Optimal => Ok(build_ok(result)),
837            SolveStatus::Timeout => {
838                // 打ち切りでも incumbent (整数実行可能解) があれば解を返す。
839                if result.solution.is_empty() {
840                    Err(ModelError::Timeout)
841                } else {
842                    Ok(build_ok(result))
843                }
844            }
845            SolveStatus::SuboptimalSolution | SolveStatus::MaxIterations => {
846                if result.solution.is_empty() {
847                    Err(ModelError::SolveError(SolveError::MaxIterations))
848                } else {
849                    Ok(build_ok(result))
850                }
851            }
852            SolveStatus::LocallyOptimal
853            | SolveStatus::NonconvexLocal
854            | SolveStatus::NonconvexGlobal => Err(ModelError::Internal(
855                "Unexpected nonconvex status on MIP path".to_string(),
856            )),
857            s => Err(classify_status_error(s)
858                .unwrap_or_else(|| ModelError::Internal("unhandled MIP status".to_string()))),
859        }
860    }
861
862    fn record_input_error(&mut self, key: &'static str, err: ModelError) {
863        let msg = match err {
864            ModelError::InvalidInput(msg) => msg,
865            other => other.to_string(),
866        };
867        self.invalid_inputs.insert(key, msg);
868    }
869}
870
871/// Maps `SolveStatus` variants that always produce an error to the corresponding
872/// `ModelError`. Returns `None` for statuses that may produce a successful result
873/// depending on context (e.g. `MaxIterations` with a non-empty solution).
874///
875/// Used by all three dispatch paths (LP / QP / MIP) to eliminate duplicated
876/// match arms for `Infeasible`, `Unbounded`, `NumericalError`, `NonConvex`, and
877/// `NotSupported`.
878fn classify_status_error(status: SolveStatus) -> Option<ModelError> {
879    match status {
880        SolveStatus::Infeasible => Some(ModelError::SolveError(SolveError::Infeasible)),
881        SolveStatus::Unbounded => Some(ModelError::SolveError(SolveError::Unbounded)),
882        SolveStatus::NumericalError => Some(ModelError::SolveError(SolveError::NumericalError)),
883        SolveStatus::NonConvex(msg) => Some(ModelError::NonConvex(msg)),
884        SolveStatus::NotSupported(msg) => Some(ModelError::NotSupported(msg)),
885        // Ok-capable or context-dependent statuses: returned as `None` so the
886        // caller's explicit match arms decide, never as a hard error here.
887        SolveStatus::Optimal
888        | SolveStatus::LocallyOptimal
889        | SolveStatus::MaxIterations
890        | SolveStatus::SuboptimalSolution
891        | SolveStatus::Timeout
892        | SolveStatus::NonconvexLocal
893        | SolveStatus::NonconvexGlobal => None,
894        // `SolveStatus` is `#[non_exhaustive]` and lives in another crate
895        // (otspot-core), so a wildcard is mandatory here — the compile-time
896        // exhaustiveness guard the same-crate version had is lost to the crate
897        // split. Returning `None` is safe: every caller wraps this in
898        // `unwrap_or_else(|| ModelError::Internal(..))`, so an unknown future
899        // variant surfaces as `Err`, never a silent success / false Optimal.
900        _ => None,
901    }
902}
903
904fn validate_timeout(secs: f64) -> Result<(), ModelError> {
905    if secs.is_finite() && secs >= 0.0 {
906        Ok(())
907    } else {
908        Err(ModelError::InvalidInput(format!(
909            "timeout must be finite and non-negative, got {secs}"
910        )))
911    }
912}
913
914/// Map a low-level `LpProblem`/`SolverError` construction failure to a `ModelError`.
915///
916/// Non-finite coefficients and invalid bounds are user-input errors → `InvalidInput`;
917/// dimension/structural failures (which the Model builder controls) stay `Internal`.
918fn map_lp_build_err(e: otspot_core::error::SolverError) -> ModelError {
919    use otspot_core::error::SolverError;
920    match e {
921        SolverError::NonFiniteCoefficient { .. } | SolverError::InvalidBounds { .. } => {
922            ModelError::InvalidInput(e.to_string())
923        }
924        _ => ModelError::Internal(e.to_string()),
925    }
926}
927
928/// Map a low-level `QpProblem` construction failure to a `ModelError` (see [`map_lp_build_err`]).
929fn map_qp_build_err(e: otspot_core::qp::QpProblemError) -> ModelError {
930    use otspot_core::qp::QpProblemError;
931    match e {
932        QpProblemError::NonFiniteCoefficient { .. }
933        | QpProblemError::InvalidBounds { .. }
934        | QpProblemError::TripletIndexOutOfBounds { .. } => {
935            ModelError::InvalidInput(e.to_string())
936        }
937        QpProblemError::DimensionMismatch(_) => ModelError::Internal(e.to_string()),
938        // #[non_exhaustive]: wildcard required for cross-crate matching.
939        _ => ModelError::Internal(e.to_string()),
940    }
941}
942
943fn validate_bounds(lb: f64, ub: f64) -> Result<(), ModelError> {
944    if lb.is_nan() || ub.is_nan() {
945        return Err(ModelError::InvalidInput(format!(
946            "variable bounds must not be NaN: lb={lb}, ub={ub}"
947        )));
948    }
949    if lb > ub {
950        return Err(ModelError::InvalidInput(format!(
951            "variable lower bound {lb} exceeds upper bound {ub}"
952        )));
953    }
954    Ok(())
955}
956
957// ---------------------------------------------------------------------------
958// ModelResult
959// ---------------------------------------------------------------------------
960
961/// What kind of optimality proof backs a successful [`ModelResult`].
962#[non_exhaustive]
963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub enum SolutionProof {
965    /// A global optimum was proven.
966    GlobalOptimal,
967    /// A local KKT point was returned without a global proof.
968    LocalOptimal,
969    /// A feasible incumbent is available, but optimality was not proven.
970    FeasibleUnproven,
971}
972
973impl SolutionProof {
974    fn from_status(status: &SolveStatus) -> Self {
975        match status {
976            SolveStatus::Optimal | SolveStatus::NonconvexGlobal => SolutionProof::GlobalOptimal,
977            SolveStatus::LocallyOptimal => SolutionProof::LocalOptimal,
978            SolveStatus::MaxIterations
979            | SolveStatus::SuboptimalSolution
980            | SolveStatus::Timeout
981            | SolveStatus::NonconvexLocal => SolutionProof::FeasibleUnproven,
982            // These error statuses never reach build_ok — all three paths (LP, QP, MIP)
983            // return Err(...) for them before calling build_ok. The conservative fallback
984            // below guards against future regressions; the debug_assert catches them in tests.
985            SolveStatus::Infeasible
986            | SolveStatus::Unbounded
987            | SolveStatus::NumericalError
988            | SolveStatus::NonConvex(_)
989            | SolveStatus::NotSupported(_) => {
990                debug_assert!(
991                    false,
992                    "from_status called with error status {:?}: this arm is unreachable from build_ok",
993                    status
994                );
995                SolutionProof::FeasibleUnproven
996            }
997            // #[non_exhaustive]: wildcard required for cross-crate matching.
998            _ => SolutionProof::FeasibleUnproven,
999        }
1000    }
1001}
1002
1003/// The result of a successful solve.
1004#[derive(Debug, Clone)]
1005pub struct ModelResult {
1006    model_id: u64,
1007    /// Solver termination status associated with this returned solution.
1008    ///
1009    /// Only success-domain variants occur here (`Optimal`, `LocallyOptimal`,
1010    /// `NonconvexLocal`, `NonconvexGlobal`, `MaxIterations`, `SuboptimalSolution`,
1011    /// `Timeout`); error variants surface as [`ModelError`] instead. Match on
1012    /// [`ModelResult::proof`] for the optimality guarantee.
1013    pub status: SolveStatus,
1014    /// Optimality proof quality for this returned solution.
1015    pub proof: SolutionProof,
1016    /// Optimal objective value.
1017    pub objective_value: f64,
1018    /// Primal solution vector (indexed by variable index).
1019    solution: Vec<f64>,
1020    /// Dual solution (shadow prices), if available.
1021    pub dual_solution: Option<Vec<f64>>,
1022    /// Reduced costs, if available.
1023    ///
1024    /// 通常は `c − A^T y`。例外: presolve の bound-tightened-fixed 列が *元の* 上下限に
1025    /// 着地した場合、`reduced_costs[j]` には bound dual (μ_lb / μ_ub) が暗黙吸収され、
1026    /// raw `c − A^T y` ではなく at-lb で `max(·, 0)` / at-ub で `min(·, 0)` の clamp 値
1027    /// となる (presolve/postsolve.rs::BoundAbsorb)。LP path で `bound_duals` は default
1028    /// 空のため μ を分離取得することはできない (QP path のみ populate)。
1029    pub reduced_costs: Option<Vec<f64>>,
1030    /// Constraint slacks, if available.
1031    pub slack: Option<Vec<f64>>,
1032    /// Variable bound dual values (QP path).
1033    /// Layout: `[lb_dual for each var with finite lb, ub_dual for each var with finite ub]`
1034    /// Empty when not provided by the solver.
1035    pub bound_duals: Vec<f64>,
1036    /// Per-solve routing and warm-start statistics (race-free, per-result).
1037    pub stats: otspot_core::problem::SolveStats,
1038}
1039
1040// ---------------------------------------------------------------------------
1041// Test-only state inspection (state-machine invariant assertions).
1042// ---------------------------------------------------------------------------
1043#[cfg(test)]
1044impl Model {
1045    /// True if there is a pending DSL-quad error (stale if a later setter
1046    /// should have cleared it).
1047    pub(crate) fn has_quad_dsl_error(&self) -> bool {
1048        self.quad_dsl_error.is_some()
1049    }
1050
1051    /// True if a quadratic objective is currently installed (always DSL-owned).
1052    pub(crate) fn is_quad_via_dsl(&self) -> bool {
1053        self.quadratic_objective.is_some()
1054    }
1055
1056    /// True if any Q matrix is currently stored.
1057    pub(crate) fn has_quadratic_objective(&self) -> bool {
1058        self.quadratic_objective.is_some()
1059    }
1060}
1061
1062impl ModelResult {
1063    /// Get the primal value of a variable.
1064    ///
1065    /// # Panics
1066    /// Panics if the variable index is out of range. Use [`try_value`](Self::try_value)
1067    /// to handle this case gracefully.
1068    pub fn value(&self, var: Variable) -> f64 {
1069        self.solution[var.index]
1070    }
1071
1072    /// Get the primal value of a variable, returning an error instead of panicking.
1073    ///
1074    /// Returns `Err` if:
1075    /// - `var` was created by a different model than the one that produced this result.
1076    /// - `var.index` is out of range for the solution vector.
1077    pub fn try_value(&self, var: Variable) -> Result<f64, ModelError> {
1078        if var.model_id != self.model_id {
1079            return Err(ModelError::InvalidInput(
1080                "variable belongs to a different model".to_string(),
1081            ));
1082        }
1083        self.solution.get(var.index).copied().ok_or_else(|| {
1084            ModelError::InvalidInput(format!(
1085                "variable index {} out of range (solution length {})",
1086                var.index,
1087                self.solution.len()
1088            ))
1089        })
1090    }
1091
1092    /// Get the optimal objective value.
1093    pub fn objective(&self) -> f64 {
1094        self.objective_value
1095    }
1096
1097    /// Returns true when the solver proved global optimality for this result.
1098    pub fn has_global_optimality_proof(&self) -> bool {
1099        self.proof == SolutionProof::GlobalOptimal
1100    }
1101}
1102
1103/// Index a `ModelResult` by `Variable` to get the primal solution value.
1104///
1105/// # Example
1106/// ```rust,no_run
1107/// # use otspot_model::Model;
1108/// # let mut model = Model::new("demo");
1109/// # let x = model.add_var("x", 0.0, f64::INFINITY);
1110/// # model.minimize(x);
1111/// # let result = model.solve().unwrap();
1112/// println!("x = {}", result[x]);
1113/// ```
1114impl Index<Variable> for ModelResult {
1115    type Output = f64;
1116    fn index(&self, var: Variable) -> &f64 {
1117        &self.solution[var.index]
1118    }
1119}
1120
1121// ---------------------------------------------------------------------------
1122// Error types
1123// ---------------------------------------------------------------------------
1124
1125/// Solver termination status for QP/LP solve operations.
1126#[non_exhaustive]
1127#[derive(Debug, Clone, PartialEq)]
1128pub enum SolveError {
1129    /// The problem has no feasible solution.
1130    Infeasible,
1131    /// The problem is unbounded.
1132    Unbounded,
1133    /// Solver reached the iteration cap before converging (no usable solution).
1134    MaxIterations,
1135    /// Solver aborted due to numerical breakdown (no usable solution).
1136    NumericalError,
1137}
1138
1139impl fmt::Display for SolveError {
1140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1141        match self {
1142            SolveError::Infeasible => write!(f, "Problem is infeasible"),
1143            SolveError::Unbounded => write!(f, "Problem is unbounded"),
1144            SolveError::MaxIterations => {
1145                write!(f, "Max iterations reached without optimal solution")
1146            }
1147            SolveError::NumericalError => write!(f, "Numerical breakdown during solve"),
1148        }
1149    }
1150}
1151
1152/// Errors that can occur when building or solving a `Model`.
1153#[non_exhaustive]
1154#[derive(Debug)]
1155pub enum ModelError {
1156    /// `solve()` was called before `minimize()` or `maximize()`.
1157    NoObjective,
1158    /// A modeling API input was invalid.
1159    InvalidInput(String),
1160    /// The solver returned a non-optimal status.
1161    SolveError(SolveError),
1162    /// Solver timed out before finding an optimal solution.
1163    Timeout,
1164    /// Problem has a non-convex (indefinite) objective; global optimality cannot
1165    /// be guaranteed via IPM. Use `solve_qp_global` for non-convex continuous QP.
1166    NonConvex(String),
1167    /// Problem type is not supported by this solver (e.g. QCQP).
1168    NotSupported(String),
1169    /// An unexpected internal error (bug or invariant violation).
1170    Internal(String),
1171}
1172
1173impl fmt::Display for ModelError {
1174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1175        match self {
1176            ModelError::NoObjective => write!(
1177                f,
1178                "No objective function defined. Call model.minimize() or model.maximize() before solve()."
1179            ),
1180            ModelError::InvalidInput(msg) => write!(f, "Invalid model input: {}", msg),
1181            ModelError::SolveError(e) => write!(f, "Solve failed: {}", e),
1182            ModelError::Timeout => write!(f, "Solver timed out"),
1183            ModelError::NonConvex(msg) => write!(f, "Non-convex problem: {}", msg),
1184            ModelError::NotSupported(msg) => write!(f, "Not supported: {}", msg),
1185            ModelError::Internal(msg) => write!(f, "Internal error: {}", msg),
1186        }
1187    }
1188}
1189
1190impl std::error::Error for ModelError {}
1191
1192// ---------------------------------------------------------------------------
1193// Tests
1194// ---------------------------------------------------------------------------
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::{classify_status_error, Model, ModelError, SolutionProof, SolveError};
1199    use crate::variable::Variable;
1200    use otspot_core::problem::SolveStatus;
1201
1202    // concurrent solver での許容誤差(IPM/IP-PMM 並列実行)
1203    const EPS: f64 = 2e-3;
1204
1205    fn assert_close(a: f64, b: f64, name: &str) {
1206        assert!(
1207            (a - b).abs() < EPS,
1208            "{}: expected {:.8}, got {:.8}",
1209            name,
1210            b,
1211            a
1212        );
1213    }
1214
1215    /// Helper: build the classic 2-variable LP:
1216    ///   min  x + 2y
1217    ///   s.t. 2x + 3y <= 12
1218    ///        x + y  >= 3
1219    ///        x in [0, inf), y in [0, 10]
1220    /// Optimal: x=3, y=0 → obj=3
1221    fn basic_model() -> (Model, Variable, Variable) {
1222        let mut model = Model::new("basic");
1223        let x = model.add_var("x", 0.0, f64::INFINITY);
1224        let y = model.add_var("y", 0.0, 10.0);
1225        // Use method API for complex expressions
1226        model.add_constraint((2.0 * x + 3.0 * y).leq(12.0));
1227        model.add_constraint((x + y).geq(3.0));
1228        model.minimize(x + 2.0 * y);
1229        (model, x, y)
1230    }
1231
1232    // -----------------------------------------------------------------------
1233    // Test 1: Basic LP – 3-variable, 3-constraint problem
1234    // -----------------------------------------------------------------------
1235    #[test]
1236    fn test_basic_lp_3var_3con() {
1237        // min  x + 2y + 3z
1238        // s.t. x + y + z >= 6
1239        //      x + 2y    <= 10
1240        //      y + z     >= 4
1241        //      x,y,z in [0, inf)
1242        let mut model = Model::new("3var");
1243        let x = model.add_var("x", 0.0, f64::INFINITY);
1244        let y = model.add_var("y", 0.0, f64::INFINITY);
1245        let z = model.add_var("z", 0.0, f64::INFINITY);
1246
1247        // Use method API (complex LHS)
1248        model.add_constraint((x + y + z).geq(6.0));
1249        model.add_constraint((x + 2.0 * y).leq(10.0));
1250        model.add_constraint((y + z).geq(4.0));
1251        model.minimize(x + 2.0 * y + 3.0 * z);
1252
1253        let result = model.solve().unwrap();
1254        // Verify feasibility: x+y+z >= 6, y+z >= 4
1255        assert!(result[x] + result[y] + result[z] >= 6.0 - 1e-6);
1256        assert!(result[y] + result[z] >= 4.0 - 1e-6);
1257        assert!(result[x] >= -1e-9);
1258        assert!(result[y] >= -1e-9);
1259        assert!(result[z] >= -1e-9);
1260        assert!(result.objective_value > 0.0, "objective should be positive");
1261    }
1262
1263    // -----------------------------------------------------------------------
1264    // Test 2: Unbounded problem
1265    // -----------------------------------------------------------------------
1266    #[test]
1267    fn test_unbounded() {
1268        // min -x  s.t. x >= 0  (objective goes to -inf)
1269        let mut model = Model::new("unbounded");
1270        let x = model.add_var("x", 0.0, f64::INFINITY);
1271        model.minimize(-1.0 * x);
1272
1273        let err = model.solve().unwrap_err();
1274        assert!(
1275            matches!(err, ModelError::SolveError(SolveError::Unbounded)),
1276            "expected Unbounded, got {:?}",
1277            err
1278        );
1279    }
1280
1281    // -----------------------------------------------------------------------
1282    // Test 3: Infeasible problem
1283    // -----------------------------------------------------------------------
1284    #[test]
1285    fn test_infeasible() {
1286        // x >= 5, x <= 3  (contradictory)
1287        let mut model = Model::new("infeasible");
1288        let x = model.add_var("x", 0.0, f64::INFINITY);
1289        // Single-variable constraint: use constraint! macro
1290        model.add_constraint(crate::constraint!(x >= 5.0));
1291        model.add_constraint(crate::constraint!(x <= 3.0));
1292        model.minimize(x);
1293
1294        let err = model.solve().unwrap_err();
1295        assert!(
1296            matches!(err, ModelError::SolveError(SolveError::Infeasible)),
1297            "expected Infeasible, got {:?}",
1298            err
1299        );
1300    }
1301
1302    // -----------------------------------------------------------------------
1303    // Test 4: Equality constraint
1304    // -----------------------------------------------------------------------
1305    #[test]
1306    fn test_equality_constraint() {
1307        // min x + y  s.t. x + y == 5, x,y >= 0
1308        // Optimal: x=5, y=0 (or any split), obj=5
1309        let mut model = Model::new("eq");
1310        let x = model.add_var("x", 0.0, f64::INFINITY);
1311        let y = model.add_var("y", 0.0, f64::INFINITY);
1312        // Equality with complex LHS: use method API
1313        model.add_constraint((x + y).eq_constraint(5.0));
1314        model.minimize(x + y);
1315
1316        let result = model.solve().unwrap();
1317        assert!(
1318            (result.objective_value - 5.0).abs() < 1e-6,
1319            "obj={} expected 5.0",
1320            result.objective_value
1321        );
1322    }
1323
1324    // -----------------------------------------------------------------------
1325    // Test 5: Variable bounds are respected
1326    // -----------------------------------------------------------------------
1327    #[test]
1328    fn test_variable_bounds() {
1329        // min x  s.t. x in [0, 3]
1330        // Optimal: x=0 (minimizing)
1331        let mut model = Model::new("bounds");
1332        let x = model.add_var("x", 0.0, 3.0);
1333        model.minimize(x);
1334
1335        let result = model.solve().unwrap();
1336        assert!(result[x].abs() < 1e-6, "x should be 0.0, got {}", result[x]);
1337
1338        // Maximize x in [0, 3] → should hit ub=3
1339        // Note: add explicit constraint because simplex edge-case (m=0, ub only)
1340        // does not check variable upper bounds when returning Unbounded.
1341        let mut model2 = Model::new("bounds_max");
1342        let x2 = model2.add_var("x", 0.0, 3.0);
1343        model2.add_constraint(crate::constraint!(x2 <= 3.0));
1344        model2.maximize(x2);
1345
1346        let result2 = model2.solve().unwrap();
1347        assert!(
1348            (result2[x2] - 3.0).abs() < 1e-6,
1349            "x should be 3.0, got {}",
1350            result2[x2]
1351        );
1352    }
1353
1354    // -----------------------------------------------------------------------
1355    // Test 6: NoObjective error
1356    // -----------------------------------------------------------------------
1357    #[test]
1358    fn test_no_objective_error() {
1359        let mut model = Model::new("no_obj");
1360        let _x = model.add_var("x", 0.0, f64::INFINITY);
1361        let err = model.solve().unwrap_err();
1362        assert!(matches!(err, ModelError::NoObjective));
1363    }
1364
1365    #[test]
1366    fn solution_proof_mapping_preserves_unproven_statuses() {
1367        assert_eq!(
1368            SolutionProof::from_status(&SolveStatus::Optimal),
1369            SolutionProof::GlobalOptimal
1370        );
1371        assert_eq!(
1372            SolutionProof::from_status(&SolveStatus::NonconvexGlobal),
1373            SolutionProof::GlobalOptimal
1374        );
1375        assert_eq!(
1376            SolutionProof::from_status(&SolveStatus::LocallyOptimal),
1377            SolutionProof::LocalOptimal
1378        );
1379        assert_eq!(
1380            SolutionProof::from_status(&SolveStatus::NonconvexLocal),
1381            SolutionProof::FeasibleUnproven
1382        );
1383        assert_eq!(
1384            SolutionProof::from_status(&SolveStatus::Timeout),
1385            SolutionProof::FeasibleUnproven
1386        );
1387        assert_eq!(
1388            SolutionProof::from_status(&SolveStatus::SuboptimalSolution),
1389            SolutionProof::FeasibleUnproven
1390        );
1391    }
1392
1393    // -----------------------------------------------------------------------
1394    // Test 7: result[x] indexing and result.value(x) agree
1395    // -----------------------------------------------------------------------
1396    #[test]
1397    fn test_result_index_and_value_agree() {
1398        let (mut model, x, y) = basic_model();
1399        let result = model.solve().unwrap();
1400        assert!((result[x] - result.value(x)).abs() < 1e-12);
1401        assert!((result[y] - result.value(y)).abs() < 1e-12);
1402    }
1403
1404    // -----------------------------------------------------------------------
1405    // Test 8: Maximize a simple LP (also tests constraint! macro)
1406    // -----------------------------------------------------------------------
1407    #[test]
1408    fn test_maximize() {
1409        // max x  s.t. x <= 7, x >= 0
1410        let mut model = Model::new("max_simple");
1411        let x = model.add_var("x", 0.0, f64::INFINITY);
1412        // Single-variable constraint: use constraint! macro to exercise it
1413        model.add_constraint(crate::constraint!(x <= 7.0));
1414        model.maximize(x);
1415
1416        let result = model.solve().unwrap();
1417        assert!(
1418            (result[x] - 7.0).abs() < 1e-6,
1419            "expected x=7, got {}",
1420            result[x]
1421        );
1422        assert!(
1423            (result.objective() - 7.0).abs() < 1e-6,
1424            "expected obj=7, got {}",
1425            result.objective()
1426        );
1427    }
1428
1429    // -----------------------------------------------------------------------
1430    // Test 9: Model QP basic – Q=2I, c=(-4,-4), no constraints, bounds=[0,∞)
1431    // -----------------------------------------------------------------------
1432    #[test]
1433    fn test_model_qp_basic() {
1434        // min x^2+y^2 - 4x - 4y  (1/2*[[2,0],[0,2]]*[x,y]^T + [-4,-4]*[x,y])
1435        // Unconstrained min: x=y=2, obj = 4+4-8-8 = -8
1436        let mut model = Model::new("qp_basic");
1437        let x = model.add_var("x", 0.0, f64::INFINITY);
1438        let y = model.add_var("y", 0.0, f64::INFINITY);
1439        model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1440
1441        let result = model.solve().unwrap();
1442        assert_close(result[x], 2.0, "T9: x");
1443        assert_close(result[y], 2.0, "T9: y");
1444        // obj = 1/2*2*(4+4) - 4*2 - 4*2 = 8 - 16 = -8
1445        assert_close(result.objective_value, -8.0, "T9: obj");
1446    }
1447
1448    // -----------------------------------------------------------------------
1449    // Test 10: Model QP with Eq constraint – Eq→2行変換の検証
1450    // -----------------------------------------------------------------------
1451    #[test]
1452    fn test_model_qp_equality() {
1453        // min x^2+y^2  s.t. x+y=1, x,y ∈ (-∞,∞)
1454        // Expected: x=y=0.5, obj=0.5
1455        let mut model = Model::new("qp_eq");
1456        let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1457        let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1458        model.add_constraint((x + y).eq_constraint(1.0));
1459        model.minimize(x * x + y * y);
1460
1461        let result = model.solve().unwrap();
1462        assert_close(result[x], 0.5, "T10: x");
1463        assert_close(result[y], 0.5, "T10: y");
1464        assert_close(result.objective_value, 0.5, "T10: obj");
1465    }
1466
1467    // -----------------------------------------------------------------------
1468    // Test 11: Model QP with Ge constraint – Ge→符号反転変換の検証
1469    // -----------------------------------------------------------------------
1470    #[test]
1471    fn test_model_qp_ge_constraint() {
1472        // min x^2+y^2  s.t. x+y >= 1, x,y ∈ (-∞,∞)
1473        // Same solution as equality case: x=y=0.5, obj=0.5
1474        let mut model = Model::new("qp_ge");
1475        let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1476        let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1477        model.add_constraint((x + y).geq(1.0));
1478        model.minimize(x * x + y * y);
1479
1480        let result = model.solve().unwrap();
1481        let qp_tol = 2e-3;
1482        assert!(
1483            (result[x] - 0.5).abs() < qp_tol,
1484            "T11: x expected 0.5, got {}",
1485            result[x]
1486        );
1487        assert!(
1488            (result[y] - 0.5).abs() < qp_tol,
1489            "T11: y expected 0.5, got {}",
1490            result[y]
1491        );
1492        assert!(
1493            (result.objective_value - 0.5).abs() < qp_tol,
1494            "T11: obj expected 0.5, got {}",
1495            result.objective_value
1496        );
1497    }
1498
1499    // -----------------------------------------------------------------------
1500    // Test 12: Model QP maximize – max -(x^2+y^2) s.t. x+y>=1, x,y>=0
1501    // -----------------------------------------------------------------------
1502    #[test]
1503    fn test_model_qp_maximize() {
1504        // max -(x^2+y^2), constraint: x+y >= 1, x,y >= 0
1505        // Expected: x=y=0.5, obj = -(0.25+0.25) = -0.5
1506        let mut model = Model::new("qp_maximize");
1507        let x = model.add_var("x", 0.0, f64::INFINITY);
1508        let y = model.add_var("y", 0.0, f64::INFINITY);
1509        model.add_constraint((x + y).geq(1.0));
1510        model.maximize((-1.0) * x * x + (-1.0) * y * y);
1511
1512        let result = model.solve().unwrap();
1513        assert_close(result[x], 0.5, "T12: x");
1514        assert_close(result[y], 0.5, "T12: y");
1515        assert_close(result.objective_value, -0.5, "T12: obj");
1516    }
1517
1518    // -----------------------------------------------------------------------
1519    // Test 13: Model QP box bounds – bounds=[0,1], T11相当
1520    // -----------------------------------------------------------------------
1521    #[test]
1522    fn test_model_qp_box_bounds() {
1523        // min x^2+y^2-4x-4y, bounds=[0,1]
1524        // Unconstrained min: x=y=2 → clipped to ub=1
1525        // Expected: x=y=1, obj = 1/2*2*(1+1) + (-4-4)*1 = 2-8 = -6
1526        let mut model = Model::new("qp_box");
1527        let x = model.add_var("x", 0.0, 1.0);
1528        let y = model.add_var("y", 0.0, 1.0);
1529        model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1530
1531        let result = model.solve().unwrap();
1532        assert_close(result[x], 1.0, "T13: x");
1533        assert_close(result[y], 1.0, "T13: y");
1534        assert_close(result.objective_value, -6.0, "T13: obj");
1535    }
1536
1537    // -----------------------------------------------------------------------
1538    // Test 14: Model QP timeout – timeout=0.001秒でTimeout返却
1539    // -----------------------------------------------------------------------
1540    #[test]
1541    fn test_model_qp_timeout() {
1542        // Use a well-defined small QP but set an extremely short timeout.
1543        let mut model = Model::new("qp_timeout");
1544        let x = model.add_var("x", 0.0, f64::INFINITY);
1545        let y = model.add_var("y", 0.0, f64::INFINITY);
1546        model.minimize(x * x + y * y + (-4.0) * x + (-4.0) * y);
1547        model.set_timeout(0.000_001); // 1 microsecond → always times out
1548
1549        let err = model.solve().unwrap_err();
1550        assert!(
1551            matches!(err, ModelError::Timeout),
1552            "expected Timeout, got {:?}",
1553            err
1554        );
1555    }
1556
1557    // -----------------------------------------------------------------------
1558    // T8-1: LP with Eq constraint (Q=0 path: solve_as_lp)
1559    // -----------------------------------------------------------------------
1560    #[test]
1561    fn test_model_lp_equality() {
1562        // min x + 2y  s.t. x + y = 4, x,y >= 0
1563        // Optimal: x=4, y=0, obj=4
1564        let mut model = Model::new("lp_eq");
1565        let x = model.add_var("x", 0.0, f64::INFINITY);
1566        let y = model.add_var("y", 0.0, f64::INFINITY);
1567        model.add_constraint((x + y).eq_constraint(4.0));
1568        model.minimize(x + 2.0 * y);
1569
1570        let result = model.solve().unwrap();
1571        assert_close(result.objective_value, 4.0, "T8-1: obj");
1572        // x+y=4 かつ obj=x+2y=4 → x=4,y=0 が最適
1573        assert_close(result[x] + result[y], 4.0, "T8-1: x+y=4");
1574    }
1575
1576    // -----------------------------------------------------------------------
1577    // T8-2: Eq制約のdual solution(LeExpansionMap逆変換の検証)
1578    // -----------------------------------------------------------------------
1579    #[test]
1580    fn test_model_eq_dual_solution() {
1581        // min x^2 + y^2  s.t. x + y = 1, x,y in (-inf, inf)
1582        // KKT: 2x + λ = 0, 2y + λ = 0 → x=y=-λ/2; x+y=1 → λ=-1, x=y=0.5
1583        // dual of Eq constraint (shadow price) = λ = -1
1584        let mut model = Model::new("qp_eq_dual");
1585        let x = model.add_var("x", f64::NEG_INFINITY, f64::INFINITY);
1586        let y = model.add_var("y", f64::NEG_INFINITY, f64::INFINITY);
1587        model.add_constraint((x + y).eq_constraint(1.0));
1588        model.minimize(x * x + y * y);
1589
1590        let result = model.solve().unwrap();
1591        assert_close(result.objective_value, 0.5, "T8-2: obj");
1592        assert_close(result[x], 0.5, "T8-2: x");
1593        assert_close(result[y], 0.5, "T8-2: y");
1594
1595        // dual検証: Eq制約のshadow price = -1
1596        let dual = result
1597            .dual_solution
1598            .as_ref()
1599            .expect("T8-2: dual_solution is None");
1600        assert!(
1601            dual.len() == 1,
1602            "T8-2: dual length expected 1, got {}",
1603            dual.len()
1604        );
1605        assert!(
1606            (dual[0] - (-1.0)).abs() < EPS,
1607            "T8-2: dual[0] expected -1.0, got {}",
1608            dual[0]
1609        );
1610    }
1611
1612    // -----------------------------------------------------------------------
1613    // LocalOptimal proof: indefinite-Q QP through Model API (table-driven).
1614    //
1615    // Sentinel: replacing from_status with a no-op that always returns
1616    // GlobalOptimal causes the assert_eq!(proof, LocalOptimal) to FAIL.
1617    // -----------------------------------------------------------------------
1618    #[test]
1619    fn test_model_qp_locally_optimal_proof() {
1620        // (name, q_diag, bounds, c) — all 2-variable diagonal-Q cases.
1621        let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1622            // Diagonal indefinite Q: eigenvalues -2, +2.
1623            ("diag(-2,2)", [-2.0, 2.0], (-1.0, 1.0), [0.0, 0.0]),
1624            // Diagonal indefinite Q: eigenvalues -3, +5 with linear term.
1625            ("diag(-3,5)", [-3.0, 5.0], (-2.0, 2.0), [-1.0, 0.0]),
1626        ];
1627
1628        for &(name, q_diag, (lb, ub), c) in cases {
1629            let mut model = Model::new(name);
1630            let x = model.add_var("x0", lb, ub);
1631            let y = model.add_var("x1", lb, ub);
1632            // Q = diag(q_diag): DSL term (d/2)*xi*xi per variable
1633            model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1634
1635            let result = model.solve();
1636            match result {
1637                Ok(r) => {
1638                    assert_eq!(
1639                        r.status,
1640                        otspot_core::problem::SolveStatus::LocallyOptimal,
1641                        "[{name}] expected LocallyOptimal, got {:?}",
1642                        r.status
1643                    );
1644                    assert_eq!(
1645                        r.proof,
1646                        SolutionProof::LocalOptimal,
1647                        "[{name}] expected LocalOptimal proof, got {:?}",
1648                        r.proof
1649                    );
1650                    assert!(
1651                        !r.has_global_optimality_proof(),
1652                        "[{name}] has_global_optimality_proof must be false for LocallyOptimal"
1653                    );
1654                }
1655                Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1656            }
1657        }
1658    }
1659
1660    // -----------------------------------------------------------------------
1661    // FeasibleUnproven proof: impossibly-tight tolerance forces SuboptimalSolution
1662    // on a convex QP that the IPM solves to finite residuals (table-driven).
1663    //
1664    // Sentinel: replacing from_status with a no-op returning GlobalOptimal
1665    // causes the assert_eq!(proof, FeasibleUnproven) to FAIL.
1666    // -----------------------------------------------------------------------
1667    #[test]
1668    fn test_model_qp_feasible_unproven_proof() {
1669        use otspot_core::options::Tolerance;
1670
1671        // (name, q_diag, (lb,ub), c)
1672        let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1673            // Convex PSD Q=2I, c=[-4,-4]. IPM converges (residuals ~1e-6) but
1674            // Custom(1e-200) makes satisfies_eps always false → SuboptimalSolution.
1675            ("convex_2x2_tight_tol", [2.0, 2.0], (0.0, f64::INFINITY), [-4.0, -4.0]),
1676            // Convex PSD Q=4I, c=[0,-2] with box bounds.
1677            ("convex_box_tight_tol", [4.0, 4.0], (0.0, 3.0), [0.0, -2.0]),
1678        ];
1679
1680        for &(name, q_diag, (lb, ub), c) in cases {
1681            let mut model = Model::new(name);
1682            let x = model.add_var("x0", lb, ub);
1683            let y = model.add_var("x1", lb, ub);
1684            model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1685            // Impossibly tight tolerance: IPM finds a finite-residual solution
1686            // but satisfies_eps(1e-200) is always false → SuboptimalSolution.
1687            model.set_tolerance(Tolerance::Custom(1e-200));
1688
1689            let result = model.solve();
1690            match result {
1691                Ok(r) => {
1692                    assert_eq!(
1693                        r.proof,
1694                        SolutionProof::FeasibleUnproven,
1695                        "[{name}] expected FeasibleUnproven proof, got {:?} (status={:?})",
1696                        r.proof, r.status
1697                    );
1698                    assert!(
1699                        !r.has_global_optimality_proof(),
1700                        "[{name}] has_global_optimality_proof must be false for FeasibleUnproven"
1701                    );
1702                    assert!(!r.solution.is_empty(), "[{name}] solution must be non-empty");
1703                }
1704                Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1705            }
1706        }
1707    }
1708
1709    // -----------------------------------------------------------------------
1710    // Sentinel: classify_status_error maps NonConvex/NotSupported to typed
1711    // variants (not Internal). Reverting the mapping to Internal causes FAIL.
1712    // -----------------------------------------------------------------------
1713    #[test]
1714    fn classify_status_error_typed_variants() {
1715        let cases_nonconvex = [
1716            "indefinite Q: eigenvalue < 0",
1717            "non-PSD matrix in MIQP",
1718        ];
1719        for msg in &cases_nonconvex {
1720            let status = SolveStatus::NonConvex(msg.to_string());
1721            let err = classify_status_error(status).expect("NonConvex must map to Some");
1722            assert!(
1723                matches!(err, ModelError::NonConvex(_)),
1724                "NonConvex status must yield ModelError::NonConvex, got {:?}",
1725                err
1726            );
1727        }
1728
1729        let cases_not_supported = [
1730            "QCQP not supported",
1731            "constraint type unsupported",
1732        ];
1733        for msg in &cases_not_supported {
1734            let status = SolveStatus::NotSupported(msg.to_string());
1735            let err = classify_status_error(status).expect("NotSupported must map to Some");
1736            assert!(
1737                matches!(err, ModelError::NotSupported(_)),
1738                "NotSupported status must yield ModelError::NotSupported, got {:?}",
1739                err
1740            );
1741        }
1742    }
1743
1744    // -----------------------------------------------------------------------
1745    // Sentinel: MIQP with indefinite Q returns ModelError::NonConvex.
1746    // Reverting NonConvex → Internal in finish_mip causes FAIL.
1747    // Table-driven: multiple indefinite Q shapes.
1748    // -----------------------------------------------------------------------
1749    #[test]
1750    fn miqp_nonconvex_q_returns_nonconvex_error() {
1751        let cases: &[(&str, [f64; 2])] = &[
1752            ("diag(-1, 1)", [-1.0, 1.0]),
1753            ("diag(-2, 3)", [-2.0, 3.0]),
1754            ("diag(1, -1)", [1.0, -1.0]),
1755        ];
1756
1757        for &(name, q_diag) in cases {
1758            let mut model = Model::new(name);
1759            let x = model.add_binary_var("x");
1760            let y = model.add_binary_var("y");
1761            // Q=diag(q_diag): Q[i][i]=d → (d/2)*xi*xi in DSL
1762            model.minimize((q_diag[0] / 2.0) * (x * x) + (q_diag[1] / 2.0) * (y * y));
1763
1764            let err = model
1765                .solve()
1766                .expect_err(&format!("[{name}] expected Err(NonConvex), got Ok"));
1767            assert!(
1768                matches!(err, ModelError::NonConvex(_)),
1769                "[{name}] expected ModelError::NonConvex, got {:?}",
1770                err
1771            );
1772        }
1773    }
1774
1775    // -----------------------------------------------------------------------
1776    // Sentinel: validate_bounds rejects lb>ub and NaN.
1777    // No-op'ing validate_bounds (always Ok) causes these tests to FAIL:
1778    //   - add_var with lb>ub would not record error → solve() would succeed
1779    //     (an LP with inverted bounds becomes infeasible but NOT an InvalidInput).
1780    // -----------------------------------------------------------------------
1781    #[test]
1782    fn add_var_lb_gt_ub_defers_error_to_solve() {
1783        let cases: &[(&str, f64, f64)] = &[
1784            ("lb=5 > ub=3", 5.0, 3.0),
1785            ("lb=1.0 > ub=0.999", 1.0, 0.999),
1786            ("lb=inf > ub=0", f64::INFINITY, 0.0),
1787        ];
1788        for &(label, lb, ub) in cases {
1789            let mut model = Model::new(label);
1790            let x = model.add_var("x", lb, ub);
1791            model.minimize(x);
1792            let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1793            assert!(
1794                matches!(err, ModelError::InvalidInput(_)),
1795                "[{label}] expected InvalidInput, got {err:?}"
1796            );
1797        }
1798    }
1799
1800    #[test]
1801    fn add_var_nan_bounds_defers_error_to_solve() {
1802        let cases: &[(&str, f64, f64)] = &[
1803            ("lb=NaN", f64::NAN, 1.0),
1804            ("ub=NaN", 0.0, f64::NAN),
1805            ("both=NaN", f64::NAN, f64::NAN),
1806        ];
1807        for &(label, lb, ub) in cases {
1808            let mut model = Model::new(label);
1809            let x = model.add_var("x", lb, ub);
1810            model.minimize(x);
1811            let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1812            assert!(
1813                matches!(err, ModelError::InvalidInput(_)),
1814                "[{label}] expected InvalidInput, got {err:?}"
1815            );
1816        }
1817    }
1818
1819    #[test]
1820    fn add_int_var_lb_gt_ub_defers_error_to_solve() {
1821        let cases: &[(&str, f64, f64)] = &[
1822            ("int lb=3 > ub=1", 3.0, 1.0),
1823            ("int lb=NaN", f64::NAN, 5.0),
1824        ];
1825        for &(label, lb, ub) in cases {
1826            let mut model = Model::new(label);
1827            let x = model.add_int_var("x", lb, ub);
1828            model.minimize(x);
1829            let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1830            assert!(
1831                matches!(err, ModelError::InvalidInput(_)),
1832                "[{label}] expected InvalidInput, got {err:?}"
1833            );
1834        }
1835    }
1836
1837    // -----------------------------------------------------------------------
1838    // try_add_var / try_add_int_var: immediate Result API.
1839    // Sentinel: no-op of validate_bounds makes all these Ok → assert!(is_err()) FAILs.
1840    // -----------------------------------------------------------------------
1841    #[test]
1842    fn try_add_var_returns_err_for_invalid_bounds() {
1843        let cases: &[(&str, f64, f64)] = &[
1844            ("lb>ub", 2.0, 1.0),
1845            ("lb=NaN", f64::NAN, 1.0),
1846            ("ub=NaN", 0.0, f64::NAN),
1847            ("lb=inf > ub=0", f64::INFINITY, 0.0),
1848        ];
1849        for &(label, lb, ub) in cases {
1850            let mut model = Model::new(label);
1851            let result = model.try_add_var("x", lb, ub);
1852            assert!(
1853                result.is_err(),
1854                "[{label}] try_add_var should return Err for invalid bounds, got Ok"
1855            );
1856        }
1857    }
1858
1859    #[test]
1860    fn try_add_var_returns_ok_for_valid_bounds() {
1861        let cases: &[(&str, f64, f64)] = &[
1862            ("lb=ub", 3.0, 3.0),
1863            ("lb=0 ub=inf", 0.0, f64::INFINITY),
1864            ("lb=-inf ub=inf", f64::NEG_INFINITY, f64::INFINITY),
1865            ("lb=-inf ub=0", f64::NEG_INFINITY, 0.0),
1866        ];
1867        for &(label, lb, ub) in cases {
1868            let mut model = Model::new(label);
1869            assert!(
1870                model.try_add_var("x", lb, ub).is_ok(),
1871                "[{label}] try_add_var should return Ok for valid bounds"
1872            );
1873        }
1874    }
1875
1876    #[test]
1877    fn try_add_int_var_returns_err_for_invalid_bounds() {
1878        let cases: &[(&str, f64, f64)] = &[
1879            ("int lb>ub", 5.0, 2.0),
1880            ("int lb=NaN", f64::NAN, 3.0),
1881        ];
1882        for &(label, lb, ub) in cases {
1883            let mut model = Model::new(label);
1884            assert!(
1885                model.try_add_int_var("n", lb, ub).is_err(),
1886                "[{label}] try_add_int_var should return Err"
1887            );
1888        }
1889    }
1890
1891    // -----------------------------------------------------------------------
1892    // try_value: safe accessor — wrong model_id and out-of-range both return Err.
1893    // Sentinel: removing the model_id check makes cross-model test pass/Err → Ok
1894    //   causing the assert!(result.is_err()) to FAIL.
1895    // -----------------------------------------------------------------------
1896    #[test]
1897    fn try_value_cross_model_returns_err() {
1898        let mut m1 = Model::new("m1");
1899        let x1 = m1.add_var("x", 0.0, f64::INFINITY);
1900        m1.minimize(x1);
1901        let r1 = m1.solve().unwrap();
1902
1903        // Variable from a different model — same index (0), different model_id.
1904        let mut m2 = Model::new("m2");
1905        let y = m2.add_var("y", 0.0, f64::INFINITY);
1906
1907        assert!(
1908            r1.try_value(y).is_err(),
1909            "try_value with variable from different model must return Err"
1910        );
1911        // Correct variable works fine.
1912        assert!(r1.try_value(x1).is_ok());
1913    }
1914
1915    #[test]
1916    fn try_value_valid_returns_ok() {
1917        let (mut model, x, y) = basic_model();
1918        let result = model.solve().unwrap();
1919        assert!(result.try_value(x).is_ok());
1920        assert!(result.try_value(y).is_ok());
1921        assert!((result.try_value(x).unwrap() - result.value(x)).abs() < 1e-12);
1922    }
1923
1924    // Out-of-range with a *matching* model_id: a variable added to the same
1925    // model after solving has an index past the result's solution vector.
1926    // Sentinel for the `.ok_or_else` branch (the model_id check passes here, so
1927    // no-op'ing the bounds check — e.g. `self.solution[var.index]` — panics).
1928    #[test]
1929    fn try_value_out_of_range_same_model_returns_err() {
1930        let mut model = Model::new("grow");
1931        let x = model.add_var("x", 0.0, f64::INFINITY);
1932        model.minimize(x);
1933        let result = model.solve().unwrap();
1934
1935        // Extend the same model: same model_id, index beyond solution length.
1936        let late = model.add_var("late", 0.0, f64::INFINITY);
1937        assert_eq!(late.model_id, result.model_id, "same model_id expected");
1938        assert!(
1939            late.index >= result.solution.len(),
1940            "test setup: late var must be out of range"
1941        );
1942        assert!(
1943            result.try_value(late).is_err(),
1944            "try_value must return Err for an out-of-range index even when model_id matches"
1945        );
1946    }
1947
1948    // -----------------------------------------------------------------------
1949    // ModelResult: Clone derive
1950    // -----------------------------------------------------------------------
1951    #[test]
1952    fn model_result_clone() {
1953        let (mut model, x, _y) = basic_model();
1954        let result = model.solve().unwrap();
1955        let cloned = result.clone();
1956        assert!((cloned.objective_value - result.objective_value).abs() < 1e-12);
1957        assert_eq!(cloned.solution.len(), result.solution.len());
1958        assert!((cloned[x] - result[x]).abs() < 1e-12);
1959    }
1960
1961    // -----------------------------------------------------------------------
1962    // Variable name retention
1963    // -----------------------------------------------------------------------
1964    #[test]
1965    fn var_name_is_retained() {
1966        let cases = [("alpha", 0.0, 1.0), ("beta_var", 0.0, f64::INFINITY)];
1967        let mut model = Model::new("named");
1968        for &(name, lb, ub) in &cases {
1969            let v = model.add_var(name, lb, ub);
1970            assert_eq!(model.var_name(v), name, "var_name mismatch for '{name}'");
1971        }
1972        let iv = model.add_int_var("gamma_int", 0.0, 10.0);
1973        assert_eq!(model.var_name(iv), "gamma_int");
1974    }
1975
1976    // -----------------------------------------------------------------------
1977    // set_timeout validation (already implemented; table-driven sentinel)
1978    // No-op'ing validate_timeout makes negative/NaN tests succeed → FAILs.
1979    // -----------------------------------------------------------------------
1980    #[test]
1981    fn set_timeout_invalid_defers_error() {
1982        let cases: &[(&str, f64)] = &[
1983            ("negative", -1.0),
1984            ("NaN", f64::NAN),
1985            ("neg_inf", f64::NEG_INFINITY),
1986        ];
1987        for &(label, secs) in cases {
1988            let mut model = Model::new(label);
1989            let x = model.add_var("x", 0.0, f64::INFINITY);
1990            model.minimize(x);
1991            model.set_timeout(secs);
1992            let err = model.solve().expect_err(&format!("[{label}] expected Err for invalid timeout"));
1993            assert!(
1994                matches!(err, ModelError::InvalidInput(_)),
1995                "[{label}] expected InvalidInput, got {err:?}"
1996            );
1997        }
1998    }
1999
2000    #[test]
2001    fn try_set_timeout_returns_err_for_invalid() {
2002        let cases: &[(&str, f64)] = &[("negative", -0.001), ("NaN", f64::NAN), ("inf", f64::INFINITY)];
2003        for &(label, secs) in cases {
2004            let mut model = Model::new(label);
2005            assert!(
2006                model.try_set_timeout(secs).is_err(),
2007                "[{label}] try_set_timeout should return Err"
2008            );
2009        }
2010    }
2011
2012    #[test]
2013    fn try_set_timeout_ok_for_valid() {
2014        let valid = [0.0, 0.001, 1.0, 3600.0];
2015        for &secs in &valid {
2016            let mut model = Model::new("t");
2017            assert!(model.try_set_timeout(secs).is_ok(), "should be Ok for secs={secs}");
2018        }
2019    }
2020}
2021
2022// ---------------------------------------------------------------------------
2023// Model API tests for MILP / MIQP paths.
2024//
2025// A lib-target module (kept separate so it runs under the CI `lib-only`
2026// nextest profile, which excludes `tests/` integration targets). Exercises the
2027// `Model` high-level API over the MILP/MIQP branch-and-bound solver.
2028// ---------------------------------------------------------------------------
2029
2030#[cfg(test)]
2031mod mip_model_tests {
2032    use super::{Model, ModelError, SolveError};
2033
2034    const EPS: f64 = 1e-4;
2035
2036    #[test]
2037    fn model_add_int_var_maximize_branches() {
2038        // max x s.t. 2x <= 3, x integer in [0,5] → x = 1, obj = 1.
2039        let mut m = Model::new("milp_int");
2040        let x = m.add_int_var("x", 0.0, 5.0);
2041        m.add_constraint((2.0 * x).leq(3.0));
2042        m.maximize(x);
2043        let r = m.solve().unwrap();
2044        assert!((r.objective() - 1.0).abs() < EPS, "obj={}", r.objective());
2045        assert!((r[x] - 1.0).abs() < EPS, "x={}", r[x]);
2046    }
2047
2048    #[test]
2049    fn model_binary_knapsack() {
2050        let mut m = Model::new("knapsack");
2051        let a = m.add_binary_var("a");
2052        let b = m.add_binary_var("b");
2053        let c = m.add_binary_var("c");
2054        let d = m.add_binary_var("d");
2055        m.add_constraint((5.0 * a + 7.0 * b + 4.0 * c + 3.0 * d).leq(14.0));
2056        m.maximize(8.0 * a + 11.0 * b + 6.0 * c + 4.0 * d);
2057        let r = m.solve().unwrap();
2058        assert!((r.objective() - 21.0).abs() < EPS, "obj={}", r.objective());
2059        assert_eq!(
2060            (r[a].round(), r[b].round(), r[c].round(), r[d].round()),
2061            (0.0, 1.0, 1.0, 1.0)
2062        );
2063    }
2064
2065    #[test]
2066    fn model_integer_infeasible_errors() {
2067        let mut m = Model::new("infeasible");
2068        let x = m.add_int_var("x", 0.0, 10.0);
2069        m.add_constraint((1.0 * x).geq(1.2));
2070        m.add_constraint((1.0 * x).leq(1.8));
2071        m.minimize(x);
2072        let err = m.solve().unwrap_err();
2073        assert!(matches!(err, ModelError::SolveError(SolveError::Infeasible)), "got {err:?}");
2074    }
2075
2076    #[test]
2077    fn model_integer_unbounded_errors() {
2078        let mut m = Model::new("unbounded");
2079        let x = m.add_int_var("x", 0.0, f64::INFINITY);
2080        m.maximize(x);
2081        let err = m.solve().unwrap_err();
2082        assert!(matches!(err, ModelError::SolveError(SolveError::Unbounded)), "got {err:?}");
2083    }
2084
2085    #[test]
2086    fn model_convex_miqp_branches_to_integer_optimum() {
2087        // min x^2 - 5x, x integer in [0,5].
2088        // Continuous min at x=2.5 (fractional → branch); integer optima x=2 or x=3, obj = -6.
2089        let mut m = Model::new("convex_miqp");
2090        let x = m.add_int_var("x", 0.0, 5.0);
2091        m.minimize(x * x + (-5.0) * x);
2092        let r = m.solve().unwrap();
2093        assert!((r.objective() - (-6.0)).abs() < EPS, "obj={}", r.objective());
2094        let xr = r[x].round();
2095        assert!(xr == 2.0 || xr == 3.0, "x must be 2 or 3, got {}", r[x]);
2096        assert!((r[x] - xr).abs() < EPS, "x must be integral: {}", r[x]);
2097    }
2098
2099    #[test]
2100    fn model_nonconvex_miqp_errors() {
2101        // indefinite Q (negative curvature) → must return ModelError::NonConvex, not silent wrong.
2102        let cases: &[(&str, &[f64], &[f64])] = &[
2103            ("single neg", &[-2.0], &[1.0]),
2104            ("neg-pos-2var", &[-3.0, 2.0], &[0.0, 1.0]),
2105        ];
2106        for &(name, q_diag, c_vec) in cases {
2107            let n = q_diag.len();
2108            let mut m = Model::new(name);
2109            let vars: Vec<_> = (0..n).map(|i| m.add_int_var(&format!("x{i}"), 0.0, 5.0)).collect();
2110            // Build objective: sum (d_i/2)*x_i^2 + c_i*x_i via DSL
2111            let obj = vars.iter().zip(q_diag).zip(c_vec).fold(
2112                crate::quad_expr::QuadExpr::from(0.0_f64),
2113                |acc, ((&v, &d), &c)| acc + (d / 2.0) * (v * v) + c * v,
2114            );
2115            m.minimize(obj);
2116            let err = m.solve().unwrap_err();
2117            assert!(
2118                matches!(err, ModelError::NonConvex(_)),
2119                "[{name}] expected ModelError::NonConvex, got {err:?}"
2120            );
2121        }
2122    }
2123
2124    #[test]
2125    fn model_mixed_integer_continuous() {
2126        // x integer in [0,5], y continuous in [0,5], x + y <= 3.5.
2127        // max(x + y) → Optimum: x=3 (integer), y=0.5 → obj 3.5.
2128        let mut m = Model::new("mixed");
2129        let x = m.add_int_var("x", 0.0, 5.0);
2130        let y = m.add_var("y", 0.0, 5.0);
2131        m.add_constraint((x + y).leq(3.5));
2132        m.maximize(x + y);
2133        let r = m.solve().unwrap();
2134        assert!((r.objective() - 3.5).abs() < EPS, "obj={}", r.objective());
2135        assert!((r[x].round() - r[x]).abs() < EPS, "x must be integral, x={}", r[x]);
2136    }
2137
2138    // --- 目的関数定数 fold テスト ---
2139
2140    // ① 線形 minimize: 定数 +3 が obj_value に含まれること
2141    #[test]
2142    fn test_linear_objective_constant_included() {
2143        // min 2x + 3.0  s.t. x ≥ 1  →  x* = 1, obj* = 2*1 + 3 = 5
2144        let mut model = Model::new("lin_const");
2145        let x = model.add_var("x", 1.0, f64::INFINITY);
2146        model.minimize(2.0 * x + 3.0);
2147        let result = model.solve().unwrap();
2148        assert!((result[x] - 1.0).abs() < EPS, "x* should be 1, got {}", result[x]);
2149        assert!(
2150            (result.objective_value - 5.0).abs() < EPS,
2151            "obj* should be 5 (includes constant +3), got {}",
2152            result.objective_value
2153        );
2154    }
2155
2156    // ② 二次 minimize: min (x-2)² = x²-4x+4, x≥0  →  x*=2, obj*=0
2157    #[test]
2158    fn test_quad_objective_constant_included() {
2159        // minimize(x*x - 4.0*x + 4.0)  s.t. x ≥ 0
2160        // x* = 2, obj* = 4 - 8 + 4 = 0
2161        let mut model = Model::new("quad_const");
2162        let x = model.add_var("x", 0.0, f64::INFINITY);
2163        model.minimize(x * x + (-4.0) * x + 4.0);
2164        let result = model.solve().unwrap();
2165        assert!(
2166            (result[x] - 2.0).abs() < 1e-4,
2167            "quad_const: x* should be 2, got {}",
2168            result[x]
2169        );
2170        assert!(
2171            result.objective_value.abs() < 1e-4,
2172            "quad_const: obj* should be 0 (constant +4 included), got {}",
2173            result.objective_value
2174        );
2175    }
2176
2177    // ③ maximize の定数符号: max(-x + 5.0) s.t. x≥0 → x*=0, obj*=5
2178    #[test]
2179    fn test_maximize_objective_constant_sign() {
2180        // max -x + 5.0  s.t. x ≥ 0  →  x* = 0, obj* = 0 + 5 = 5
2181        let mut model = Model::new("max_const");
2182        let x = model.add_var("x", 0.0, 10.0);
2183        model.maximize((-1.0) * x + 5.0);
2184        let result = model.solve().unwrap();
2185        assert!(
2186            (result[x]).abs() < EPS,
2187            "max_const: x* should be 0, got {}",
2188            result[x]
2189        );
2190        assert!(
2191            (result.objective_value - 5.0).abs() < EPS,
2192            "max_const: obj* should be 5 (constant not negated for max), got {}",
2193            result.objective_value
2194        );
2195    }
2196
2197    // ④ set_obj_offset と DSL 定数の併用: 二重加算しないこと
2198    #[test]
2199    fn test_obj_offset_and_dsl_constant_no_double_count() {
2200        // min x + 3.0  s.t. x ≥ 1, with set_obj_offset(10.0)
2201        // obj* = 1 + 3 + 10 = 14
2202        let mut model = Model::new("offset_plus_const");
2203        let x = model.add_var("x", 1.0, f64::INFINITY);
2204        model.minimize(1.0 * x + 3.0);
2205        model.set_obj_offset(10.0);
2206        let result = model.solve().unwrap();
2207        assert!(
2208            (result.objective_value - 14.0).abs() < EPS,
2209            "offset+const: obj* should be 14 (1+3+10), got {}",
2210            result.objective_value
2211        );
2212    }
2213
2214    // ⑤ 再 minimize でリセット: 定数が二重加算されないこと
2215    #[test]
2216    fn test_reminimize_constant_not_double_counted() {
2217        // First minimize(x + 3.0), then minimize(x + 7.0): only the last constant counts.
2218        let mut model = Model::new("reminimize");
2219        let x = model.add_var("x", 1.0, f64::INFINITY);
2220        model.minimize(1.0 * x + 3.0);  // constant = 3.0
2221        model.minimize(1.0 * x + 7.0);  // constant = 7.0 (replaces 3.0)
2222        let result = model.solve().unwrap();
2223        assert!(
2224            (result.objective_value - 8.0).abs() < EPS,
2225            "re-minimize: obj* should be 8 (1+7, not 1+3+7=11), got {}",
2226            result.objective_value
2227        );
2228    }
2229
2230    // ---------------------------------------------------------------------------
2231    // State-machine tests for P2-a / P2-b objective-setter transitions.
2232    // ---------------------------------------------------------------------------
2233
2234    // P2-a: NaN quad (invalid DSL) → pure-linear must clear the stale error.
2235    #[test]
2236    fn test_p2a_nan_dsl_quad_then_linear_is_optimal() {
2237        // minimize(NaN * x*x) sets quad_dsl_error.
2238        // A subsequent minimize(x) clears it (at the start of apply_objective) → solve succeeds.
2239        let mut model = Model::new("p2a_nan_then_linear");
2240        let x = model.add_var("x", 1.0, f64::INFINITY);
2241        model.minimize(f64::NAN * (x * x));   // invalid DSL quad → error recorded
2242        model.minimize(1.0 * x);              // pure-linear replaces objective
2243        let result = model.solve();
2244        assert!(result.is_ok(), "P2-a: should be Optimal after linear replaces NaN quad, got {result:?}");
2245        let r = result.unwrap();
2246        assert!((r[x] - 1.0).abs() < EPS, "P2-a: x* should be 1, got {}", r[x]);
2247    }
2248
2249    // P2-a (reverse): valid quad → then invalid (NaN) quad → solve must error.
2250    #[test]
2251    fn test_p2a_valid_quad_then_nan_errors() {
2252        let mut model = Model::new("p2a_valid_then_nan");
2253        let x = model.add_var("x", 0.0, f64::INFINITY);
2254        model.minimize(x * x + (-2.0) * x);   // valid DSL quad
2255        model.minimize(f64::NAN * (x * x));    // invalid DSL quad → should error
2256        let result = model.solve();
2257        assert!(
2258            matches!(result, Err(ModelError::InvalidInput(_))),
2259            "P2-a: NaN quad must yield InvalidInput, got {result:?}"
2260        );
2261    }
2262
2263    // P2-a: maximize with stale DSL error must also clear on replacement.
2264    #[test]
2265    fn test_p2a_nan_dsl_then_maximize_linear_is_optimal() {
2266        let mut model = Model::new("p2a_nan_then_max");
2267        let x = model.add_var("x", 0.0, 5.0);
2268        model.minimize(f64::NAN * (x * x));   // stale error
2269        model.maximize(1.0 * x);              // pure-linear maximize replaces
2270        let result = model.solve();
2271        assert!(result.is_ok(), "P2-a: maximize should succeed after NaN DSL cleared, got {result:?}");
2272        let r = result.unwrap();
2273        assert!((r[x] - 5.0).abs() < EPS, "P2-a: maximize x → x*=5, got {}", r[x]);
2274    }
2275
2276    // State-machine: DSL transition table (DSL paths only × invariants).
2277    // Each row: transition sequence → expected (has_error, via_dsl, has_q).
2278    #[test]
2279    fn test_quad_state_invariants_transition_table() {
2280        // Helper: build a 1-var model with x in [0,∞) and return (model, x).
2281        fn mk() -> (Model, crate::variable::Variable) {
2282            let mut m = Model::new("t");
2283            let x = m.add_var("x", 0.0, f64::INFINITY);
2284            (m, x)
2285        }
2286
2287        // After DSL success: no error, via_dsl=true, has_q=true.
2288        {
2289            let (mut m, x) = mk();
2290            m.minimize(x * x);
2291            assert!(!m.has_quad_dsl_error(), "DSL ok: no error");
2292            assert!(m.is_quad_via_dsl(),       "DSL ok: via_dsl=true");
2293            assert!(m.has_quadratic_objective(),"DSL ok: has_q=true");
2294        }
2295
2296        // After DSL fail (NaN): error=true, via_dsl=false, has_q=false.
2297        {
2298            let (mut m, x) = mk();
2299            m.minimize(f64::NAN * (x * x));
2300            assert!(m.has_quad_dsl_error(),    "DSL fail: error recorded");
2301            assert!(!m.is_quad_via_dsl(),      "DSL fail: via_dsl=false");
2302            assert!(!m.has_quadratic_objective(),"DSL fail: has_q=false");
2303        }
2304
2305        // DSL success → DSL fail: error=true, via_dsl=false, has_q=false.
2306        {
2307            let (mut m, x) = mk();
2308            m.minimize(x * x);
2309            m.minimize(f64::NAN * (x * x));
2310            assert!(m.has_quad_dsl_error(),    "ok→fail: error recorded");
2311            assert!(!m.is_quad_via_dsl(),      "ok→fail: via_dsl=false");
2312            assert!(!m.has_quadratic_objective(),"ok→fail: has_q=false");
2313        }
2314
2315        // DSL success → linear minimize: error=false, via_dsl=false, has_q=false.
2316        {
2317            let (mut m, x) = mk();
2318            m.minimize(x * x);
2319            m.minimize(1.0 * x);
2320            assert!(!m.has_quad_dsl_error(), "dsl→linear: no error");
2321            assert!(!m.is_quad_via_dsl(),    "dsl→linear: via_dsl=false");
2322            assert!(!m.has_quadratic_objective(),"dsl→linear: DSL Q cleared");
2323        }
2324    }
2325
2326    // solve() result after each DSL transition (not just state — actual Optimal/Error).
2327    #[test]
2328    fn test_quad_state_solve_outcome_table() {
2329        let cases: &[(&str, bool)] = &[
2330            // (description, expect_ok)
2331            // DSL NaN then linear → Optimal (P2-a regression)
2332            ("nan_then_linear", true),
2333            // DSL NaN alone → error
2334            ("nan_alone", false),
2335            // DSL ok then NaN → error (P2-a reverse)
2336            ("ok_then_nan", false),
2337            // P2-i: DSL NaN then valid DSL quad → Optimal (stale error must not block)
2338            ("nan_then_valid_quad", true),
2339            // P2-i: DSL NaN × 2 then valid DSL quad → Optimal
2340            ("nan_nan_then_valid_quad", true),
2341        ];
2342
2343        for &(name, expect_ok) in cases {
2344            let mut m = Model::new(name);
2345            let x = m.add_var("x", 0.0, f64::INFINITY);
2346
2347            match name {
2348                "nan_then_linear" => {
2349                    m.minimize(f64::NAN * (x * x) + x);
2350                    m.minimize(1.0 * x);
2351                }
2352                "nan_alone" => {
2353                    m.minimize(f64::NAN * (x * x) + x);
2354                }
2355                "ok_then_nan" => {
2356                    m.minimize(x * x + (-4.0) * x);
2357                    m.minimize(f64::NAN * (x * x) + x);
2358                }
2359                "nan_then_valid_quad" => {
2360                    m.minimize(f64::NAN * (x * x));
2361                    m.minimize(x * x + (-4.0) * x);
2362                }
2363                "nan_nan_then_valid_quad" => {
2364                    m.minimize(f64::NAN * (x * x));
2365                    m.minimize(f64::NAN * (x * x));
2366                    m.minimize(x * x + (-4.0) * x);
2367                }
2368                _ => unreachable!(),
2369            }
2370
2371            let result = m.solve();
2372            if expect_ok {
2373                assert!(
2374                    result.is_ok(),
2375                    "case '{name}': expected Optimal, got {result:?}"
2376                );
2377            } else {
2378                assert!(
2379                    matches!(result, Err(ModelError::InvalidInput(_))),
2380                    "case '{name}': expected InvalidInput, got {result:?}"
2381                );
2382            }
2383        }
2384    }
2385
2386    // ---------------------------------------------------------------------------
2387    // P2-f: linear part of QuadExpr must also reject foreign-model variables
2388    // ---------------------------------------------------------------------------
2389
2390    // Mixed: quad from m1 + linear from m2 → InvalidInput.
2391    #[test]
2392    fn test_p2f_mixed_quad_local_linear_foreign_rejected() {
2393        let mut m1 = Model::new("m1");
2394        let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2395
2396        let mut m2 = Model::new("m2");
2397        let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2398
2399        // x1 is local (quad ok), y2 is foreign (linear must be rejected).
2400        m1.minimize(x1 * x1 + y2);
2401        let result = m1.solve();
2402        assert!(
2403            matches!(result, Err(ModelError::InvalidInput(_))),
2404            "P2-f mixed: foreign linear term must give InvalidInput, got {result:?}"
2405        );
2406    }
2407
2408    // Pure-linear path with a foreign variable → must also reject (not drop).
2409    #[test]
2410    fn test_p2f_pure_linear_foreign_rejected() {
2411        let mut m1 = Model::new("m1");
2412        let _x1 = m1.add_var("x", 0.0, f64::INFINITY);
2413
2414        let mut m2 = Model::new("m2");
2415        let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2416
2417        // minimize(y2) on m1 — y2 is foreign, must not silently drop.
2418        m1.minimize(1.0 * y2);
2419        let result = m1.solve();
2420        assert!(
2421            matches!(result, Err(ModelError::InvalidInput(_))),
2422            "P2-f pure-linear: foreign variable must give InvalidInput (not silent drop), got {result:?}"
2423        );
2424    }
2425
2426    // Sanity: mixed with all-local variables must succeed (no false positive).
2427    #[test]
2428    fn test_p2f_mixed_all_local_accepted() {
2429        let mut m = Model::new("m");
2430        let x = m.add_var("x", 0.0, f64::INFINITY);
2431        // min x^2 - 4x, x >= 0 → x*=2, obj=-4
2432        m.minimize(x * x + (-4.0) * x);
2433        let result = m.solve().unwrap();
2434        assert!(
2435            (result[x] - 2.0).abs() < EPS,
2436            "P2-f no false positive: x*=2, got {}",
2437            result[x]
2438        );
2439    }
2440
2441    // Cross-term (from m1+m2) + additional linear foreign: both caught.
2442    #[test]
2443    fn test_p2f_cross_term_plus_linear_foreign() {
2444        let mut m1 = Model::new("m1");
2445        let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2446
2447        let mut m2 = Model::new("m2");
2448        let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2449
2450        // x1 * y2 is a cross-model quad; y2 also appears linear.
2451        // Both are foreign from m1's perspective.
2452        m1.minimize(x1 * y2 + 2.0 * y2);
2453        let result = m1.solve();
2454        assert!(
2455            matches!(result, Err(ModelError::InvalidInput(_))),
2456            "P2-f cross+linear: must give InvalidInput, got {result:?}"
2457        );
2458    }
2459
2460    // maximize path: foreign linear variable also rejected via maximize.
2461    #[test]
2462    fn test_p2f_foreign_linear_maximize_rejected() {
2463        let mut m1 = Model::new("m1");
2464        let _x1 = m1.add_var("x", 0.0, 5.0);
2465
2466        let mut m2 = Model::new("m2");
2467        let y2 = m2.add_var("y", 0.0, 5.0);
2468
2469        m1.maximize(1.0 * y2);
2470        let result = m1.solve();
2471        assert!(
2472            matches!(result, Err(ModelError::InvalidInput(_))),
2473            "P2-f maximize foreign linear: must give InvalidInput, got {result:?}"
2474        );
2475    }
2476
2477    // ---------------------------------------------------------------------------
2478    // P2-h: validate_objective rejects non-finite coefficients and constants
2479    // ---------------------------------------------------------------------------
2480
2481    // Non-finite linear coefficient (NaN).
2482    #[test]
2483    fn test_p2h_nan_linear_coef_rejected_at_solve() {
2484        let mut m = Model::new("p2h_nan_coef");
2485        let x = m.add_var("x", 0.0, f64::INFINITY);
2486        m.minimize(f64::NAN * x);
2487        let err = m.solve().unwrap_err();
2488        assert!(
2489            matches!(err, ModelError::InvalidInput(_)),
2490            "P2-h: NaN linear coefficient must give InvalidInput, got {err:?}"
2491        );
2492    }
2493
2494    // Non-finite linear coefficient (Inf).
2495    #[test]
2496    fn test_p2h_inf_linear_coef_rejected_at_solve() {
2497        let mut m = Model::new("p2h_inf_coef");
2498        let x = m.add_var("x", 0.0, f64::INFINITY);
2499        m.minimize(f64::INFINITY * x);
2500        let err = m.solve().unwrap_err();
2501        assert!(
2502            matches!(err, ModelError::InvalidInput(_)),
2503            "P2-h: Inf linear coefficient must give InvalidInput, got {err:?}"
2504        );
2505    }
2506
2507    // Non-finite constant term (NaN via Expression add).
2508    #[test]
2509    fn test_p2h_nan_constant_rejected_at_solve() {
2510        let mut m = Model::new("p2h_nan_const");
2511        let x = m.add_var("x", 0.0, f64::INFINITY);
2512        m.minimize(1.0 * x + f64::NAN);
2513        let err = m.solve().unwrap_err();
2514        assert!(
2515            matches!(err, ModelError::InvalidInput(_)),
2516            "P2-h: NaN constant term must give InvalidInput, got {err:?}"
2517        );
2518    }
2519
2520    // Non-finite constant term (Inf via Expression add).
2521    #[test]
2522    fn test_p2h_inf_constant_rejected_at_solve() {
2523        let mut m = Model::new("p2h_inf_const");
2524        let x = m.add_var("x", 0.0, f64::INFINITY);
2525        m.minimize(1.0 * x + f64::INFINITY);
2526        let err = m.solve().unwrap_err();
2527        assert!(
2528            matches!(err, ModelError::InvalidInput(_)),
2529            "P2-h: Inf constant term must give InvalidInput, got {err:?}"
2530        );
2531    }
2532
2533    // Q-diagonal overflow → Inf stored in CscMatrix → validate_objective 287-293.
2534    // f64::MAX is finite so quad_to_csc's is_finite() check passes, but 2*MAX
2535    // overflows to INFINITY in the stored Q.  validate_objective must reject this.
2536    #[test]
2537    fn test_p2h_inf_q_coef_via_diagonal_overflow_rejected_at_solve() {
2538        let mut m = Model::new("p2h_inf_q_diagonal");
2539        let x = m.add_var("x", 0.0, f64::INFINITY);
2540        // f64::MAX is finite → quad_to_csc passes the is_finite check, but
2541        // stores 2.0 * f64::MAX = INFINITY in the diagonal Q entry.
2542        // validate_objective (lines 287-293) must catch this.
2543        m.minimize(f64::MAX * (x * x));
2544        let err = m.solve().unwrap_err();
2545        assert!(
2546            matches!(err, ModelError::InvalidInput(_)),
2547            "P2-h: Inf Q diagonal (2*MAX overflow) must give InvalidInput, got {err:?}"
2548        );
2549    }
2550
2551    // DSL non-finite Q coefficient (NaN): caught by quad_to_csc → fail_dsl_quad
2552    // → quad_dsl_error fires at line 261, not 287-293. Still InvalidInput.
2553    #[test]
2554    fn test_p2h_nan_q_coef_dsl_rejected_at_solve() {
2555        let mut m = Model::new("p2h_nan_q_dsl");
2556        let x = m.add_var("x", 0.0, f64::INFINITY);
2557        // NaN coefficient is caught earlier (quad_to_csc), but the observable
2558        // result is still InvalidInput at solve time.
2559        m.minimize(f64::NAN * (x * x));
2560        let err = m.solve().unwrap_err();
2561        assert!(
2562            matches!(err, ModelError::InvalidInput(_)),
2563            "P2-h: NaN DSL Q coefficient must give InvalidInput, got {err:?}"
2564        );
2565    }
2566
2567    // ---------------------------------------------------------------------------
2568    // P2-i: stale quad_dsl_error must not block a subsequent valid DSL quad
2569    // ---------------------------------------------------------------------------
2570
2571    // Core repro: minimize(NaN*x*x) then minimize(x*x) then solve() → Optimal.
2572    #[test]
2573    fn test_p2i_nan_quad_replaced_by_valid_quad_is_optimal() {
2574        let mut model = Model::new("p2i_nan_then_valid");
2575        let x = model.add_var("x", 0.0, f64::INFINITY);
2576        model.minimize(f64::NAN * (x * x));   // DSL fail: quad_dsl_error set
2577        assert!(model.has_quad_dsl_error(), "P2-i setup: error must be recorded");
2578
2579        model.minimize(x * x + (-4.0) * x);   // new minimize: clears quad_dsl_error, installs valid Q
2580        assert!(!model.has_quad_dsl_error(), "P2-i: valid minimize must clear quad_dsl_error");
2581        assert!(model.has_quadratic_objective(), "P2-i: valid Q must be installed");
2582
2583        // min x² - 4x, x≥0 → x*=2, obj*=-4
2584        let result = model.solve();
2585        assert!(result.is_ok(), "P2-i: valid quad after NaN must be Optimal, got {result:?}");
2586        let r = result.unwrap();
2587        assert!((r[x] - 2.0).abs() < EPS, "P2-i: x*=2, got {}", r[x]);
2588        assert!((r.objective_value - (-4.0)).abs() < EPS, "P2-i: obj*=-4, got {}", r.objective_value);
2589    }
2590
2591    // NaN quad alone still gives InvalidInput (regression guard).
2592    #[test]
2593    fn test_p2i_nan_quad_alone_is_invalid() {
2594        let mut model = Model::new("p2i_nan_alone");
2595        let x = model.add_var("x", 0.0, f64::INFINITY);
2596        model.minimize(f64::NAN * (x * x));
2597        let result = model.solve();
2598        assert!(
2599            matches!(result, Err(ModelError::InvalidInput(_))),
2600            "P2-i: NaN quad alone must give InvalidInput, got {result:?}"
2601        );
2602    }
2603
2604    // Table: all setter orderings with ultimate valid/invalid state.
2605    #[test]
2606    fn test_p2i_setter_ordering_table() {
2607        // Each entry: (label, setup closure, expect_optimal)
2608        type Setup = fn(&mut Model, crate::variable::Variable);
2609        let cases: &[(&str, Setup, bool)] = &[
2610            // NaN → valid quad → Optimal (P2-i core)
2611            ("nan→valid_quad", |m, x| {
2612                m.minimize(f64::NAN * (x * x));
2613                m.minimize(x * x + (-4.0) * x);
2614            }, true),
2615            // NaN → NaN → valid quad → Optimal
2616            ("nan→nan→valid_quad", |m, x| {
2617                m.minimize(f64::NAN * (x * x));
2618                m.minimize(f64::NAN * (x * x));
2619                m.minimize(x * x + (-4.0) * x);
2620            }, true),
2621            // valid quad → NaN → InvalidInput
2622            ("valid→nan", |m, x| {
2623                m.minimize(x * x + (-4.0) * x);
2624                m.minimize(f64::NAN * (x * x));
2625            }, false),
2626            // NaN → linear → Optimal (P2-a, must still pass)
2627            ("nan→linear", |m, x| {
2628                m.minimize(f64::NAN * (x * x));
2629                m.minimize(1.0 * x);  // minimize x, x≥0 → x*=0
2630            }, true),
2631            // NaN → valid quad → NaN → InvalidInput
2632            ("nan→valid→nan", |m, x| {
2633                m.minimize(f64::NAN * (x * x));
2634                m.minimize(x * x + (-4.0) * x);
2635                m.minimize(f64::NAN * (x * x));
2636            }, false),
2637        ];
2638
2639        for &(label, setup, expect_optimal) in cases {
2640            let mut m = Model::new(label);
2641            let x = m.add_var("x", 0.0, f64::INFINITY);
2642            setup(&mut m, x);
2643            let result = m.solve();
2644            if expect_optimal {
2645                assert!(result.is_ok(), "P2-i [{label}]: expected Optimal, got {result:?}");
2646            } else {
2647                assert!(
2648                    matches!(result, Err(ModelError::InvalidInput(_))),
2649                    "P2-i [{label}]: expected InvalidInput, got {result:?}"
2650                );
2651            }
2652        }
2653    }
2654
2655}