oximo

oximo is a Rust algebraic modeling library for linear, mixed-integer, and nonlinear optimization.
Generated on August 23, 2026

Table of Contents

  1. Installation
  2. Quickstart
  3. Modeling
  4. Solvers
  5. Results
  6. Printing & Debugging
  7. I/O
  8. Contributing

Installation

For most projects, installation is one command. The default oximo build provides the modeling layer and file I/O. Solver backends are opt-in.

This site is a guide for tutorials and worked examples. For the complete API reference, see docs.rs/oximo.

🔗Start here

oximo requires Rust 1.85 or later (edition 2024). Install Rust with rustup if you do not already have it.

Create a project and add oximo:

cargo new my-oximo-model --edition 2024
cd my-oximo-model
cargo add oximo

This default dependency is enough to construct models and use the MPS, LP, and NL I/O APIs. Choose a solver feature before solving a model.

🔗HiGHS

HiGHS is a bundled LP/MILP/QP solver. Enable it by doing:

cargo add oximo --features highs

The HiGHS build requires a C/C++ compiler:

  • On Windows, install the MSVC C++ build tools.
  • On macOS, run xcode-select --install.
  • On Linux, install your distribution's standard C/C++ build tools.

Then follow the Quickstart to solve the example model.

🔗Clarabel

Clarabel is a pure-Rust solver for continuous LP, QP, and SOCP models:

cargo add oximo --features clarabel

Use this path when you want a solver without a C compiler or external solver installation. Clarabel does not solve mixed-integer models.

🔗Other backends

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.

🔗Advanced: exact nonlinear derivatives

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:

RUSTFLAGS="-Zautodiff=Enable" cargo +nightly build --profile enzyme --features pounce-enzyme

🔗Feature reference

FeatureIncluded by defaultPurpose
highsnoBundled Highs solver for LP, MILP, and QP.
ioyesMPS, LP, and NL file writers.
baronnoBaron global-optimization backend.
clarabelnoPure-Rust Clarabel solver for LP, QP, and SOCP.
clarabel-faernoUse Clarabel's faer linear-algebra backend.
gurobinoGurobi backend.
gamsnoGams bridge.
pouncenoPure-Rust Pounce solver for continuous nonlinear models.
pounce-enzymenoExact Enzyme derivatives for POUNCE; requires nightly Rust.
moseknoMosek backend for MOSEK 11.2.

Quickstart

This walkthrough builds a small linear program end-to-end. HiGHS is an opt-in backend, so enable it in the project before running this example:

cargo add oximo --features highs

🔗The problem

[ \begin{aligned} \max \quad & 3x + 4y \ \text{s.t.} \quad & x + 2y \le 14 \ & 3x \ge y \ & x \le y + 2 \ & x \ge 0 \ & 0 \le y \le 4 \end{aligned} ]

🔗The full program

use oximo::prelude::*;
use oximo::solvers::Highs;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let m = Model::new("transport");

    variable!(m, x >= 0.0);
    variable!(m, 0.0 <= y <= 4.0);

    constraint!(m, c1, x + 2.0 * y <= 14.0);
    constraint!(m, c2, 3.0 * x >= y);
    constraint!(m, c3, x <= y + 2.0);
    objective!(m, Max, 3.0 * x + 4.0 * y);

    let result = Highs.solve(&m, &HighsOptions::default())?;
    println!("obj = {:?}", result.objective()); // Some(34.0)
    println!("x   = {:?}", result.value_of(x)); // Some(6.0)
    println!("y   = {:?}", result.value_of(y)); // Some(4.0)
    Ok(())
}

Run it with cargo run.

🔗Step by step

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

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 ≥ 0
variable!(m, 0.0 <= y <= 10.0);         // continuous, 0 ≤ y ≤ 10
variable!(m, z);                        // free, unbounded by default
variable!(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 domain
variable!(m, w, lb = 0.0, ub = 10.0, Int); // mixed with a positional domain token
variable!(m, p, lb = 0.0, initial = 3.0);  // warm start (scalar only)
variable!(m, q, fix = 5.0);                // fixed to 5.0 (scalar only)
Domain tokenMeaning
(none)Continuous
BinBinary {0, 1} (alias Binary)
IntGeneral integer (alias Integer)
SemiCont(t)Zero, or continuous in [t, ub]
SemiInt(t)Zero, or integer in [t, ub]

🔗Parameters

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.

🔗Index sets

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 pattern
set!(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]);

🔗Indexed variables

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 route
variable!(m, y[k in items] >= 0.0, Int);    // integer family
variable!(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.

🔗Expressions

Exprs are built with standard Rust operators. Scalars are f64.

let lhs = x + 2.0 * y - 3.0 * z;
let rhs = 4.0 * x + 5.0;

Behind the scenes oximo uses arena-allocated expression trees (oximo-expr), so combining large Exprs stays cheap.

🔗Summing over sets

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 markets
let 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]);

For the plain dot-product case there is also dot.

🔗Constraints

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

For SOC constraints, use the soc_constraint! macro.

🔗Objectives

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.

🔗Rule-style constraints

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);

🔗Nonlinear expressions

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 NLP
objective!(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));

Check the inferred kind with Model::kind(), which returns a ModelKind. Backends reject kinds they don't support. See Printing & Debugging.

🔗Next steps

  • Solvers: pick a backend, set options, read the solution
  • Printing & Debugging: print a model as algebra and check what it actually says
  • I/O: export your model to MPS, LP, or NL

Solvers

oximo is solver-agnostic, and provides a variety of backends to solve your optimization models.

Every backend implements the same Solver trait, so swapping engines is a one-line change. The general pattern is:

let result = Backend.solve(&model, &BackendOptions::default())?;

result is a SolverResult, the same struct regardless of backend.

🔗Choosing a backend

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.

🔗Model kind support

Each row is what Solver::supports accepts for that backend, against every ModelKind:

BackendLPMILPQPMIQPQCPMIQCPSOCPMISOCPNLPMINLP
Highs✓✓✓———————
Clarabel✓—✓———✓———
Pounce✓—✓—✓—✓—✓—
Gurobi✓✓✓✓✓✓✓✓✓✓
Mosek✓✓✓✓✓✓✓✓——
Baron✓✓✓✓✓✓✓✓✓✓
Gams✓✓✓✓✓✓✓✓✓✓

Gams accepts every kind at the oximo layer, but the actual coverage is the GAMS sub-solver's. Pick one that handles the kind you emit.

🔗Requirements & capabilities

Deployment cost and diagnostic/solve capability per backend.

✓ = yes/supported; — = no.

BackendLicenseSeparate installC compilerDirect interfaceIISWarm startSol. poolDualsParallel
Highs——✓✓—†✓—✓✓
Clarabel———✓—✓—✓✓
Pounce———✓—✓—✓—
Gurobi✓✓—✓✓✓✓✓✓
Mosek✓✓—✓—✓—✓✓
Baron✓✓——✓—✓✓✓
Gams✓✓————✓¶✓✓

Column meanings:

  • 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.
  • Sol. pool: returns multiple solutions, best-first.
  • Duals: shadow prices/reduced costs.
  • Parallel: solver can solve in parallel.

Footnotes:

  • † The highs crate does not support IIS yet.
  • ¶ GAMS's pool and duals come from the underlying sub-solver's GDX output (e.g. CPLEX solnpool).

A backend rejects model kinds it can't handle, so check Model::kind() if a solve returns SolverError::UnsupportedKind.

🔗HiGHS

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))?;

Common HighsOptions:

MethodEffect
.time_limit(d)Stop after d (std::time::Duration)
.threads(n)Cap parallelism
.mip_gap(g)Relative MIP optimality gap (e.g. 0.01 = 1%)
.verbose(b)Stream the solver log
.method(m)LP algorithm via HighsMethod (Simplex, Ipm, …)

🔗Clarabel

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())?;

🔗POUNCE

Pounce is a pure-Rust IPOPT and convex-solver backend. Enable the pounce feature:

use oximo::prelude::*;
use oximo::pounce::{Pounce, PounceOptions, PounceSolverSelection};

let result = Pounce.solve(&m, &PounceOptions::default())?;

🔗Derivatives

  • 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.
  • pounce-enzyme feature: nightly-only exact gradients, sparse Jacobians, and sparse Lagrangian Hessians for nonlinear models. See Installation > Advanced: exact nonlinear derivatives.

Note: pounce-enzyme currently requires the nightly-2026-07-26 toolchain. Later nightly toolchains fail. See rust-lang/rust#160470.

🔗Automatic routing

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.

🔗Options

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

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))?;

Common GurobiOptions:

MethodEffect
.time_limit(d)Wall-clock limit
.mip_focus(n)Gurobi MIPFocus (1 = feasibility, 2 = optimality, 3 = bound)
.mip_gap(g)Relative MIP optimality gap
.seed(n)Random seed for reproducible runs

🔗MOSEK

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

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

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.

🔗Next steps

  • 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.

🔗Reading results

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>

🔗Status

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.

🔗Fields and accessors

ItemTypeWhat information is available
terminationTerminationStatusWhy the solver stopped
primal_statusPrimalStatusWhether a usable point is present
objective()Option<f64>Objective of the best solution
value_of(expr)Option<f64>Primal value for a variable or indexed element
values_of(&var)iterator of (&IndexKey, f64)Every element of an indexed variable
dual_of(id)Option<f64>Shadow price (continuous models)
reduced_costsmap keyed by VarIdReduced costs (continuous models)
best_bound, gapOption<f64>Populated by branch-and-bound backends
solve_timeDurationWall-clock time in the backend
iterationsu64Iteration count when the backend reports one
raw_logOption<String>Backend log, when captured

🔗Indexed variables

values_of walks an indexed family without a manual key loop:

for (key, value) in result.values_of(&x) {
    println!("x[{}] = {value:.2}", display_index_key(key));
}

🔗Solution pools

Backends that can return multiple points expose them, best-first:

for i in 0..result.result_count() {
    let point = result.solution(i).unwrap();
    println!("objective {:?}", point.objective);
}

🔗Next steps

  • Solvers: choose a backend and set its options
  • Printing & Debugging: inspect the model that produced a result
  • 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.

🔗Printing

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 y
s.t.
  c1: x + 2 y <= 14
  c2: 3 x - y >= 0
vars
  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

🔗Debugging

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!:

AdapterRenders
display_expr(e)A bare Expr
display_constraint(id)One named constraint
display_objective()The objective
display_soc(id)One second-order cone

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.

🔗Rule-generated names

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.

🔗Parameters show their current binding

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 = 4

m.set_param(price, 7.5);
println!("{m}"); // min 7.5 x ... params: price = 7.5

🔗Checking the model kind

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.

🔗Diagnosing infeasibility

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.

Call compute_iis from the InfeasibilityDiagnosis trait, then render the result against the model with Iis::report.

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));
irreducible infeasible subsystem (2 members)

constraints (2)
  floor
  ceil

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 VarBoundKind Lower/Upper). compute_iis returns a SolverError if the model turns out feasible, so it doubles as an "is this actually infeasible?" assertion.

For more details on IIS support, see Solvers > Requirements & capabilities.

🔗When printing isn't enough

ErrorDebugging steps
Backend rejects the modelCheck Model::kind() against the backend table
Model is too big to readExport it with io::write_lp (if linear)
Solver returns nothing usableInspect termination and primal_status, not just objective()
Model reports infeasibleCompute an IIS (if supported by backend)
Need the backend's own logsSet .verbose(true) in the options, or read result.raw_log

After a solve, result.report(&m) pairs the model with its solution in one print-out.

🔗Next steps

  • 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
  • Inspect the model

🔗To a string

use oximo::io;

let mps = io::to_mps_string(&m)?;
let lp  = io::to_lp_string(&m)?;
let nl  = io::to_nl_string(&m)?;

Each returns a String you can log, hash, or feed into something else in memory.

🔗To a file

use oximo::io;

io::write_mps(&m, "model.mps")?;
io::write_lp(&m, "model.lp")?;
io::write_nl(&m, "model.nl")?;

All three accept anything that implements AsRef<Path>.

🔗Picking a format

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.

FormatProsWhen to pick
MPSUniversal, column-oriented, fixed historical formatMaximum solver compatibility, archival
LPHuman-readable, row-oriented, mirrors algebraic notationQuick inspection, sharing in bug reports
NLCompact, carries nonlinear structureNLP/MINLP models, AMPL-compatible solvers

🔗NL options

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.

🔗Names round-trip

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).

🔗Reading MPS models

Use read_mps_file for a path or read_mps for any text stream:

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.

🔗Reading NL models

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.

🔗Reading LP models

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.

🔗Skipping the writers

If you don't need file export, opt out of the io feature to drop the dependency:

[dependencies]
oximo = { version = "0.6", default-features = false, features = ["highs"] }

Contributing

All contributions are welcome: bug reports, documentation fixes, tests, and code. Everything happens on GitHub.

🔗Reporting bugs

If you hit a bug, unexpected behavior, or an inconsistency, open an issue. Many problems are found by users first, and a report helps everyone.

A useful report includes:

  • A clear description of the problem
  • Steps to reproduce it
  • Expected and actual behavior
  • Relevant error messages or logs
  • A minimal reproducible example, when possible

🔗Improving documentation

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.

Worth sending:

  • Clarifications to existing guides
  • More examples
  • Tutorials
  • Better API explanations
  • Typo and formatting fixes

Open an issue or send a pull request directly.

🔗Contributing code

Bug fixes, performance work, new features, tests, and refactors are all fair game.

  1. Fork the repository
  2. Create a branch for your changes
  3. Make your modifications
  4. Add or update tests when appropriate
  5. Run cargo fmt and make sure cargo clippy passes
  6. Write a commit message following the naming convention
  7. Run the tests relevant to your change
  8. Open a pull request

Keep pull requests focused and reasonably scoped. Smaller ones are easier to review and land.

🔗Testing

Some tests need third-party tools, including commercial solvers. Default features don't depend on any of them.

You are not expected to run tests for crates unrelated to your change. Skip them with --exclude:

cargo test --workspace --exclude oximo-gurobi

A reviewer runs the full suite manually before merging. The GitHub CI workflow can't execute all tests because of solver licensing restrictions.

🔗Commit naming convention

oximo follows a commit naming convention that keeps the history readable and makes the scope of a change obvious at a glance.

ScopePrefix
A single crateoximo-<crate-name>:
Workspace configurationworkspace:
GitHub workflows or repo configgithub:
More than one backendbackends:

Examples:

oximo-core: Improve error handling
workspace: Update dependency versions
github: Add CI workflow for testing
backends: Improve mapping of terminations

🔗AI-assisted contributions

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.

🔗Accountability

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.

🔗Transparency

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.

🔗Licensing

By contributing to oximo you agree that your contributions are licensed under the same dual-license terms as the project:

  • MIT License
  • Apache License 2.0

Unless stated otherwise, all submitted contributions are assumed to be provided under both.

© 2026 oximo