Skip to main content

otspot_model/
model.rs

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