Skip to main content

otspot_model/
model.rs

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