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