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    #[allow(clippy::type_complexity)]
1620    fn test_model_qp_locally_optimal_proof() {
1621        // (name, q_diag, bounds, c) — all 2-variable diagonal-Q cases.
1622        let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1623            // Diagonal indefinite Q: eigenvalues -2, +2.
1624            ("diag(-2,2)", [-2.0, 2.0], (-1.0, 1.0), [0.0, 0.0]),
1625            // Diagonal indefinite Q: eigenvalues -3, +5 with linear term.
1626            ("diag(-3,5)", [-3.0, 5.0], (-2.0, 2.0), [-1.0, 0.0]),
1627        ];
1628
1629        for &(name, q_diag, (lb, ub), c) in cases {
1630            let mut model = Model::new(name);
1631            let x = model.add_var("x0", lb, ub);
1632            let y = model.add_var("x1", lb, ub);
1633            // Q = diag(q_diag): DSL term (d/2)*xi*xi per variable
1634            model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1635
1636            let result = model.solve();
1637            match result {
1638                Ok(r) => {
1639                    assert_eq!(
1640                        r.status,
1641                        otspot_core::problem::SolveStatus::LocallyOptimal,
1642                        "[{name}] expected LocallyOptimal, got {:?}",
1643                        r.status
1644                    );
1645                    assert_eq!(
1646                        r.proof,
1647                        SolutionProof::LocalOptimal,
1648                        "[{name}] expected LocalOptimal proof, got {:?}",
1649                        r.proof
1650                    );
1651                    assert!(
1652                        !r.has_global_optimality_proof(),
1653                        "[{name}] has_global_optimality_proof must be false for LocallyOptimal"
1654                    );
1655                }
1656                Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1657            }
1658        }
1659    }
1660
1661    // -----------------------------------------------------------------------
1662    // FeasibleUnproven proof: impossibly-tight tolerance forces SuboptimalSolution
1663    // on a convex QP that the IPM solves to finite residuals (table-driven).
1664    //
1665    // Sentinel: replacing from_status with a no-op returning GlobalOptimal
1666    // causes the assert_eq!(proof, FeasibleUnproven) to FAIL.
1667    // -----------------------------------------------------------------------
1668    #[test]
1669    #[allow(clippy::type_complexity)]
1670    fn test_model_qp_feasible_unproven_proof() {
1671        use otspot_core::options::Tolerance;
1672
1673        // (name, q_diag, (lb,ub), c)
1674        let cases: &[(&str, [f64; 2], (f64, f64), [f64; 2])] = &[
1675            // Convex PSD Q=2I, c=[-4,-4]. IPM converges (residuals ~1e-6) but
1676            // Custom(1e-200) makes satisfies_eps always false → SuboptimalSolution.
1677            ("convex_2x2_tight_tol", [2.0, 2.0], (0.0, f64::INFINITY), [-4.0, -4.0]),
1678            // Convex PSD Q=4I, c=[0,-2] with box bounds.
1679            ("convex_box_tight_tol", [4.0, 4.0], (0.0, 3.0), [0.0, -2.0]),
1680        ];
1681
1682        for &(name, q_diag, (lb, ub), c) in cases {
1683            let mut model = Model::new(name);
1684            let x = model.add_var("x0", lb, ub);
1685            let y = model.add_var("x1", lb, ub);
1686            model.minimize((q_diag[0] / 2.0) * x * x + (q_diag[1] / 2.0) * y * y + c[0] * x + c[1] * y);
1687            // Impossibly tight tolerance: IPM finds a finite-residual solution
1688            // but satisfies_eps(1e-200) is always false → SuboptimalSolution.
1689            model.set_tolerance(Tolerance::Custom(1e-200));
1690
1691            let result = model.solve();
1692            match result {
1693                Ok(r) => {
1694                    assert_eq!(
1695                        r.proof,
1696                        SolutionProof::FeasibleUnproven,
1697                        "[{name}] expected FeasibleUnproven proof, got {:?} (status={:?})",
1698                        r.proof, r.status
1699                    );
1700                    assert!(
1701                        !r.has_global_optimality_proof(),
1702                        "[{name}] has_global_optimality_proof must be false for FeasibleUnproven"
1703                    );
1704                    assert!(!r.solution.is_empty(), "[{name}] solution must be non-empty");
1705                }
1706                Err(e) => panic!("[{name}] unexpected Err: {e:?}"),
1707            }
1708        }
1709    }
1710
1711    // -----------------------------------------------------------------------
1712    // Sentinel: classify_status_error maps NonConvex/NotSupported to typed
1713    // variants (not Internal). Reverting the mapping to Internal causes FAIL.
1714    // -----------------------------------------------------------------------
1715    #[test]
1716    fn classify_status_error_typed_variants() {
1717        let cases_nonconvex = [
1718            "indefinite Q: eigenvalue < 0",
1719            "non-PSD matrix in MIQP",
1720        ];
1721        for msg in &cases_nonconvex {
1722            let status = SolveStatus::NonConvex(msg.to_string());
1723            let err = classify_status_error(status).expect("NonConvex must map to Some");
1724            assert!(
1725                matches!(err, ModelError::NonConvex(_)),
1726                "NonConvex status must yield ModelError::NonConvex, got {:?}",
1727                err
1728            );
1729        }
1730
1731        let cases_not_supported = [
1732            "QCQP not supported",
1733            "constraint type unsupported",
1734        ];
1735        for msg in &cases_not_supported {
1736            let status = SolveStatus::NotSupported(msg.to_string());
1737            let err = classify_status_error(status).expect("NotSupported must map to Some");
1738            assert!(
1739                matches!(err, ModelError::NotSupported(_)),
1740                "NotSupported status must yield ModelError::NotSupported, got {:?}",
1741                err
1742            );
1743        }
1744    }
1745
1746    // -----------------------------------------------------------------------
1747    // Sentinel: MIQP with indefinite Q returns ModelError::NonConvex.
1748    // Reverting NonConvex → Internal in finish_mip causes FAIL.
1749    // Table-driven: multiple indefinite Q shapes.
1750    // -----------------------------------------------------------------------
1751    #[test]
1752    fn miqp_nonconvex_q_returns_nonconvex_error() {
1753        let cases: &[(&str, [f64; 2])] = &[
1754            ("diag(-1, 1)", [-1.0, 1.0]),
1755            ("diag(-2, 3)", [-2.0, 3.0]),
1756            ("diag(1, -1)", [1.0, -1.0]),
1757        ];
1758
1759        for &(name, q_diag) in cases {
1760            let mut model = Model::new(name);
1761            let x = model.add_binary_var("x");
1762            let y = model.add_binary_var("y");
1763            // Q=diag(q_diag): Q[i][i]=d → (d/2)*xi*xi in DSL
1764            model.minimize((q_diag[0] / 2.0) * (x * x) + (q_diag[1] / 2.0) * (y * y));
1765
1766            let err = model
1767                .solve()
1768                .expect_err(&format!("[{name}] expected Err(NonConvex), got Ok"));
1769            assert!(
1770                matches!(err, ModelError::NonConvex(_)),
1771                "[{name}] expected ModelError::NonConvex, got {:?}",
1772                err
1773            );
1774        }
1775    }
1776
1777    // -----------------------------------------------------------------------
1778    // Sentinel: validate_bounds rejects lb>ub and NaN.
1779    // No-op'ing validate_bounds (always Ok) causes these tests to FAIL:
1780    //   - add_var with lb>ub would not record error → solve() would succeed
1781    //     (an LP with inverted bounds becomes infeasible but NOT an InvalidInput).
1782    // -----------------------------------------------------------------------
1783    #[test]
1784    fn add_var_lb_gt_ub_defers_error_to_solve() {
1785        let cases: &[(&str, f64, f64)] = &[
1786            ("lb=5 > ub=3", 5.0, 3.0),
1787            ("lb=1.0 > ub=0.999", 1.0, 0.999),
1788            ("lb=inf > ub=0", f64::INFINITY, 0.0),
1789        ];
1790        for &(label, lb, ub) in cases {
1791            let mut model = Model::new(label);
1792            let x = model.add_var("x", lb, ub);
1793            model.minimize(x);
1794            let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1795            assert!(
1796                matches!(err, ModelError::InvalidInput(_)),
1797                "[{label}] expected InvalidInput, got {err:?}"
1798            );
1799        }
1800    }
1801
1802    #[test]
1803    fn add_var_nan_bounds_defers_error_to_solve() {
1804        let cases: &[(&str, f64, f64)] = &[
1805            ("lb=NaN", f64::NAN, 1.0),
1806            ("ub=NaN", 0.0, f64::NAN),
1807            ("both=NaN", f64::NAN, f64::NAN),
1808        ];
1809        for &(label, lb, ub) in cases {
1810            let mut model = Model::new(label);
1811            let x = model.add_var("x", lb, ub);
1812            model.minimize(x);
1813            let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1814            assert!(
1815                matches!(err, ModelError::InvalidInput(_)),
1816                "[{label}] expected InvalidInput, got {err:?}"
1817            );
1818        }
1819    }
1820
1821    #[test]
1822    fn add_int_var_lb_gt_ub_defers_error_to_solve() {
1823        let cases: &[(&str, f64, f64)] = &[
1824            ("int lb=3 > ub=1", 3.0, 1.0),
1825            ("int lb=NaN", f64::NAN, 5.0),
1826        ];
1827        for &(label, lb, ub) in cases {
1828            let mut model = Model::new(label);
1829            let x = model.add_int_var("x", lb, ub);
1830            model.minimize(x);
1831            let err = model.solve().expect_err(&format!("[{label}] expected Err, got Ok"));
1832            assert!(
1833                matches!(err, ModelError::InvalidInput(_)),
1834                "[{label}] expected InvalidInput, got {err:?}"
1835            );
1836        }
1837    }
1838
1839    // -----------------------------------------------------------------------
1840    // try_add_var / try_add_int_var: immediate Result API.
1841    // Sentinel: no-op of validate_bounds makes all these Ok → assert!(is_err()) FAILs.
1842    // -----------------------------------------------------------------------
1843    #[test]
1844    fn try_add_var_returns_err_for_invalid_bounds() {
1845        let cases: &[(&str, f64, f64)] = &[
1846            ("lb>ub", 2.0, 1.0),
1847            ("lb=NaN", f64::NAN, 1.0),
1848            ("ub=NaN", 0.0, f64::NAN),
1849            ("lb=inf > ub=0", f64::INFINITY, 0.0),
1850        ];
1851        for &(label, lb, ub) in cases {
1852            let mut model = Model::new(label);
1853            let result = model.try_add_var("x", lb, ub);
1854            assert!(
1855                result.is_err(),
1856                "[{label}] try_add_var should return Err for invalid bounds, got Ok"
1857            );
1858        }
1859    }
1860
1861    #[test]
1862    fn try_add_var_returns_ok_for_valid_bounds() {
1863        let cases: &[(&str, f64, f64)] = &[
1864            ("lb=ub", 3.0, 3.0),
1865            ("lb=0 ub=inf", 0.0, f64::INFINITY),
1866            ("lb=-inf ub=inf", f64::NEG_INFINITY, f64::INFINITY),
1867            ("lb=-inf ub=0", f64::NEG_INFINITY, 0.0),
1868        ];
1869        for &(label, lb, ub) in cases {
1870            let mut model = Model::new(label);
1871            assert!(
1872                model.try_add_var("x", lb, ub).is_ok(),
1873                "[{label}] try_add_var should return Ok for valid bounds"
1874            );
1875        }
1876    }
1877
1878    #[test]
1879    fn try_add_int_var_returns_err_for_invalid_bounds() {
1880        let cases: &[(&str, f64, f64)] = &[
1881            ("int lb>ub", 5.0, 2.0),
1882            ("int lb=NaN", f64::NAN, 3.0),
1883        ];
1884        for &(label, lb, ub) in cases {
1885            let mut model = Model::new(label);
1886            assert!(
1887                model.try_add_int_var("n", lb, ub).is_err(),
1888                "[{label}] try_add_int_var should return Err"
1889            );
1890        }
1891    }
1892
1893    // -----------------------------------------------------------------------
1894    // try_value: safe accessor — wrong model_id and out-of-range both return Err.
1895    // Sentinel: removing the model_id check makes cross-model test pass/Err → Ok
1896    //   causing the assert!(result.is_err()) to FAIL.
1897    // -----------------------------------------------------------------------
1898    #[test]
1899    fn try_value_cross_model_returns_err() {
1900        let mut m1 = Model::new("m1");
1901        let x1 = m1.add_var("x", 0.0, f64::INFINITY);
1902        m1.minimize(x1);
1903        let r1 = m1.solve().unwrap();
1904
1905        // Variable from a different model — same index (0), different model_id.
1906        let mut m2 = Model::new("m2");
1907        let y = m2.add_var("y", 0.0, f64::INFINITY);
1908
1909        assert!(
1910            r1.try_value(y).is_err(),
1911            "try_value with variable from different model must return Err"
1912        );
1913        // Correct variable works fine.
1914        assert!(r1.try_value(x1).is_ok());
1915    }
1916
1917    #[test]
1918    fn try_value_valid_returns_ok() {
1919        let (mut model, x, y) = basic_model();
1920        let result = model.solve().unwrap();
1921        assert!(result.try_value(x).is_ok());
1922        assert!(result.try_value(y).is_ok());
1923        assert!((result.try_value(x).unwrap() - result.value(x)).abs() < 1e-12);
1924    }
1925
1926    // Out-of-range with a *matching* model_id: a variable added to the same
1927    // model after solving has an index past the result's solution vector.
1928    // Sentinel for the `.ok_or_else` branch (the model_id check passes here, so
1929    // no-op'ing the bounds check — e.g. `self.solution[var.index]` — panics).
1930    #[test]
1931    fn try_value_out_of_range_same_model_returns_err() {
1932        let mut model = Model::new("grow");
1933        let x = model.add_var("x", 0.0, f64::INFINITY);
1934        model.minimize(x);
1935        let result = model.solve().unwrap();
1936
1937        // Extend the same model: same model_id, index beyond solution length.
1938        let late = model.add_var("late", 0.0, f64::INFINITY);
1939        assert_eq!(late.model_id, result.model_id, "same model_id expected");
1940        assert!(
1941            late.index >= result.solution.len(),
1942            "test setup: late var must be out of range"
1943        );
1944        assert!(
1945            result.try_value(late).is_err(),
1946            "try_value must return Err for an out-of-range index even when model_id matches"
1947        );
1948    }
1949
1950    // -----------------------------------------------------------------------
1951    // ModelResult: Clone derive
1952    // -----------------------------------------------------------------------
1953    #[test]
1954    fn model_result_clone() {
1955        let (mut model, x, _y) = basic_model();
1956        let result = model.solve().unwrap();
1957        let cloned = result.clone();
1958        assert!((cloned.objective_value - result.objective_value).abs() < 1e-12);
1959        assert_eq!(cloned.solution.len(), result.solution.len());
1960        assert!((cloned[x] - result[x]).abs() < 1e-12);
1961    }
1962
1963    // -----------------------------------------------------------------------
1964    // Variable name retention
1965    // -----------------------------------------------------------------------
1966    #[test]
1967    fn var_name_is_retained() {
1968        let cases = [("alpha", 0.0, 1.0), ("beta_var", 0.0, f64::INFINITY)];
1969        let mut model = Model::new("named");
1970        for &(name, lb, ub) in &cases {
1971            let v = model.add_var(name, lb, ub);
1972            assert_eq!(model.var_name(v), name, "var_name mismatch for '{name}'");
1973        }
1974        let iv = model.add_int_var("gamma_int", 0.0, 10.0);
1975        assert_eq!(model.var_name(iv), "gamma_int");
1976    }
1977
1978    // -----------------------------------------------------------------------
1979    // set_timeout validation (already implemented; table-driven sentinel)
1980    // No-op'ing validate_timeout makes negative/NaN tests succeed → FAILs.
1981    // -----------------------------------------------------------------------
1982    #[test]
1983    fn set_timeout_invalid_defers_error() {
1984        let cases: &[(&str, f64)] = &[
1985            ("negative", -1.0),
1986            ("NaN", f64::NAN),
1987            ("neg_inf", f64::NEG_INFINITY),
1988        ];
1989        for &(label, secs) in cases {
1990            let mut model = Model::new(label);
1991            let x = model.add_var("x", 0.0, f64::INFINITY);
1992            model.minimize(x);
1993            model.set_timeout(secs);
1994            let err = model.solve().expect_err(&format!("[{label}] expected Err for invalid timeout"));
1995            assert!(
1996                matches!(err, ModelError::InvalidInput(_)),
1997                "[{label}] expected InvalidInput, got {err:?}"
1998            );
1999        }
2000    }
2001
2002    #[test]
2003    fn try_set_timeout_returns_err_for_invalid() {
2004        let cases: &[(&str, f64)] = &[("negative", -0.001), ("NaN", f64::NAN), ("inf", f64::INFINITY)];
2005        for &(label, secs) in cases {
2006            let mut model = Model::new(label);
2007            assert!(
2008                model.try_set_timeout(secs).is_err(),
2009                "[{label}] try_set_timeout should return Err"
2010            );
2011        }
2012    }
2013
2014    #[test]
2015    fn try_set_timeout_ok_for_valid() {
2016        let valid = [0.0, 0.001, 1.0, 3600.0];
2017        for &secs in &valid {
2018            let mut model = Model::new("t");
2019            assert!(model.try_set_timeout(secs).is_ok(), "should be Ok for secs={secs}");
2020        }
2021    }
2022}
2023
2024// ---------------------------------------------------------------------------
2025// Model API tests for MILP / MIQP paths.
2026//
2027// A lib-target module (kept separate so it runs under the CI `lib-only`
2028// nextest profile, which excludes `tests/` integration targets). Exercises the
2029// `Model` high-level API over the MILP/MIQP branch-and-bound solver.
2030// ---------------------------------------------------------------------------
2031
2032#[cfg(test)]
2033mod mip_model_tests {
2034    use super::{Model, ModelError, SolveError};
2035
2036    const EPS: f64 = 1e-4;
2037
2038    #[test]
2039    fn model_add_int_var_maximize_branches() {
2040        // max x s.t. 2x <= 3, x integer in [0,5] → x = 1, obj = 1.
2041        let mut m = Model::new("milp_int");
2042        let x = m.add_int_var("x", 0.0, 5.0);
2043        m.add_constraint((2.0 * x).leq(3.0));
2044        m.maximize(x);
2045        let r = m.solve().unwrap();
2046        assert!((r.objective() - 1.0).abs() < EPS, "obj={}", r.objective());
2047        assert!((r[x] - 1.0).abs() < EPS, "x={}", r[x]);
2048    }
2049
2050    #[test]
2051    fn model_binary_knapsack() {
2052        let mut m = Model::new("knapsack");
2053        let a = m.add_binary_var("a");
2054        let b = m.add_binary_var("b");
2055        let c = m.add_binary_var("c");
2056        let d = m.add_binary_var("d");
2057        m.add_constraint((5.0 * a + 7.0 * b + 4.0 * c + 3.0 * d).leq(14.0));
2058        m.maximize(8.0 * a + 11.0 * b + 6.0 * c + 4.0 * d);
2059        let r = m.solve().unwrap();
2060        assert!((r.objective() - 21.0).abs() < EPS, "obj={}", r.objective());
2061        assert_eq!(
2062            (r[a].round(), r[b].round(), r[c].round(), r[d].round()),
2063            (0.0, 1.0, 1.0, 1.0)
2064        );
2065    }
2066
2067    #[test]
2068    fn model_integer_infeasible_errors() {
2069        let mut m = Model::new("infeasible");
2070        let x = m.add_int_var("x", 0.0, 10.0);
2071        m.add_constraint((1.0 * x).geq(1.2));
2072        m.add_constraint((1.0 * x).leq(1.8));
2073        m.minimize(x);
2074        let err = m.solve().unwrap_err();
2075        assert!(matches!(err, ModelError::SolveError(SolveError::Infeasible)), "got {err:?}");
2076    }
2077
2078    #[test]
2079    fn model_integer_unbounded_errors() {
2080        let mut m = Model::new("unbounded");
2081        let x = m.add_int_var("x", 0.0, f64::INFINITY);
2082        m.maximize(x);
2083        let err = m.solve().unwrap_err();
2084        assert!(matches!(err, ModelError::SolveError(SolveError::Unbounded)), "got {err:?}");
2085    }
2086
2087    #[test]
2088    fn model_convex_miqp_branches_to_integer_optimum() {
2089        // min x^2 - 5x, x integer in [0,5].
2090        // Continuous min at x=2.5 (fractional → branch); integer optima x=2 or x=3, obj = -6.
2091        let mut m = Model::new("convex_miqp");
2092        let x = m.add_int_var("x", 0.0, 5.0);
2093        m.minimize(x * x + (-5.0) * x);
2094        let r = m.solve().unwrap();
2095        assert!((r.objective() - (-6.0)).abs() < EPS, "obj={}", r.objective());
2096        let xr = r[x].round();
2097        assert!(xr == 2.0 || xr == 3.0, "x must be 2 or 3, got {}", r[x]);
2098        assert!((r[x] - xr).abs() < EPS, "x must be integral: {}", r[x]);
2099    }
2100
2101    #[test]
2102    fn model_nonconvex_miqp_errors() {
2103        // indefinite Q (negative curvature) → must return ModelError::NonConvex, not silent wrong.
2104        let cases: &[(&str, &[f64], &[f64])] = &[
2105            ("single neg", &[-2.0], &[1.0]),
2106            ("neg-pos-2var", &[-3.0, 2.0], &[0.0, 1.0]),
2107        ];
2108        for &(name, q_diag, c_vec) in cases {
2109            let n = q_diag.len();
2110            let mut m = Model::new(name);
2111            let vars: Vec<_> = (0..n).map(|i| m.add_int_var(&format!("x{i}"), 0.0, 5.0)).collect();
2112            // Build objective: sum (d_i/2)*x_i^2 + c_i*x_i via DSL
2113            let obj = vars.iter().zip(q_diag).zip(c_vec).fold(
2114                crate::quad_expr::QuadExpr::from(0.0_f64),
2115                |acc, ((&v, &d), &c)| acc + (d / 2.0) * (v * v) + c * v,
2116            );
2117            m.minimize(obj);
2118            let err = m.solve().unwrap_err();
2119            assert!(
2120                matches!(err, ModelError::NonConvex(_)),
2121                "[{name}] expected ModelError::NonConvex, got {err:?}"
2122            );
2123        }
2124    }
2125
2126    #[test]
2127    fn model_mixed_integer_continuous() {
2128        // x integer in [0,5], y continuous in [0,5], x + y <= 3.5.
2129        // max(x + y) → Optimum: x=3 (integer), y=0.5 → obj 3.5.
2130        let mut m = Model::new("mixed");
2131        let x = m.add_int_var("x", 0.0, 5.0);
2132        let y = m.add_var("y", 0.0, 5.0);
2133        m.add_constraint((x + y).leq(3.5));
2134        m.maximize(x + y);
2135        let r = m.solve().unwrap();
2136        assert!((r.objective() - 3.5).abs() < EPS, "obj={}", r.objective());
2137        assert!((r[x].round() - r[x]).abs() < EPS, "x must be integral, x={}", r[x]);
2138    }
2139
2140    // --- 目的関数定数 fold テスト ---
2141
2142    // ① 線形 minimize: 定数 +3 が obj_value に含まれること
2143    #[test]
2144    fn test_linear_objective_constant_included() {
2145        // min 2x + 3.0  s.t. x ≥ 1  →  x* = 1, obj* = 2*1 + 3 = 5
2146        let mut model = Model::new("lin_const");
2147        let x = model.add_var("x", 1.0, f64::INFINITY);
2148        model.minimize(2.0 * x + 3.0);
2149        let result = model.solve().unwrap();
2150        assert!((result[x] - 1.0).abs() < EPS, "x* should be 1, got {}", result[x]);
2151        assert!(
2152            (result.objective_value - 5.0).abs() < EPS,
2153            "obj* should be 5 (includes constant +3), got {}",
2154            result.objective_value
2155        );
2156    }
2157
2158    // ② 二次 minimize: min (x-2)² = x²-4x+4, x≥0  →  x*=2, obj*=0
2159    #[test]
2160    fn test_quad_objective_constant_included() {
2161        // minimize(x*x - 4.0*x + 4.0)  s.t. x ≥ 0
2162        // x* = 2, obj* = 4 - 8 + 4 = 0
2163        let mut model = Model::new("quad_const");
2164        let x = model.add_var("x", 0.0, f64::INFINITY);
2165        model.minimize(x * x + (-4.0) * x + 4.0);
2166        let result = model.solve().unwrap();
2167        assert!(
2168            (result[x] - 2.0).abs() < 1e-4,
2169            "quad_const: x* should be 2, got {}",
2170            result[x]
2171        );
2172        assert!(
2173            result.objective_value.abs() < 1e-4,
2174            "quad_const: obj* should be 0 (constant +4 included), got {}",
2175            result.objective_value
2176        );
2177    }
2178
2179    // ③ maximize の定数符号: max(-x + 5.0) s.t. x≥0 → x*=0, obj*=5
2180    #[test]
2181    fn test_maximize_objective_constant_sign() {
2182        // max -x + 5.0  s.t. x ≥ 0  →  x* = 0, obj* = 0 + 5 = 5
2183        let mut model = Model::new("max_const");
2184        let x = model.add_var("x", 0.0, 10.0);
2185        model.maximize((-1.0) * x + 5.0);
2186        let result = model.solve().unwrap();
2187        assert!(
2188            (result[x]).abs() < EPS,
2189            "max_const: x* should be 0, got {}",
2190            result[x]
2191        );
2192        assert!(
2193            (result.objective_value - 5.0).abs() < EPS,
2194            "max_const: obj* should be 5 (constant not negated for max), got {}",
2195            result.objective_value
2196        );
2197    }
2198
2199    // ④ set_obj_offset と DSL 定数の併用: 二重加算しないこと
2200    #[test]
2201    fn test_obj_offset_and_dsl_constant_no_double_count() {
2202        // min x + 3.0  s.t. x ≥ 1, with set_obj_offset(10.0)
2203        // obj* = 1 + 3 + 10 = 14
2204        let mut model = Model::new("offset_plus_const");
2205        let x = model.add_var("x", 1.0, f64::INFINITY);
2206        model.minimize(1.0 * x + 3.0);
2207        model.set_obj_offset(10.0);
2208        let result = model.solve().unwrap();
2209        assert!(
2210            (result.objective_value - 14.0).abs() < EPS,
2211            "offset+const: obj* should be 14 (1+3+10), got {}",
2212            result.objective_value
2213        );
2214    }
2215
2216    // ⑤ 再 minimize でリセット: 定数が二重加算されないこと
2217    #[test]
2218    fn test_reminimize_constant_not_double_counted() {
2219        // First minimize(x + 3.0), then minimize(x + 7.0): only the last constant counts.
2220        let mut model = Model::new("reminimize");
2221        let x = model.add_var("x", 1.0, f64::INFINITY);
2222        model.minimize(1.0 * x + 3.0);  // constant = 3.0
2223        model.minimize(1.0 * x + 7.0);  // constant = 7.0 (replaces 3.0)
2224        let result = model.solve().unwrap();
2225        assert!(
2226            (result.objective_value - 8.0).abs() < EPS,
2227            "re-minimize: obj* should be 8 (1+7, not 1+3+7=11), got {}",
2228            result.objective_value
2229        );
2230    }
2231
2232    // ---------------------------------------------------------------------------
2233    // State-machine tests for P2-a / P2-b objective-setter transitions.
2234    // ---------------------------------------------------------------------------
2235
2236    // P2-a: NaN quad (invalid DSL) → pure-linear must clear the stale error.
2237    #[test]
2238    fn test_p2a_nan_dsl_quad_then_linear_is_optimal() {
2239        // minimize(NaN * x*x) sets quad_dsl_error.
2240        // A subsequent minimize(x) clears it (at the start of apply_objective) → solve succeeds.
2241        let mut model = Model::new("p2a_nan_then_linear");
2242        let x = model.add_var("x", 1.0, f64::INFINITY);
2243        model.minimize(f64::NAN * (x * x));   // invalid DSL quad → error recorded
2244        model.minimize(1.0 * x);              // pure-linear replaces objective
2245        let result = model.solve();
2246        assert!(result.is_ok(), "P2-a: should be Optimal after linear replaces NaN quad, got {result:?}");
2247        let r = result.unwrap();
2248        assert!((r[x] - 1.0).abs() < EPS, "P2-a: x* should be 1, got {}", r[x]);
2249    }
2250
2251    // P2-a (reverse): valid quad → then invalid (NaN) quad → solve must error.
2252    #[test]
2253    fn test_p2a_valid_quad_then_nan_errors() {
2254        let mut model = Model::new("p2a_valid_then_nan");
2255        let x = model.add_var("x", 0.0, f64::INFINITY);
2256        model.minimize(x * x + (-2.0) * x);   // valid DSL quad
2257        model.minimize(f64::NAN * (x * x));    // invalid DSL quad → should error
2258        let result = model.solve();
2259        assert!(
2260            matches!(result, Err(ModelError::InvalidInput(_))),
2261            "P2-a: NaN quad must yield InvalidInput, got {result:?}"
2262        );
2263    }
2264
2265    // P2-a: maximize with stale DSL error must also clear on replacement.
2266    #[test]
2267    fn test_p2a_nan_dsl_then_maximize_linear_is_optimal() {
2268        let mut model = Model::new("p2a_nan_then_max");
2269        let x = model.add_var("x", 0.0, 5.0);
2270        model.minimize(f64::NAN * (x * x));   // stale error
2271        model.maximize(1.0 * x);              // pure-linear maximize replaces
2272        let result = model.solve();
2273        assert!(result.is_ok(), "P2-a: maximize should succeed after NaN DSL cleared, got {result:?}");
2274        let r = result.unwrap();
2275        assert!((r[x] - 5.0).abs() < EPS, "P2-a: maximize x → x*=5, got {}", r[x]);
2276    }
2277
2278    // State-machine: DSL transition table (DSL paths only × invariants).
2279    // Each row: transition sequence → expected (has_error, via_dsl, has_q).
2280    #[test]
2281    fn test_quad_state_invariants_transition_table() {
2282        // Helper: build a 1-var model with x in [0,∞) and return (model, x).
2283        fn mk() -> (Model, crate::variable::Variable) {
2284            let mut m = Model::new("t");
2285            let x = m.add_var("x", 0.0, f64::INFINITY);
2286            (m, x)
2287        }
2288
2289        // After DSL success: no error, via_dsl=true, has_q=true.
2290        {
2291            let (mut m, x) = mk();
2292            m.minimize(x * x);
2293            assert!(!m.has_quad_dsl_error(), "DSL ok: no error");
2294            assert!(m.is_quad_via_dsl(),       "DSL ok: via_dsl=true");
2295            assert!(m.has_quadratic_objective(),"DSL ok: has_q=true");
2296        }
2297
2298        // After DSL fail (NaN): error=true, via_dsl=false, has_q=false.
2299        {
2300            let (mut m, x) = mk();
2301            m.minimize(f64::NAN * (x * x));
2302            assert!(m.has_quad_dsl_error(),    "DSL fail: error recorded");
2303            assert!(!m.is_quad_via_dsl(),      "DSL fail: via_dsl=false");
2304            assert!(!m.has_quadratic_objective(),"DSL fail: has_q=false");
2305        }
2306
2307        // DSL success → DSL fail: error=true, via_dsl=false, has_q=false.
2308        {
2309            let (mut m, x) = mk();
2310            m.minimize(x * x);
2311            m.minimize(f64::NAN * (x * x));
2312            assert!(m.has_quad_dsl_error(),    "ok→fail: error recorded");
2313            assert!(!m.is_quad_via_dsl(),      "ok→fail: via_dsl=false");
2314            assert!(!m.has_quadratic_objective(),"ok→fail: has_q=false");
2315        }
2316
2317        // DSL success → linear minimize: error=false, via_dsl=false, has_q=false.
2318        {
2319            let (mut m, x) = mk();
2320            m.minimize(x * x);
2321            m.minimize(1.0 * x);
2322            assert!(!m.has_quad_dsl_error(), "dsl→linear: no error");
2323            assert!(!m.is_quad_via_dsl(),    "dsl→linear: via_dsl=false");
2324            assert!(!m.has_quadratic_objective(),"dsl→linear: DSL Q cleared");
2325        }
2326    }
2327
2328    // solve() result after each DSL transition (not just state — actual Optimal/Error).
2329    #[test]
2330    fn test_quad_state_solve_outcome_table() {
2331        let cases: &[(&str, bool)] = &[
2332            // (description, expect_ok)
2333            // DSL NaN then linear → Optimal (P2-a regression)
2334            ("nan_then_linear", true),
2335            // DSL NaN alone → error
2336            ("nan_alone", false),
2337            // DSL ok then NaN → error (P2-a reverse)
2338            ("ok_then_nan", false),
2339            // P2-i: DSL NaN then valid DSL quad → Optimal (stale error must not block)
2340            ("nan_then_valid_quad", true),
2341            // P2-i: DSL NaN × 2 then valid DSL quad → Optimal
2342            ("nan_nan_then_valid_quad", true),
2343        ];
2344
2345        for &(name, expect_ok) in cases {
2346            let mut m = Model::new(name);
2347            let x = m.add_var("x", 0.0, f64::INFINITY);
2348
2349            match name {
2350                "nan_then_linear" => {
2351                    m.minimize(f64::NAN * (x * x) + x);
2352                    m.minimize(1.0 * x);
2353                }
2354                "nan_alone" => {
2355                    m.minimize(f64::NAN * (x * x) + x);
2356                }
2357                "ok_then_nan" => {
2358                    m.minimize(x * x + (-4.0) * x);
2359                    m.minimize(f64::NAN * (x * x) + x);
2360                }
2361                "nan_then_valid_quad" => {
2362                    m.minimize(f64::NAN * (x * x));
2363                    m.minimize(x * x + (-4.0) * x);
2364                }
2365                "nan_nan_then_valid_quad" => {
2366                    m.minimize(f64::NAN * (x * x));
2367                    m.minimize(f64::NAN * (x * x));
2368                    m.minimize(x * x + (-4.0) * x);
2369                }
2370                _ => unreachable!(),
2371            }
2372
2373            let result = m.solve();
2374            if expect_ok {
2375                assert!(
2376                    result.is_ok(),
2377                    "case '{name}': expected Optimal, got {result:?}"
2378                );
2379            } else {
2380                assert!(
2381                    matches!(result, Err(ModelError::InvalidInput(_))),
2382                    "case '{name}': expected InvalidInput, got {result:?}"
2383                );
2384            }
2385        }
2386    }
2387
2388    // ---------------------------------------------------------------------------
2389    // P2-f: linear part of QuadExpr must also reject foreign-model variables
2390    // ---------------------------------------------------------------------------
2391
2392    // Mixed: quad from m1 + linear from m2 → InvalidInput.
2393    #[test]
2394    fn test_p2f_mixed_quad_local_linear_foreign_rejected() {
2395        let mut m1 = Model::new("m1");
2396        let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2397
2398        let mut m2 = Model::new("m2");
2399        let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2400
2401        // x1 is local (quad ok), y2 is foreign (linear must be rejected).
2402        m1.minimize(x1 * x1 + y2);
2403        let result = m1.solve();
2404        assert!(
2405            matches!(result, Err(ModelError::InvalidInput(_))),
2406            "P2-f mixed: foreign linear term must give InvalidInput, got {result:?}"
2407        );
2408    }
2409
2410    // Pure-linear path with a foreign variable → must also reject (not drop).
2411    #[test]
2412    fn test_p2f_pure_linear_foreign_rejected() {
2413        let mut m1 = Model::new("m1");
2414        let _x1 = m1.add_var("x", 0.0, f64::INFINITY);
2415
2416        let mut m2 = Model::new("m2");
2417        let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2418
2419        // minimize(y2) on m1 — y2 is foreign, must not silently drop.
2420        m1.minimize(1.0 * y2);
2421        let result = m1.solve();
2422        assert!(
2423            matches!(result, Err(ModelError::InvalidInput(_))),
2424            "P2-f pure-linear: foreign variable must give InvalidInput (not silent drop), got {result:?}"
2425        );
2426    }
2427
2428    // Sanity: mixed with all-local variables must succeed (no false positive).
2429    #[test]
2430    fn test_p2f_mixed_all_local_accepted() {
2431        let mut m = Model::new("m");
2432        let x = m.add_var("x", 0.0, f64::INFINITY);
2433        // min x^2 - 4x, x >= 0 → x*=2, obj=-4
2434        m.minimize(x * x + (-4.0) * x);
2435        let result = m.solve().unwrap();
2436        assert!(
2437            (result[x] - 2.0).abs() < EPS,
2438            "P2-f no false positive: x*=2, got {}",
2439            result[x]
2440        );
2441    }
2442
2443    // Cross-term (from m1+m2) + additional linear foreign: both caught.
2444    #[test]
2445    fn test_p2f_cross_term_plus_linear_foreign() {
2446        let mut m1 = Model::new("m1");
2447        let x1 = m1.add_var("x", 0.0, f64::INFINITY);
2448
2449        let mut m2 = Model::new("m2");
2450        let y2 = m2.add_var("y", 0.0, f64::INFINITY);
2451
2452        // x1 * y2 is a cross-model quad; y2 also appears linear.
2453        // Both are foreign from m1's perspective.
2454        m1.minimize(x1 * y2 + 2.0 * y2);
2455        let result = m1.solve();
2456        assert!(
2457            matches!(result, Err(ModelError::InvalidInput(_))),
2458            "P2-f cross+linear: must give InvalidInput, got {result:?}"
2459        );
2460    }
2461
2462    // maximize path: foreign linear variable also rejected via maximize.
2463    #[test]
2464    fn test_p2f_foreign_linear_maximize_rejected() {
2465        let mut m1 = Model::new("m1");
2466        let _x1 = m1.add_var("x", 0.0, 5.0);
2467
2468        let mut m2 = Model::new("m2");
2469        let y2 = m2.add_var("y", 0.0, 5.0);
2470
2471        m1.maximize(1.0 * y2);
2472        let result = m1.solve();
2473        assert!(
2474            matches!(result, Err(ModelError::InvalidInput(_))),
2475            "P2-f maximize foreign linear: must give InvalidInput, got {result:?}"
2476        );
2477    }
2478
2479    // ---------------------------------------------------------------------------
2480    // P2-h: validate_objective rejects non-finite coefficients and constants
2481    // ---------------------------------------------------------------------------
2482
2483    // Non-finite linear coefficient (NaN).
2484    #[test]
2485    fn test_p2h_nan_linear_coef_rejected_at_solve() {
2486        let mut m = Model::new("p2h_nan_coef");
2487        let x = m.add_var("x", 0.0, f64::INFINITY);
2488        m.minimize(f64::NAN * x);
2489        let err = m.solve().unwrap_err();
2490        assert!(
2491            matches!(err, ModelError::InvalidInput(_)),
2492            "P2-h: NaN linear coefficient must give InvalidInput, got {err:?}"
2493        );
2494    }
2495
2496    // Non-finite linear coefficient (Inf).
2497    #[test]
2498    fn test_p2h_inf_linear_coef_rejected_at_solve() {
2499        let mut m = Model::new("p2h_inf_coef");
2500        let x = m.add_var("x", 0.0, f64::INFINITY);
2501        m.minimize(f64::INFINITY * x);
2502        let err = m.solve().unwrap_err();
2503        assert!(
2504            matches!(err, ModelError::InvalidInput(_)),
2505            "P2-h: Inf linear coefficient must give InvalidInput, got {err:?}"
2506        );
2507    }
2508
2509    // Non-finite constant term (NaN via Expression add).
2510    #[test]
2511    fn test_p2h_nan_constant_rejected_at_solve() {
2512        let mut m = Model::new("p2h_nan_const");
2513        let x = m.add_var("x", 0.0, f64::INFINITY);
2514        m.minimize(1.0 * x + f64::NAN);
2515        let err = m.solve().unwrap_err();
2516        assert!(
2517            matches!(err, ModelError::InvalidInput(_)),
2518            "P2-h: NaN constant term must give InvalidInput, got {err:?}"
2519        );
2520    }
2521
2522    // Non-finite constant term (Inf via Expression add).
2523    #[test]
2524    fn test_p2h_inf_constant_rejected_at_solve() {
2525        let mut m = Model::new("p2h_inf_const");
2526        let x = m.add_var("x", 0.0, f64::INFINITY);
2527        m.minimize(1.0 * x + f64::INFINITY);
2528        let err = m.solve().unwrap_err();
2529        assert!(
2530            matches!(err, ModelError::InvalidInput(_)),
2531            "P2-h: Inf constant term must give InvalidInput, got {err:?}"
2532        );
2533    }
2534
2535    // Q-diagonal overflow → Inf stored in CscMatrix → validate_objective 287-293.
2536    // f64::MAX is finite so quad_to_csc's is_finite() check passes, but 2*MAX
2537    // overflows to INFINITY in the stored Q.  validate_objective must reject this.
2538    #[test]
2539    fn test_p2h_inf_q_coef_via_diagonal_overflow_rejected_at_solve() {
2540        let mut m = Model::new("p2h_inf_q_diagonal");
2541        let x = m.add_var("x", 0.0, f64::INFINITY);
2542        // f64::MAX is finite → quad_to_csc passes the is_finite check, but
2543        // stores 2.0 * f64::MAX = INFINITY in the diagonal Q entry.
2544        // validate_objective (lines 287-293) must catch this.
2545        m.minimize(f64::MAX * (x * x));
2546        let err = m.solve().unwrap_err();
2547        assert!(
2548            matches!(err, ModelError::InvalidInput(_)),
2549            "P2-h: Inf Q diagonal (2*MAX overflow) must give InvalidInput, got {err:?}"
2550        );
2551    }
2552
2553    // DSL non-finite Q coefficient (NaN): caught by quad_to_csc → fail_dsl_quad
2554    // → quad_dsl_error fires at line 261, not 287-293. Still InvalidInput.
2555    #[test]
2556    fn test_p2h_nan_q_coef_dsl_rejected_at_solve() {
2557        let mut m = Model::new("p2h_nan_q_dsl");
2558        let x = m.add_var("x", 0.0, f64::INFINITY);
2559        // NaN coefficient is caught earlier (quad_to_csc), but the observable
2560        // result is still InvalidInput at solve time.
2561        m.minimize(f64::NAN * (x * x));
2562        let err = m.solve().unwrap_err();
2563        assert!(
2564            matches!(err, ModelError::InvalidInput(_)),
2565            "P2-h: NaN DSL Q coefficient must give InvalidInput, got {err:?}"
2566        );
2567    }
2568
2569    // ---------------------------------------------------------------------------
2570    // P2-i: stale quad_dsl_error must not block a subsequent valid DSL quad
2571    // ---------------------------------------------------------------------------
2572
2573    // Core repro: minimize(NaN*x*x) then minimize(x*x) then solve() → Optimal.
2574    #[test]
2575    fn test_p2i_nan_quad_replaced_by_valid_quad_is_optimal() {
2576        let mut model = Model::new("p2i_nan_then_valid");
2577        let x = model.add_var("x", 0.0, f64::INFINITY);
2578        model.minimize(f64::NAN * (x * x));   // DSL fail: quad_dsl_error set
2579        assert!(model.has_quad_dsl_error(), "P2-i setup: error must be recorded");
2580
2581        model.minimize(x * x + (-4.0) * x);   // new minimize: clears quad_dsl_error, installs valid Q
2582        assert!(!model.has_quad_dsl_error(), "P2-i: valid minimize must clear quad_dsl_error");
2583        assert!(model.has_quadratic_objective(), "P2-i: valid Q must be installed");
2584
2585        // min x² - 4x, x≥0 → x*=2, obj*=-4
2586        let result = model.solve();
2587        assert!(result.is_ok(), "P2-i: valid quad after NaN must be Optimal, got {result:?}");
2588        let r = result.unwrap();
2589        assert!((r[x] - 2.0).abs() < EPS, "P2-i: x*=2, got {}", r[x]);
2590        assert!((r.objective_value - (-4.0)).abs() < EPS, "P2-i: obj*=-4, got {}", r.objective_value);
2591    }
2592
2593    // NaN quad alone still gives InvalidInput (regression guard).
2594    #[test]
2595    fn test_p2i_nan_quad_alone_is_invalid() {
2596        let mut model = Model::new("p2i_nan_alone");
2597        let x = model.add_var("x", 0.0, f64::INFINITY);
2598        model.minimize(f64::NAN * (x * x));
2599        let result = model.solve();
2600        assert!(
2601            matches!(result, Err(ModelError::InvalidInput(_))),
2602            "P2-i: NaN quad alone must give InvalidInput, got {result:?}"
2603        );
2604    }
2605
2606    // Table: all setter orderings with ultimate valid/invalid state.
2607    #[test]
2608    fn test_p2i_setter_ordering_table() {
2609        // Each entry: (label, setup closure, expect_optimal)
2610        type Setup = fn(&mut Model, crate::variable::Variable);
2611        let cases: &[(&str, Setup, bool)] = &[
2612            // NaN → valid quad → Optimal (P2-i core)
2613            ("nan→valid_quad", |m, x| {
2614                m.minimize(f64::NAN * (x * x));
2615                m.minimize(x * x + (-4.0) * x);
2616            }, true),
2617            // NaN → NaN → valid quad → Optimal
2618            ("nan→nan→valid_quad", |m, x| {
2619                m.minimize(f64::NAN * (x * x));
2620                m.minimize(f64::NAN * (x * x));
2621                m.minimize(x * x + (-4.0) * x);
2622            }, true),
2623            // valid quad → NaN → InvalidInput
2624            ("valid→nan", |m, x| {
2625                m.minimize(x * x + (-4.0) * x);
2626                m.minimize(f64::NAN * (x * x));
2627            }, false),
2628            // NaN → linear → Optimal (P2-a, must still pass)
2629            ("nan→linear", |m, x| {
2630                m.minimize(f64::NAN * (x * x));
2631                m.minimize(1.0 * x);  // minimize x, x≥0 → x*=0
2632            }, true),
2633            // NaN → valid quad → NaN → InvalidInput
2634            ("nan→valid→nan", |m, x| {
2635                m.minimize(f64::NAN * (x * x));
2636                m.minimize(x * x + (-4.0) * x);
2637                m.minimize(f64::NAN * (x * x));
2638            }, false),
2639        ];
2640
2641        for &(label, setup, expect_optimal) in cases {
2642            let mut m = Model::new(label);
2643            let x = m.add_var("x", 0.0, f64::INFINITY);
2644            setup(&mut m, x);
2645            let result = m.solve();
2646            if expect_optimal {
2647                assert!(result.is_ok(), "P2-i [{label}]: expected Optimal, got {result:?}");
2648            } else {
2649                assert!(
2650                    matches!(result, Err(ModelError::InvalidInput(_))),
2651                    "P2-i [{label}]: expected InvalidInput, got {result:?}"
2652                );
2653            }
2654        }
2655    }
2656
2657}