oximo-core 0.7.0

Core modeling types (Variable, Set, Constraint, Model) for oximo
Documentation

oximo-core

Core modeling types for oximo: Model, Variable, Set, Constraint, Objective, Parameter, IndexedVar, Domain, and ModelKind.

Re-exports oximo-expr types (Expr, ExprArena, ExprId, ExprNode, UnaryOp, Children, ParamId, VarId) so downstream code does not need a separate oximo-expr import. End users typically depend on the umbrella oximo crate rather than this one directly.

Usage

[dependencies]
oximo-core = "0.7.0"

Or via the umbrella crate (recommended for end users):

[dependencies]
oximo = "0.7.0"

Quick example

use oximo_core::prelude::*;

let m = Model::new("transport");

// Scalar variables
variable!(m, x >= 0.0);
variable!(m, 0.0 <= y <= 10.0);

// Constraints (incl. a two-sided range, kept as one constraint)
constraint!(m, c1, x + 2.0 * y <= 14.0);
constraint!(m, c2, 3.0 * x - y >= 0.0);
constraint!(m, band, 1.0 <= x + y <= 12.0);

// Objective (or `objective!(m, Feasibility)` for a pure feasibility problem)
objective!(m, Max, 3.0 * x + 4.0 * y);

println!("kind = {:?}", m.kind()); // LP

Modeling API

The modeling surface is a set of macros: variable!, constraint!, objective!, sum!, min!, max!, set!, and param!. Each expands to the underlying typed model operations, so there is no runtime cost and full compile-time type/borrow checking is preserved.

Model uses interior mutability, so a macro can take &m, register variables/constraints, and the variable!-introduced bindings (x, y, ...) are locals you can use immediately.

let m = Model::new("my_model");
variable!(m, x >= 0.0);        // binds a local `x: Expr<'_>`
constraint!(m, cap, x <= 5.0); // uses x while holding &m

Names are unique per registry. Registering a duplicate variable or constraint name panics.

Accessors

m.num_variables()   // usize
m.num_constraints() // algebraic + SOC + SOS + indicator constraints
m.variables()       // Ref<'_, Vec<Variable>>
m.constraints()     // ModelConstraints<'_>
m.constraints().algebraic()          // typed algebraic registry
m.constraints().second_order_cones() // typed explicit-SOC registry
m.constraints().special_ordered_sets() // typed SOS registry
m.constraints().indicators()         // typed indicator registry
m.arena()          // ExprArenaSnapshot<'_>
m.kind()           // ModelKind, cached, invalidated on change
m.try_objective()  // Result<Objective, Error>
m.variable_id("x") // Option<VarId>
m.constraint_id("cap")      // Option<ConstraintId> (algebraic)
m.soc_constraint_id("cone") // Option<SocConstraintId>
m.sos_constraint_id("choice") // Option<SosConstraintId>

ModelConstraints::iter() visits every declared constraint. Its algebraic() and second_order_cones() slices are convenient when both kinds are needed in one scope. Typed views keep backend passes homogeneous while preserving the same storage and typed IDs. SOC-shaped quadratic constraints declared with constraint! remain algebraic entries; only constraints declared with soc_constraint! or add_soc_constraint appear in second_order_cones().

Fixing and unfixing variables

m.fix_var(var_id, 3.0);         // lb = ub = 3.0
m.unfix_var(var_id, 0.0, 10.0); // restore bounds

Variables

Scalar variables

variable!(m, x);                        // free (-inf, +inf)
variable!(m, x >= 0.0);                 // lower bound only
variable!(m, 0.0 <= x <= 10.0);         // both bounds
variable!(m, b, Bin);                   // binary {0, 1}  (also Binary)
variable!(m, 0.0 <= n <= 100.0, Int);   // general integer  (also Integer)
variable!(m, s <= 10.0, SemiCont(2.0)); // semicontinuous: 0 or in [2, 10]
variable!(m, t <= 5.0, SemiInt(1.0));   // semi-integer: 0 or integer in [1, 5]

// Keyword args:
variable!(m, u, lb = 0.0, ub = 10.0);    // same as `0.0 <= u <= 10.0`
variable!(m, v, lb = 0.0, domain = Int); // keyword domain (or a positional `Int`)
variable!(m, w, initial = 3.0);          // warm start  (scalar only)
variable!(m, p, fix = 5.0);              // fixed to 5.0 (scalar only)

Indexed variables

Creates one scalar variable per key in a Set (or range), named base[key], and binds an IndexedVar.

let i = Set::range(0..5);
variable!(m, 0.0 <= x[k in i] <= 10.0);     // uniform bounds
variable!(m, y[k in i] >= 0.0, Int);        // integer family
variable!(m, z[a in rows, b in cols], Bin); // multi-index (Cartesian product)

// Access by key (panics on missing key):
let expr = x[2];  // single key (usize / "name" / (a, b))
let e2 = z[a, b]; // inside the macros: multi-index sugar == z[(&a, &b)]

// Bounds may reference the index -> lowered to per-key bounds:
variable!(m, lower[k] <= w[k in i] <= upper[k]);

// Filtered family: keep only matching keys (no trivial elements built).
variable!(m, d[(i, j) in rc if i == j] >= 0.0);

Large indexed variable, parameter, algebraic, range, SOC, and SOS families are prepared in parallel when the family is large enough, then registered serially in set iteration order.

Domain

Variant Description
Domain::Real Any real number (default)
Domain::Integer Any integer
Domain::Binary 0 or 1
Domain::SemiContinuous { threshold } 0 or any value >= threshold
Domain::SemiInteger { threshold } 0 or any integer >= threshold

Sets

Set is an ordered finite index set. Three variants:

let i = Set::range(0..5);              // Range: i64 keys 0..5
let j = Set::strings(["a", "b", "c"]); // Strings
let k = Set::product(&i, &j);          // Tuples: (0,"a"), (0,"b"), ...
let k = &i * &j;                       // Same via Mul operator

// From sparse ints:
let s = Set::from_ints([0, 2, 4, 8]);

// Filter:
let evens = i.filter(|k| k.as_i64().unwrap() % 2 == 0);

Constraints

==, <=, and >= are written directly, the macro intercepts the tokens, so these are real constraint operators.

constraint!(m, name, lhs <= rhs);                  // named, also >= and ==
constraint!(m, lhs >= rhs);                        // anonymous (auto-named _c0, _c1, ...)
constraint!(m, band, 1.0 <= e <= 3.0);             // two-sided range -> one constraint (expr bounds -> band_lo/band_hi)
constraint!(m, name = format!("c_{k}"), e == rhs); // computed run-time name

Indexed family over a set

// One constraint per key, auto-named supply[seattle], ...
constraint!(m, supply[p in plants], sum!(x[p, q] for q in markets) <= cap[p]);

// Multi-index family (multi-index access sugar: x[i, j]).
constraint!(m, flow[i in 0..n, j in 0..m], x[i, j] >= 0.0);

// Filtered family: only keys passing the guard.
constraint!(m, diag[(i, j) in rc if i == j], x[i, j] <= 1.0);

Capture the macro result to query rows without reconstructing their names:

use oximo_core::prelude::*;

let m = Model::new("constraint_handles");
variable!(m, x[i in 0..4] >= 0.0);
let capacity: ConstraintHandle = constraint!(m, capacity, x[0] <= 10.0);
let cover: IndexedConstraint<usize> =
    constraint!(m, cover[i in 0..4 if i % 2 == 0], x[i] >= 1.0);
assert!(cover.get(1).is_none()); // filtered out
for (i, cid) in cover.iter() {
    assert_eq!(cover.get(i), Some(cid));
}

let bands: IndexedRangeConstraint<usize> =
    constraint!(m, bands[i in 0..4], 1.0 <= x[i] <= 4.0);
let RangeConstraintHandles::Interval(cid) = bands.get(0).unwrap() else {
    panic!("constant bounds and a linear body produce an interval row");
};

get(key) returns copied model-bound handles; iter() yields typed (K, handle) pairs in domain order for dense, sparse, string, tuple, and filtered domains.

Two-sided ranges return RangeConstraintHandles::Interval(handle) or RangeConstraintHandles::Split { lower, upper }. Call .id() on an individual handle when a backend-facing raw ConstraintId is required.

Summation

sum!(body for k in domain) reads as sum_{k in domain} body. Nest with extra clauses and filter with a trailing if:

constraint!(m, cap, sum!(weights[i] * x[i] for i in items) <= capacity);
objective!(m, Min, sum!(c[i, j] * x[i, j] for i in rows, j in cols));
let evens = sum!(x[i] for i in items if i % 2 == 0); // filtered

Inside constraint!, soc_constraint!, and objective!, sums inherit the model's expression context. Empty domains and filters produce the constant expression 0, including in indexed families and nested sums.

For a standalone sum, pass the model explicitly when the domain or filter can be empty:

let total = sum!(m, x[i] for i in possibly_empty_items);
let filtered = sum!(m, x[i] for i in items if predicate(i));
// Both produce the constant expression 0 when no term is selected.

Special ordered sets

SOS1 and SOS2 constraints apply ordering weights to bare variables. SOS1 allows at most one nonzero member. SOS2 allows at most two adjacent nonzero members. Weights must be finite and unique within each set.

variable!(m, 0.0 <= choice_a <= 1.0);
variable!(m, 0.0 <= choice_b <= 1.0);
variable!(m, 0.0 <= choice_c <= 1.0);

let one_choice = sos_constraint!(m, one_choice, SOS1, [choice_a, choice_b, choice_c]);
sos_constraint!(m, adjacent_choice, SOS2, [choice_a, choice_b, choice_c]);
sos_constraint!(m, weighted, SOS2, [
    (choice_a, 10.0), (choice_b, 20.0), (choice_c, 30.0),
]);

The indexed form creates one set for every key in the binder. The binder is required because the macro cannot infer an index domain from an IndexedVar: choice[i in 0..n] produces choice[0], ..., choice[n - 1]. To create one SOS over a dynamically assembled collection, use m.add_sos_constraint_auto_weights("choice", SosType::Sos1, members).

The single-constraint form returns a model-bound SosConstraintHandle. Backends without native SOS support return an error. Reformulate one constraint through its handle, or every active SOS in a model explicitly:

let transformed = one_choice.to_reformulated_model(SosReformulationOptions::default())?;
solver.solve(&transformed, &options)?;

let transformed = m.to_reformulated_sos_model(
    SosReformulationOptions::default().with_fallback_big_m(1.0e6),
)?;

// Reformulate every active SOS in place when the native form is no longer needed.
let artifacts = m.reformulate_sos(
    SosReformulationOptions::default().with_fallback_big_m(1.0e6),
)?;
solver.solve(&m, &options)?;

The to_reformulated_* methods produce an independent model. reformulate* methods modify the source model.

All forms preserve original IDs, append binary variables and linear rows, and retain the source SOS entries as inactive provenance. Finite variable bounds are used as member-specific Big-M values. An infinite bound is an error unless the caller explicitly supplies a positive finite fallback. A fallback that is too small can truncate the feasible region.

Prefer solving the original model with a backend that supports native SOS. Reformulate when the selected backend lacks that capability or when you intentionally need a MILP representation. In-place reformulation first validates every active set, then appends all generated artifacts without copying the source model.

Because the generated rows embed the current member bounds, those bounds cannot be changed on a reformulated model. Change bounds before reformulating, or produce a fresh reformulated copy after changing the source model.

Indicator constraints

An indicator applies an affine row only when an existing binary variable has the selected value. The implication is one-way:

variable!(m, enabled, Binary);
variable!(m, x);
indicator_constraint!(m, capacity, enabled == 1 => x <= 10.0);
indicator_constraint!(m, shutdown, enabled == 0 => x == 0.0);
indicator_constraint!(m, band, enabled == 1 => -2.0 <= x <= 4.0);

The trigger must be a bare binary variable from the same model and the consequent must be affine. Native support is available in Gurobi and MOSEK; GAMS requires an explicitly selected COPT, CPLEX, Gurobi, SCIP, or Xpress subsolver.

Backends without native indicator support reject active indicators. Convert them explicitly with reformulate_indicators, or use to_reformulated_indicator_model when changing the source model isn't intended.

m.reformulate_indicators(IndicatorReformulationOptions::default())?;
highs.solve(&m, &options)?;

Second-order cone constraints

soc_constraint! registers ||terms||_2 <= bound. Every term and the bound must be affine. The model classifies as SOCP/MISOCP.

soc_constraint!(m, cone, [x, y] <= t);                       // named -> SocConstraintHandle
soc_constraint!(m, [x - y, 2.0 * y] <= t + 1.0);             // anonymous (auto-named _soc0, ...)
soc_constraint!(m, name = format!("c_{k}"), [x] <= t);       // computed run-time name
soc_constraint!(m, risk[i in assets], [s[i] * w[i]] <= cap); // family: risk[key] per key

The method form m.add_soc_constraint("cone", [x, y], t) is equivalent.

Objectives

objective!(m, Min, cost_expr);
objective!(m, Max, revenue_expr);

Nonlinear expressions use Expr methods such as sqrt, exp2, log1p, atan2, min, and max. min!/max! provide the same indexed-domain syntax as sum! and preserve deterministic child order.

Parameters

param!(m, rate = 0.05);     // binds a re-bindable `rate: Expr<'_>`
rate.set_param_value(0.07); // change between solves without rebuilding

Indexed parameters

Mirror indexed variables: one re-bindable scalar parameter per key, bound as an IndexedParam. The right-hand side is evaluated per key and may reference the index.

let items = Set::range(0..3);
param!(m, cost[i in items] = base_cost[i]); // one parameter per key
param!(m, w[(i, j) in rc] = weight(i, j));  // multi-index
param!(m, c[p in plants] = price[p]);       // string-keyed (sparse)

let unit = cost[1];             // index for a param `Expr`
cost[1].set_param_value(9.0);   // re-bind one entry via its handle
m.set_param_idx(&cost, 1, 9.0)?; // ...or by key on the model
m.param_value_idx(&cost, 1);    // -> Some(9.0)

Model kind

Inferred automatically from variables and expressions, cached and invalidated on change. The decision ladder runs top-down. Any integer/binary variable picks the MI* variant of the row that matches:

Kind (continuous/integer) Conditions
NLP/MINLP Any nonlinear expression (degree > 2, transcendentals, division)
QCP/MIQCP Any quadratic constraint not recognized as a second-order cone
SOCP/MISOCP Second-order cones present (explicit or detected)
QP/MIQP Quadratic objective, linear constraints
LP/MILP Everything linear

License

MIT OR Apache-2.0