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.7", 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}\).
The min! and max! macros use the same domains, Cartesian products, and
filters to build one flattened minimum or maximum expression. Their domains
must contain at least one selected key.
Empty sums are handled as the additive identity when the model is known. Sums
inside constraint!, objective!, and soc_constraint! inherit that macro's
model automatically, including nested sums and filtered domains:
// If maybe_empty has no selected keys, this contributes the model's zero.constraint!(m, balance, sum!(x[i] for i in maybe_empty) == 0.0);// Use the explicit model form for a standalone sum.let total = sum!(m, x[i] for i in maybe_empty);
The explicit sum!(model, ...) form also ensures that every selected term
belongs to that model. An unanchored standalone sum still needs at least one
term, because it has no model-owned expression context from which to construct
the empty result.
// 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]);// Indexed extrema, with the same domain and filter syntax.let least_slack = min!(capacity[i] - x[i] for i in items);let peak_load = max!(load[t] for t in periods if online[t]);
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
The indicator_constraint! macro applies an affine
relation only when a binary trigger has a selected value. The implication is
one-way: when the trigger has the other value, the relation is not enforced.
variable!(m, enabled, Binary);variable!(m, -10.0 <= x <= 20.0);indicator_constraint!(m, capacity, enabled == 1 => x <= 8.0);indicator_constraint!(m, shutdown, enabled == 0 => x == 0.0);indicator_constraint!(m, operating_band, enabled == 1 => -2.0 <= x <= 4.0);
The trigger must be a bare binary variable from the same model, its selected
value must be the literal 0 or 1, and the consequent must be affine. The
constraint does not imply the reverse direction: satisfying x <= 8.0 does
not force enabled to equal 1.
Indicator constraints support the same indexed-family and computed-name forms
as ordinary constraints:
variable!(m, enabled[t in periods], Binary);variable!(m, production[t in periods] >= 0.0);indicator_constraint!( m, capacity[t in periods if available[t]], enabled[t] == 1 => production[t] <= capacity_at(t),);let row_name = "shutdown_final";indicator_constraint!( m, name = row_name, enabled[last] == 0 => production[last] == 0.0,);
Gurobi and MOSEK consume indicators natively. GAMS supports them when an
explicit COPT, CPLEX, Gurobi, SCIP, or Xpress sub-solver is selected. Other
backends return SolverError::UnsupportedIndicator while active indicators
remain in the model. To use one of those backends, explicitly replace the
indicators with linear Big-M rows:
use oximo::prelude::*;use oximo::{HighsOptions, solvers::Highs};let m = Model::new("reformulated_indicators");variable!(m, produce, Binary);variable!(m, 0.0 <= quantity <= 10.0);indicator_constraint!(m, capacity, produce == 1 => quantity <= 7.0);indicator_constraint!(m, shutdown, produce == 0 => quantity == 0.0);objective!(m, Max, quantity + 2.0 * produce);let artifacts = m.reformulate_indicators(IndicatorReformulationOptions::default())?;let result = Highs.solve(&m, &HighsOptions::default())?;
Model::reformulate_indicators changes the model in place without cloning it
and returns artifacts containing the generated constraint IDs. To retain the
native-indicator model, use m.to_reformulated_indicator_model(options) to make
an independent transformed model. Both forms preserve source indicator IDs,
mark the sources inactive, and record generated constraint IDs in
indicator_reformulations(). They add no variables. For one indicator, use its
handle's reformulate(options) or to_reformulated_model(options) method.
Each finite side of an indicator gets a Big-M row. oximo derives M from the
body's affine coefficients and variable bounds, including the zero option for
semi-continuous and semi-integer variables. For an unbounded side, pass a
positive, finite fallback M to the in-place call instead:
let options = IndicatorReformulationOptions::default().with_fallback_big_m(1.0e6);m.reformulate_indicators(options)?;
SOS1 and SOS2 constraints are native special ordered sets. SOS1 allows at most
one nonzero member. SOS2 allows at most two adjacent nonzero members according
to their ordering weights.
Use the short form when member order itself defines the weights. It assigns
consecutive weights 1.0, 2.0, and so on:
sos_constraint!(m, one_choice, SOS1, [x, y, z]);sos_constraint!(m, adjacent_choice, SOS2, [x, y, z]);
Use explicit (variable, weight) pairs when the ordering values are meaningful
or nonuniform:
The indexed form creates one SOS constraint for every key in the binder. The
index domain must be written explicitly; the macro cannot infer it from an
IndexedVar:
variable!(m, x[i in 0..n] >= 0.0);variable!(m, y[i in 0..n] >= 0.0);variable!(m, z[i in 0..n] >= 0.0);sos_constraint!(m, choice[i in 0..n], SOS1, [x[i], y[i], z[i]]);
This registers choice[0], choice[1], and so on, with positional weights
1.0, 2.0, and 3.0 in each set. Explicit weights work in the same form:
For one SOS over a dynamically assembled collection, use the method API:
let members = vec![x, y, z];m.add_sos_constraint_auto_weights("one_choice", SosType::Sos1, members);
Use Model::add_sos_constraint instead when supplying explicit weights from a
runtime iterator.
Backends with native SOS support, such as Gurobi, consume these constraints
directly. Other backends reject a model while it still contains active SOS
constraints. oximo never changes the formulation implicitly, but you can
explicitly replace every active SOS with a portable MILP formulation before
using a backend such as HiGHS:
use oximo::prelude::*;use oximo::{HighsOptions, solvers::Highs};let m = Model::new("reformulated_sos");variable!(m, 0.0 <= x <= 10.0);variable!(m, 0.0 <= y <= 10.0);variable!(m, 0.0 <= z <= 10.0);sos_constraint!(m, choice, SOS1, [x, y, z]);objective!(m, Max, x + 2.0 * y + 3.0 * z);// Uses each member's finite bounds as its Big-M values. The source SOS is// retained as inactive provenance, while binary variables and linear rows are// appended to `m`.let artifacts = m.reformulate_sos(SosReformulationOptions::default())?;let result = Highs.solve(&m, &HighsOptions::default())?;
Model::reformulate_sos modifies the model in place and returns the generated
variable and constraint IDs. To retain the native-SOS model—for example, to
solve it with Gurobi as well—produce an independent transformed model instead:
let reformulated = m.to_reformulated_sos_model(SosReformulationOptions::default())?;let result = Highs.solve(&reformulated, &HighsOptions::default())?;
A member needs finite bounds in every direction in which it can be nonzero. If
that is not available, pass a positive finite fallback to the in-place call
instead:
let options = SosReformulationOptions::default().with_fallback_big_m(1.0e6);m.reformulate_sos(options)?;
A fallback that is too small truncates the feasible region. Generated Big-M
rows embed the member bounds that existed during reformulation, so those bounds
cannot subsequently be changed on the transformed model. Set those bounds
before reformulating, or change them on a retained native source and make a
fresh reformulated model.
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);
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.7", 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.7", 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.7", 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, ...).
For a model containing indicators, select COPT, CPLEX, Gurobi, SCIP, or Xpress
explicitly in GamsOptions. The default solver selection is rejected because
indicator handling is sub-solver-specific.
[dependencies]oximo = { version = "0.7", features = ["gams"] }
use oximo::prelude::*;use oximo::solvers::Gams;let result = Gams::new().solve(&m, &GamsOptions::default())?;
See GamsOptions and the per-solver option structs in oximo::gams (GamsCplexOptions, GamsBaronOptions, GamsIpoptOptions, ...) for tuning the underlying solver.
A solver accepting NLP or MINLP does not imply that a backend can represent every
nonlinear expression. oximo validates the expressions before solving
and returns SolverError::UnsupportedNonlinearOperator when a
translation is unavailable.
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_handle)?; // 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.
Constraint declarations return handles that let you query their rows after a
solve. A scalar relation returns a model-bound ConstraintHandle.
A two-sided range returns RangeConstraintHandles,
which identifies either one interval row or its separate lower and upper rows.
The model identity prevents accidentally querying a result with a handle from a
different model. Use .id() on a handle only when a backend-facing raw
ConstraintId is required.
Capture an indexed declaration when you need to query its rows. The returned
IndexedConstraint maps each typed key to its registered
handle, so dual queries do not need to reconstruct generated names:
let cover: IndexedConstraint<usize> = constraint!(m, cover[i in 0..n_items], x[i] >= demand[i]);let handle = cover.get(0).expect("cover row exists");let dual = result.dual_of(handle)?;for (i, handle) in cover.iter() { println!("cover[{i}] dual = {:?}", result.dual_of(handle)?);}
get(key) also works for sparse, filtered, string, and tuple domains, and
iter() yields typed (key, ConstraintHandle) pairs in domain order. Handles
carry their model identity and remain usable after the model declaration block.
Indexed two-sided ranges return an IndexedRangeConstraint.
Each key maps to RangeConstraintHandles::Interval(handle) when the row stays a
native interval, or to RangeConstraintHandles::Split { lower, upper } when
symbolic bounds or a nonlinear body require two rows. Match the value and query
each handle with result.dual_of separately; a family can contain both forms.
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.
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.
The NL writer emits supported nonlinear operations as exact prefix opcode
trees. The reader uses the same mapping for ASCII and little-endian binary NL:
Opcode
Operation
oximo support
o13
floor
Not represented by the expression model
o14
ceil
Not represented by the expression model
o15
abs
Read and written
o16
unary -
Read and written
o34
logical not
Logical expressions are not represented
o37
tanh
Read and written
o38
tan
Read and written
o39
sqrt
Read and written
o40
sinh
Read and written
o41
sin
Read and written
o42
log10
Read and written
o43
log
Read and written
o44
exp
Read and written
o45
cosh
Read and written
o46
cos
Read and written
o47
atanh
Read and written
o49
atan
Read and written
o50
asinh
Read and written
o51
asin
Read and written
o52
acosh
Read and written
o53
acos
Read and written
exp2(x) is written exactly as the power expression 2 ^ x (o5), because
NL has no separate exp2 opcode.
The oximo operations cbrt, expm1, log1p, and log2 also have no direct
NL opcode. Writing one returns
IoError::UnsupportedNonlinearOperator.
Reading floor, ceil, logical not, or another
well-formed but unrepresentable opcode returns IoError::UnsupportedNl.
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, SOS sections, INDICATORS records, and the
QUADOBJ, QMATRIX, QCMATRIX, and QSECTION quadratic extensions. An MPS
indicator references an ordinary affine row using IF row binary 0|1; ranged
indicator bodies are represented as two rows. 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)?;
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, quadratic terms, SOS sections, and inline indicator
rows of the form binary = 0|1 -> affine relation. Ranged indicator bodies are
written as two indicator rows. Malformed input returns
IoError::InvalidLp with its source line and column. Unsupported LP
sections return IoError::UnsupportedLp.
NL has no native segment for the indicator representation used by oximo.
Writing a model with an active indicator returns IoError::UnsupportedNl.
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.