Modeling

Variables, index sets, expressions, and constraints in oximo.

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.

πŸ”—Aggregating 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}\). 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!, soc_constraint!, and psd_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 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]);

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

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.

πŸ”—Semidefinite constraints

A positive semidefinite (PSD) constraint requires every eigenvalue of a real symmetric matrix to be nonnegative. Use symmetric_variable! to declare its entries and psd_constraint! to require the matrix to be PSD. A symmetric variable declaration alone creates free continuous variables and it does not impose positivity or bounds.

πŸ”—Symmetric matrices

symmetric_variable!(m, X[n]) returns a SymmetricMatrix<Expr<Affine>> with n * (n + 1) / 2 independent variables. Mirrored entries refer to the same variable. Use X[(i, j)] in Rust expressions, while modeling macros also accept the X[i, j] notation. The side dimension must be positive.

For numeric matrix data, dense rows avoid triangle ordering:

let c = SymmetricMatrix::try_from_rows([[2.0, 1.0], [1.0, 2.0]])?;
let identity = SymmetricMatrix::<f64>::identity(2);
println!("C = {c}");
println!("I = {identity}");

Matrices support addition, subtraction, negation, scalar multiplication, trace(), and frobenius(&other). frobenius computes the full matrix inner product, counting each stored off-diagonal product twice. Matrices combined by arithmetic must have matching dimensions.

πŸ”—Affine matrix inequalities

A PSD constraint can contain any symmetric matrix of constant or affine entries, including symbolic parameters. For example, minimizing t subject to t I - C being PSD gives the largest eigenvalue of C. The eigenvalues of t I - C are t minus those of C, so PSD membership requires t to be at least the largest eigenvalue.

Quadratic and nonlinear matrix entries are rejected. The current SDP support is limited to real symmetric matrices.

πŸ”—Names, families, and activity

PSD declarations support named, anonymous, computed-name, indexed, and filtered forms. For a symmetric matrix variable X:

let identity = SymmetricMatrix::<f64>::identity(2);
let shifts = [0.0, 1.0, 2.0];
psd_constraint!(m, shifted[i in 0..shifts.len()], &X + shifts[i] * &identity);
let extra = psd_constraint!(m, name = "extra positivity".to_owned(), &X);
m.set_psd_active(extra, false)?;

A scalar declaration returns a PsdConstraintHandle. Retrieve a registered handle with m.psd_constraint_handle("shifted[0]"). PSD constraints have their own registry, separate from scalar and SOC constraints. Inactive PSD blocks are excluded from solving and model-kind classification.

πŸ”—Indicator constraints

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, MOSEK, and SCIP 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)?;

πŸ”—Special ordered sets (SOS) constraints

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:

sos_constraint!(m, weighted_choice, SOS2, [
    (x, 10.0),
    (y, 20.0),
    (z, 50.0),
]);

πŸ”—Indexed SOS families

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:

sos_constraint!(m, curve[i in 0..n], SOS2, [
    (x[i], 1.0),
    (y[i], 2.0),
    (z[i], 3.0),
]);

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 and SCIP, 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.

πŸ”—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

Nonlinear operations are first-class expression nodes, so they compose with linear terms and indexed aggregations. Expr provides:

  • powers: pow, powi, and powf,
  • roots and magnitude: sqrt, cbrt, and abs,
  • exponentials and logarithms: exp, exp2, expm1, log/ln, log2, log10, and log1p/ln_1p,
  • trigonometric and inverse-trigonometric functions: sin, cos, tan, asin, acos, and atan,
  • hyperbolic and inverse-hyperbolic functions: sinh, cosh, tanh, asinh, acosh, and atanh,
  • binary atan2, min, and max.
// 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));

// Functions and extrema compose as ordinary expressions.
constraint!(m5, response, x.exp() + y.tanh() <= x.max(y) + 3.0);

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

πŸ”—Incremental affine construction

Use model.affine_builder() to construct an affine expression by adding terms incrementally, without creating intermediate addition nodes. The builder accepts constant or affine terms (weighted sums of variables plus a constant) from the same model.

use oximo::prelude::*;

let m = Model::new("incremental terms");
variable!(m, x[i in 0..3]);

let mut builder = m.affine_builder();
for (i, weight) in [2.0, 3.0, 4.0].into_iter().enumerate() {
    builder.add_term(weight, x[i]);
}
builder.add_constant(5.0);
let lhs = builder.build();
constraint!(m, capacity, lhs <= 100.0);

build() returns an affine expression, resets the pending terms and constant, and retains scratch capacity for reuse in the next row. Numeric terms are combined into a compact expression. Parameter-dependent terms stay symbolic.

Combining affine terms can change how floating-point additions are grouped. Mathematically equivalent constructions may therefore produce different coefficients, especially when large terms cancel.

For repeated contributions to a coefficient with large magnitude differences, choose model.compensated_affine_builder(). Supplying the original weights [1e16, 1.0, -1e16] for the same variable retains the coefficient 1, while ordinary floating-point accumulation gives 0. Compensated summation is explicit and uses additional arithmetic and scratch storage. It cannot recover rounding already present in an input expression.

πŸ”—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