The Solvers guide compares model-kind support and summarizes the
installation requirements of every backend. Enable a backend with its feature,
for example:
[dependencies]oximo = { version = "0.6", features = ["pounce"] }
With no solver feature, you can still construct models and export them through
the default io feature.
pounce uses finite-difference derivatives for nonlinear expressions by
default. The nightly-only pounce-enzyme feature provides exact gradients,
Jacobians, and Hessians through Enzyme.
It requires a nightly Rust toolchain with the enzyme component,
RUSTFLAGS="-Zautodiff=Enable", and a fat-LTO profile:
Model::new creates the model container. The variable! macro registers the
variables and their bounds, while constraint! adds relations written with
<=, >=, or ==. Finally, objective! declares the expression to maximize.
Every backend implements the same Solver trait, so switching from
HiGHS to another compatible backend changes only the solver type and options.
For a C-free continuous LP/QP/SOCP path, enable Clarabel instead:
cargo add oximo --features clarabel
See Modeling for indexed variables and nonlinear expressions,
Solvers for backend capabilities, and Results for
status, values, duals, and reduced costs.
Modeling
oximo's modeling layer lets you describe an optimization problem with idiomatic Rust.
You write models using declarative macros that mirror algebraic notation, set algebra for indices, operator-overloaded expressions, and rule-style constraint generation.
Everything on this page is re-exported from oximo::prelude.
Variables are the quantities a solver may choose. variable!
registers one on the model and binds a Rust variable of the same name, so the
symbol is immediately usable in later expressions. Bounds are written the way
you'd write them on paper.
use oximo::prelude::*;let m = Model::new("my_model");variable!(m, x >= 0.0); // continuous, x ≥ 0variable!(m, 0.0 <= y <= 10.0); // continuous, 0 ≤ y ≤ 10variable!(m, z); // free, unbounded by defaultvariable!(m, b, Bin); // binary {0, 1} (also Binary)variable!(m, n >= 0.0, Int); // general integer (also Integer)variable!(m, s <= 10.0, SemiCont(2.0)); // semicontinuous: 0 or in [2, 10]
Bounds, domain, warm start, and fixing can also be passed as keyword arguments after the name:
variable!(m, x, lb = 0.0, ub = 1.0); // same as `0.0 <= x <= 1.0`variable!(m, n, lb = 0.0, domain = Int); // keyword domainvariable!(m, w, lb = 0.0, ub = 10.0, Int); // mixed with a positional domain tokenvariable!(m, p, lb = 0.0, initial = 3.0); // warm start (scalar only)variable!(m, q, fix = 5.0); // fixed to 5.0 (scalar only)
A parameter is data that can change while the model structure stays fixed. It
stays symbolic in the model, so param! lets you build once and re-bind its
value between solves without rebuilding variables, constraints, or the
objective. This is useful for scenario sweeps.
param!(m, p1 = 0.0);variable!(m, x1 >= 0.0);objective!(m, Max, p1 * x1);for price in [1.0, 1.6, 2.0] { p1.set_param_value(price); let result = Highs.solve(&m, &HighsOptions::default())?; println!("{price} -> {:?}", result.objective());}
A parameter times a variable stays linear, so the model kind is unchanged by re-binding.
For parameter sweeps, it is recommended to use a Persistent solver if available. See Solvers for details.
A Set is the modeling-layer container for an ordered, finite index set
over integers, strings, or tuples. Use one to name the domain of an indexed
variable, a generated constraint, or a sum.
Most domains need no explicit Set, since an integer range is already
a domain (x[i in 0..5], sum!(… for i in 0..n)).
Reach for Set when keys are strings, tuples, sparse, or a
subset reused across statements.
The set! macro binds a named set. A plain right side is normalized to an owned set, while a pat in domain [if cond] comprehension builds and optionally filters one.
use oximo::prelude::*;let plants = Set::strings(["seattle", "san-diego"]);set!(items = 0..5); // range normalized to Set<usize>set!(routes = plants * plants); // Cartesian product// Comprehension: product domain + by-value `if`. These two are equivalent.set!(arcs = (p, q) in &plants * &plants if p != q); // single tuple patternset!(arcs = i in plants, j in plants if i != j); // multi-bind product// The typed filter is also a Set method (the receiver pins the key type):let diag = (&plants * &plants).filter_typed(|(p, q)| p == q);// Sparse / string leaf sets:let sparse = Set::from_ints([0, 2, 4, 8]);
Many optimization quantities vary by period, product, or route. The indexed
form, variable!(m, x[k in set]), registers one scalar per key and auto-names
it like x[seattle,nyc]. Bounds apply uniformly by default. A multi-index
family ranges over a Cartesian product.
let m = Model::new("transport");variable!(m, x[r in routes] >= 0.0); // one var per routevariable!(m, y[k in items] >= 0.0, Int); // integer familyvariable!(m, z[a in rows, b in cols], Bin); // multi-index (Cartesian product)// Scalar lookup: any type that converts to IndexKey works.let e1 = x[("seattle", "nyc")];let e2 = z[a, b];// Per-key bounds may reference the index.variable!(m, 0.0 <= w[(p, q) in routes] <= capacity_for(&p, &q));variable!(m, v[k in items], lb = 0.0, ub = cap[k]);// Filtered family: keep only matching keys (no trivial elements built).variable!(m, d[(i, j) in rc if i == j] >= 0.0);
x[key] returns the Expr for that element, ready to drop into a constraint or objective.
When an expression has one term per key, use sum! instead of building a Rust
loop. It produces one Expr that can go anywhere an expression is
accepted.
sum!(body for k in set) reads as \(\sum_{k \in \text{set}} \text{body}\).
// Single sum: sum over i in items of weights[i] * x[i]constraint!(m, cap, sum!(weights[i] * x[i] for i in items) <= capacity);// Double sum, flat: sum over (p, q) in plants x marketslet total_cost = sum!(c[p, q] * x[p, q] for p in plants, q in markets);// Filtered sum.let active = sum!(x[i] for i in 0..n if online[i]);
Constraints define which combinations of variable values are allowed. Give each
one a stable name for readable solver output and diagnostics, then use
constraint! with a relation written as <=, >=, or ==.
A two-sided range becomes a single constraint.
constraint!(m, cap, 2.0 * x + 3.0 * y <= 100.0);constraint!(m, demand, x >= 5.0);constraint!(m, balance, x - y == 0.0);constraint!(m, band, 1.0 <= x + y <= 10.0); // two-sided range -> one constraint
A Model has exactly one objective function, the quantity it minimizes or
maximizes among feasible solutions. Add it after defining the expressions it
uses, with the sense written next to the model.
objective!(m1, Min, 3.0 * x + 5.0 * y);objective!(m2, Max, x + 2.0 * y); // also Minimize/min, Maximize/max
If you have a feasibility problem, use the feas/Feasibility sense.
Use the indexed form of constraint! when the same rule applies
across a set. It emits one constraint per key, auto-named like
supply[seattle], without an explicit Rust loop. A trailing if filters the
keys, and name = expr gives a computed run-time name.
// Scalar set: one constraint per period.let periods = Set::range(0..T);constraint!(m, setup[t in periods], x[t] <= capacity * s[t]);// Tuple set + inner sum builds the LHS expression (key types inferred).constraint!(m, supply[p in plants], sum!(x[p, q] for q in markets) <= supply_of(&p));// Filtered family: only the keys passing the guard are built.constraint!(m, diag[(i, j) in arcs if i == j], x[i, j] <= 1.0);// Computed run-time name.constraint!(m, name = format!("bal_{p}"), inflow[p] - outflow[p] == 0.0);
Pow, Sin, Cos, Exp, Log, Abs, and bilinear products are first-class,
so you can write nonlinear algebra in the same expressions as linear terms.
The model's kind is inferred from what you write.
// Rosenbrock NLPobjective!(m1, Min, (1.0 - x).powi(2) + 100.0 * (y - x.powi(2)).powi(2));// Quadratic constraint (model kind: QCP)constraint!(m2, disk, x.powi(2) + y.powi(2) <= 1.0);// Second-order cone ||(x, y)|| <= t (model kind: SOCP)soc_constraint!(m3, cone, [x, y] <= t);// Transcendental utility (MINLP when any variable is integer/binary)objective!(m4, Max, sum!(u[i] * (1.0 + w[i] * x[i]).log() for i in items));
To determine what backend to use, consider what each backend solves (by model kind) and what it costs and offers (license, install, diagnostics). Each backend's Cargo feature flag is named in its own section below.
License: the underlying solver is commercial and needs a license at runtime. The oximo wrapper crates are all MIT OR Apache-2.0.
Direct interface: talks to the solver in-process. C API/FFI for HiGHS, Gurobi, and MOSEK. Pure Rust for Clarabel and Pounce. BARON and GAMS instead exchange model/result files (.bar, .gms) with an external executable.
C compiler: whether a C/C++ compiler is required at build time.
IIS: computes an irreducible infeasible set to explain an infeasible model.
Warm start: persistent handle for incremental re-solves.
Highs is enabled with the highs Cargo feature. No external solver
install is required, but a C/C++ compiler is needed at build time. Add it with
cargo add oximo --features highs.
use oximo::prelude::*;use oximo::solvers::Highs;use std::time::Duration;let result = Highs.solve(&m, &HighsOptions::default() .time_limit(Duration::from_secs(60)) .threads(4) .mip_gap(0.01) .verbose(true) .method(HighsMethod::Ipm))?;
Clarabel is a pure-Rust conic interior-point solver. No install, no license. It handles continuous LP, QP (convex quadratic objectives), and SOCP models.
use oximo::prelude::*;use oximo::solvers::Clarabel;let result = Clarabel.solve(&m, &ClarabelOptions::default())?;
Default stable path: LP/QP/QCP models use exact analytic derivatives,
including Jacobian rows and the constant Lagrangian Hessian. Nonlinear models
use compiled tapes with finite-difference nonlinear derivatives and a
limited-memory L-BFGS Hessian.
PounceSolverSelection::Auto is the default. It certifies convexity before
selecting a specialized engine:
LP and convex QP use the convex IPM.
SOCP with a convex objective uses the conic IPM, including explicit cones and
recognized quadratic SOC forms.
QCP, indefinite QP, and general NLP use the TNLP/builder path.
Inconclusive convexity checks fall back to NLP.
A numerical failure in an automatically selected LP or detected-SOCP route gets
one NLP attempt. The specialized convex engines currently have no
time-limit hook, with a time limit, Auto uses NLP.
PounceOptions provides dedicated setters and typed builders for POUNCE's
option reference:
use oximo::pounce::{MuStrategy, PounceOptions, PounceSolverSelection};let options = PounceOptions::default() .tol(1e-8) .mu_strategy(MuStrategy::Adaptive) .solver_selection(PounceSolverSelection::Auto) .presolve(true) .linear_solver("feral");// Escape hatch for an option not exposed by a dedicated setter.let options = options.set("acceptable_tol", 1e-5);
The backend manages print_level, max_cpu_time, warm_start_init_point, and
hessian_approximation. Configure those through the corresponding oximo options
or persistent handle.
Gurobi uses gurobi-rs when the
gurobi Cargo feature is enabled. It requires a licensed Gurobi installation.
Set GUROBI_HOME to the installation directory before building and make sure a
Gurobi license is active.
Note: Only Gurobi v13 and later are supported.
[dependencies]oximo = { version = "0.6", features = ["gurobi"] }
use oximo::prelude::*;use oximo::solvers::Gurobi;use std::time::Duration;let result = Gurobi.solve(&m, &GurobiOptions::default() .time_limit(Duration::from_secs(120)) .mip_focus(1) .seed(101))?;
Mosek supports LP, MILP, convex QP/MIQP, convex QCP/MIQCP, and
SOCP/MISOCP models. It requires the mosek Cargo feature, a licensed MOSEK
11.2 installation, and MOSEK_BINDIR_112 when MOSEK is outside its default
location. MOSEK validates the convexity of quadratic data. The backend links
through the mosek crate.
On Windows, setting MOSEK_BINDIR_112 directly to a path containing spaces can
fail because of an unquoted linker flag in the current mosek crate build
script. Create a junction with a space-free path and point the environment
variable at that junction; see mosek.rust#1.
Only MOSEK 11.2 is currently supported.
[dependencies]oximo = { version = "0.6", features = ["mosek"] }
use oximo::prelude::*;use oximo::solvers::Mosek;use std::time::Duration;let result = Mosek.solve(&m, &MosekOptions::default() .time_limit(Duration::from_secs(120)) .threads(4) .mio_tol_rel_gap(1e-4))?;
MosekOptions provides builders for every MOSEK 11.2 parameter. Universal
options such as time_limit, threads, and verbose are applied first.
MOSEK-specific parameter builders are then applied in call order.
Baron is a global solver for LP/MILP/QP/MIQP/QCP/MIQCP/SOCP/MISOCP/
NLP/MINLP models. The Oximo adapter supports all of these model kinds and
translates explicit second-order cones to BARON's quadratic constraint
representation. It requires the baron feature, a licensed BARON installation
on PATH, and exchanges model and result files with the external executable.
[dependencies]oximo = { version = "0.6", features = ["baron"] }
use oximo::prelude::*;use oximo::solvers::Baron;let result = Baron::new().solve(&m, &BaronOptions::default())?;
Gams requires the gams Cargo feature plus a GAMS installation on
PATH. It exchanges model and result files with GAMS, which is useful when you
want to route a model through GAMS-managed solvers (CPLEX, BARON, IPOPT,
KNITRO, ...).
[dependencies]oximo = { version = "0.6", features = ["gams"] }
use oximo::prelude::*;use oximo::solvers::Gams;let result = Gams.solve(&m, &GamsOptions::default())?;
See GamsOptions and the per-solver option structs in oximo::gams (GamsCplexOptions, GamsBaronOptions, GamsIpoptOptions, ...) for tuning the underlying solver.
Results: inspect solver status, values, duals, and solution pools
Printing & Debugging: print a model as algebra and track down what it actually says
I/O: write your model to MPS, LP or NL for use with external tools
Results
Every oximo backend returns the same SolverResult. Read it
the same way independently of the solver.
It is recommended to first check what stopped the solve and whether a usable point is available, then
inspect the values relevant to your application.
The quickest option is the built-in report, which renders a model-aware summary:
print!("{}", result.report(&m));
For programmatic access, SolverResult separates why the
solver stopped from whether a usable point came back. That split matters because,
for example, a run that hits a time limit can still carry a good incumbent.
let result = Highs.solve(&m, &HighsOptions::default())?;match result.termination { TerminationStatus::Optimal => { // `objective()` is Option, since a model may have no objective. if let Some(obj) = result.objective() { println!("optimal: {obj}"); } } TerminationStatus::Infeasible => println!("infeasible"), TerminationStatus::TimeLimit if result.has_solution() => { println!("time limit, best = {:?}", result.objective()); } _ => {}}let x_val = result.value_of(x); // Option<f64>let dual = result.dual_of(constraint_id); // Option<f64>
TerminationStatus says why the solver stopped:
Optimal, LocallyOptimal, Feasible, Infeasible, Unbounded,
InfeasibleOrUnbounded, IterationLimit, TimeLimit, NodeLimit,
Interrupted, NumericError, NotSolved, or Other(String) for an unmapped
backend status.
PrimalStatus says what you actually got: NoSolution,
FeasiblePoint, or OptimalPoint. result.has_solution() is the shorthand.
Always check it before trusting a value.
I/O: export a model for inspection in another tool
Printing & Debugging
Macros that generate families of constraints are convenient right up until the model doesn't say what you thought it said. Model implements Display, so the fastest way to check is to print it.
You can print the complete model as a readable algebra block to the console with println!("{m}");.
let m = Model::new("diet");variable!(m, x >= 0.0);variable!(m, y >= 0.0);constraint!(m, c1, x + 2.0 * y <= 14.0);constraint!(m, c2, 3.0 * x - y >= 0.0);objective!(m, Min, 3.0 * x + 4.0 * y);println!("{m}");
Model 'diet' (LP)min 3 x + 4 ys.t. c1: x + 2 y <= 14 c2: 3 x - y >= 0vars x >= 0 y >= 0
The header carries the inferred ModelKind, so a single print answers both "did my constraints come out right?" and "why is this backend refusing my model?". Ranges, cones, and domains all render in the algebraic form you wrote:
max x + y band: 1 <= x + t <= 4 disk: ||x, t|| <= x + 1 0 <= y <= 1, binary
Printing a 10,000-constraint model is not debugging. Display adapters render a single piece, which is what you want inside an assertion or a targeted dbg!:
Look an id up by name with constraint_id (or soc_constraint_id), then render it:
let c = m.constraint_id("c").unwrap();assert_eq!(m.display_constraint(c).to_string(), "c: -y + x * y <= 3");assert_eq!(m.display_expr(2.0 * x - y).to_string(), "2 x - y");
Because these return Display adapters rather than String, they're cheap to leave in test assertions. The rendering only happens when something formats them.
Indexed constraints are auto-named base[key], and that name is exactly what constraint_id expects. This is the usual way to confirm a rule expanded over the keys you intended:
constraint!(m, supply[p in plants], sum!(x[p, q] for q in markets) <= supply_of(&p));let c = m.constraint_id("supply[seattle]").unwrap();println!("{}", m.display_constraint(c));
To render a key the same way the auto-namer does, use display_index_key.
A printed model resolves parameters to whatever they're bound to right now, which makes it easy to confirm a scenario sweep is re-binding what you think:
param!(m, price = 4.0);objective!(m, Min, price * x);println!("{m}"); // min 4 x ... params: price = 4m.set_param(price, 7.5);println!("{m}"); // min 7.5 x ... params: price = 7.5
oximo infers the ModelKind from the expressions you write. You never declare it. A backend that rejects your model is usually telling you an expression is a different kind than you assumed, so you can pin it down in a test:
assert_eq!(m.kind(), ModelKind::LP);
Compare the result against the backend table to see which solvers accept it.
Sometimes the backend accepts the model's kind and still returns infeasible. Each constraint is fine on its own, but together they can't all hold. Backends built on a solver with a native conflict refiner can express why by computing an irreducible infeasible subsystem (IIS), a minimal set of constraints and variable bounds that are jointly infeasible, where dropping any one member makes the rest feasible.
use oximo::solvers::Gurobi;let m = Model::new("iis");variable!(m, x >= 0.0);constraint!(m, floor, x >= 2.0);constraint!(m, ceil, x <= 1.0);objective!(m, Min, x);let iis = Gurobi.compute_iis(&m, &GurobiOptions::default())?;println!("{}", iis.report(&m));
The report uses the model's own names, so floor and ceil point straight back to the constraint! lines that conflict. The x >= 0 bound isn't part of the conflict, so it isn't listed. Reach the members programmatically through Iis's constraints, soc_constraints, and var_bounds fields (each bound tagged VarBoundKindLower/Upper). compute_iis returns a SolverError if the model turns out feasible, so it doubles as an "is this actually infeasible?" assertion.
I/O: export your model to MPS, LP, or NL for inspection in other tools
Modeling: back to variables, sets, and rule-style constraints
I/O
The oximo-io crate writes Models to the standard text formats MPS, LP, and NL, and can read all three formats back into a Model. All I/O is gated on the io Cargo feature, which is on by default.
Use this when you want to:
Hand a Model to a solver oximo doesn't bundle (COPT, SCIP, CPLEX, ...)
Feed a nonlinear model to an AMPL-compatible solver via NL
Reproduce a bug report against a third-party tool
Archive the exact problem instance for later inspection
MPS and LP describe linear and quadratic models. In general, you should use LP and reach for NL when the model has
nonlinear expressions that MPS and LP cannot represent.
Format
Pros
When to pick
MPS
Universal, column-oriented, fixed historical format
The NL writer is the most configurable of the three. write_nl_with and to_nl_string_with take a WriteOptions to select the NlFormat (binary or ASCII) and attach solver metadata: suffixes, defined variables, imported functions, and complementarity pairs.
use oximo::io::{NlFormat, WriteOptions, write_nl_with};let opts = WriteOptions::default().format(NlFormat::Ascii);write_nl_with(&m, "model.nl", &opts)?;
write_nl_files emits the .nl alongside its companion .col/.row name files, which is what most AMPL-compatible solvers expect when you want readable names in the solution.
All writers preserve the Variable and constraint names from your model, so exported files cross-reference cleanly with SolverResult lookups such as dual_of and reduced_costs (see Results).
use oximo::io::{read_mps, read_mps_file};use std::fs::File;let model = read_mps_file("model.mps")?;let model_from_stream = read_mps(File::open("model.mps")?)?;
The reader accepts the standard linear sections, range rows, integer markers,
binary and semi-variable bounds, and the QUADOBJ, QMATRIX, QCMATRIX, and
QSECTION quadratic extensions. MPS does not identify the coefficient scaling
used by quadratic constraints, so the default is the Gurobi convention. Select
CPLEX or MOSEK scaling explicitly when needed:
use oximo::io::{ MpsQuadraticFormat, MpsReadOptions, read_mps_file_with,};let options = MpsReadOptions { quadratic_format: MpsQuadraticFormat::Cplex,};let model = read_mps_file_with("cplex-model.mps", &options)?;
Malformed input returns IoError::InvalidMps. Multiple alternative
RHS, range, or bounds vectors and semantics not represented by oximo-core, such
as SOS and indicator constraints, return IoError::UnsupportedMps.
The NL reader imports models produced by oximo or compatible
AMPL-style tools. Use read_nl_file for a path or read_nl
for any byte stream:
use oximo::io::{read_nl, read_nl_file};use std::fs::File;let model = read_nl_file("model.nl")?;let model_from_stream = read_nl(File::open("model.nl")?)?;
Both ASCII and little-endian binary NL encodings are accepted. When a .row or
.col sidecar exists beside the file, it supplies the original row and column
names; otherwise deterministic names are generated. Interval rows and initial
values are preserved when they can be represented by the core model.
The reader rejects malformed input with IoError::InvalidNl and
well-formed NL sections that the core model cannot represent with
IoError::UnsupportedNl. Imported functions, defined variables,
logical/network constraints, complementarity sections, and unsupported expression
opcodes are intentionally rejected.
LP files can be imported from a byte stream or a path with read_lp
and read_lp_file:
use oximo::io::{read_lp, read_lp_file};use std::fs::File;let model = read_lp_file("model.lp")?;let model_from_stream = read_lp(File::open("model.lp")?)?;
The reader supports the CPLEX LP linear and quadratic subset represented by the
core model, including objectives, constraints, bounds, integer/binary and
semicontinuous domains, and quadratic terms. Malformed input returns
IoError::InvalidLp with its source line and column. Unsupported LP
sections return IoError::UnsupportedLp.
If part of this guide is confusing, incomplete, outdated, or hard to follow, say so. Getting stuck while trying to do something in oximo usually means the docs can be better, not that you missed something.
AI-assisted contributions are allowed. Contributors remain fully responsible for the quality, correctness, licensing, and usefulness of what they submit. The standards are the same either way.
Review and validate AI-assisted content before submitting it. Using an AI tool does not transfer responsibility for correctness, code quality, license compliance, security, or documentation accuracy.
Disclose it when a significant portion of a contribution is generated by, or copied verbatim from, an AI tool. Routine help (grammar, spelling, minor phrasing) needs no disclosure.
Note it in the pull request description, or use a commit trailer:
Assisted-by: generic LLM chatbot
This helps the project evaluate tooling practices and refine these guidelines over time.