An Elixir library for modeling and solving linear, mixed-integer, quadratic, and second-order cone programs (LP, MILP, QP, QCP, SOCP, plus native indicator, absolute-value, piecewise-linear, min/max, and SOS constructs), with in-process solver bindings via Rustler: HiGHS (always available) and optionally Gurobi, CPLEX, and COPT (each compiled only when its licensed installation is present at build time).
Three cleanly separated layers:
- Modeling (pure Elixir): a declarative
model do ... endDSL, affine/quadratic expressions, an immutable model struct. - Solver abstraction (pure Elixir): a
Optex.Solverbehaviour with a strict capability model and a neutral column-sparseOptex.SolverInput. - Binding (Rustler): one dirty NIF per backend that hands the whole model to the solver and returns the solution.
Installation
def deps do
[
{:optex, "~> 0.1.0"}
]
endThe default HiGHS backend ships precompiled for x86_64/aarch64 Linux
(glibc), x86_64/aarch64 macOS, and x86_64 Windows (MSVC): on those
platforms mix deps.get && mix compile downloads a checksummed binary and
no Rust toolchain is needed. On any other platform, or with
FORCE_OPTEX_BUILD=1 set, the NIF builds from source, which requires Rust
1.91+, CMake, and libclang (on Windows, install LLVM and set
LIBCLANG_PATH to its bin directory) and compiles all of HiGHS via
CMake on first build (minutes; cached afterwards).
The commercial backends are always compiled from source against your
installed SDK: set GUROBI_HOME, a versioned CPLEX_STUDIO_DIR* variable,
or COPT_HOME (their installers do this) and run mix compile --force
once. Without them the rest of the library works normally and each
backend's available?/0 returns false.
Developed and tested on Windows (MSVC) and Linux (CI); the precompiled macOS binaries are built in CI but not exercised by a full test run there yet.
Usage
import Optex.DSL
m =
model sense: :max do
variable x, lb: 0.0
variable y[i], i <- [1, 2, 3], lb: 0.0
variable pick, type: :bin
constraint x + sum(y[i], i <- [1, 2, 3]) <= 10
constraint sum(y[i], i <- [1, 2, 3], i > 1) <= 4
constraint x - pick <= 6
objective x + 2 * y[1] + pick
end
{:ok, sol} = Optex.optimize(m)
sol.status #=> :optimal
sol.objective #=> the optimal objective value
sol.values[:x] #=> value of x
sol.values[{:y, 2}] #=> value of y[2]Solution values are keyed by the names used in the model: the bare atom for a
scalar variable, {family, index} for indexed families. Multi-index families
use explicit tuple keys: declare variable w[{i, j}], i <- 1..2, j <- 1..3
and read sol.values[{:w, {1, 2}}]. (Elixir's parser does not accept
w[i, j].)
Runnable, commented examples live in examples/, from a
starter LP to an assignment problem and a data-driven multi-period plan:
mix run examples/knapsack.exs.
Variable types are :cont (default), :int, and :bin; binary variables get
[0, 1] bounds automatically. Bounds accept numbers or symbolic
:infinity/:neg_infinity. Constraints use <=, >=, == with variables
and constants on either side. sum/2+ takes generators and filters as
arguments; a literal for comprehension works too. A constraint with
trailing generator clauses declares a whole family, one row per binding:
constraint sum(ship[{p, mk}], mk <- markets) <= supply[p], p <- plantsNative general constraints (solved by the solver's own construct, never
reformulated) are available on capable backends (Gurobi, CPLEX; HiGHS rejects
them with {:error, {:unsupported, construct, backend}}):
constraint ship[s] <= cap[s], s <- sites, if: open[s] # indicator: open -> row
constraint x <= 1, if: {b, 0} # active when b = 0
variable t = abs(x - y) # exact absolute value
variable c = pwl(x, [{0, 0}, {10, 10}, {20, 30}]) # piecewise-linear cost
variable m = max(x, y, 3.5) # native max (Gurobi only)
constraint norm(x - y, z) <= t # second-order cone
constraint sos1([{x, 1}, {y, 2}]), name: :pick # special ordered setpwl breakpoints are {x, y} pairs with non-decreasing x; consecutive
points are joined by segments and the first and last segments extend beyond
the breakpoint range (identical semantics on every capable backend). Two
consecutive points sharing an x with different y values define a jump
discontinuity; at the jump the solver may pick either value, and jumps must
be interior (the end segments define the extension slopes).
max/min accept any mix of linear expressions and numbers (numbers fold
into one constant operand) and are a Gurobi-only capability; HiGHS and CPLEX
reject them.
norm(exprs...) <= bound declares a second-order cone (bound >= sqrt(sum of squares)), solved natively on Gurobi, CPLEX, and COPT (each through its
own documented encoding); rotated cones (2 h1 h2 >= sum of squares) are
available programmatically via Optex.Model.add_rotated_cone/5. Cone
bounds must be nonnegative variables (expressions get an auxiliary head).
sos1/sos2 declare special ordered sets over {variable, weight} pairs
(distinct weights define the order; SOS2 adjacency follows it), on the same
three backends.
No big-M anywhere: the solver handles the logic internally. abs/max/min
deeper inside expressions are rejected at build time with guidance.
Constraints take a trailing name: option (evaluated per binding in a
family, so it may reference the generator variables):
constraint 2 * tables + chairs <= 40, name: :carpentry
constraint x[t] <= cap[t], t <- periods, name: {:cap, t}optimize/2 accepts solver options: time_limit:, mip_gap:, threads:,
log: (true for stdout, or a pid that receives {:optex_highs_log, line}
messages), and cancel: (a token from Optex.Solver.HiGHS.cancel_token/0;
calling cancel/1 from another process interrupts the solve, which returns
status :interrupted).
Long MIP solves can be watched live on every backend: progress: streams
throttled {:optex_progress, %{best_obj, best_bound, gap, nodes, time}}
maps (progress_every: sets the throttle in ms, default 1000; fields a
backend does not report are nil), and incumbents: streams
{:optex_incumbent, %{objective, values}} for each improving solution with
values keyed by variable name. Combining progress: with a cancel token
gives stop-when-good-enough rules in plain Elixir: watch the stream,
decide, cancel.
Solutions carry stats (solve time, simplex iterations, nodes, achieved MIP
gap), and for LPs duals (keyed by constraint name, id fallback for unnamed
rows) and reduced_costs (by variable name); both are nil for models with
integer variables. duals covers linear rows only; on Gurobi, passing
qcp_duals: true additionally returns quadratic constraint duals in
qcon_duals (keyed by qconstraint name) for continuous QCPs, at the cost of
the extra dual computation Gurobi's QCPDual parameter enables. Backends
without that capability reject the option.
Debugging aids:
Optex.explain_infeasibility(m)computes an irreducible infeasible subsystem: the minimal set of named constraints and variable bounds that conflict. On Gurobi the IIS examines the full model, and conflicting native constructs (indicators, abs/pwl/min-max definitions, quadratic constraints) are reported as{kind, name}underconstructs; on other backends the analysis covers the linear relaxation and constructs are stripped and reported undernot_examined.Optex.Format.pretty(m)renders the model as readable text with the names as written;Optex.LP.emit(m)writes an LP-format file with sanitized names for hand inspection or other solvers.
Objectives and constraints may be quadratic, with literal coefficients:
objective x * x + 2 * x * y - 3 * x # QP, all backends
constraint x * x + y * y <= 2, name: :ball # QCP, capable backendsThe capability matrix is strict, and unsupported inputs fail with
{:error, {:unsupported, construct, backend}} before solving:
| HiGHS | Gurobi | CPLEX | COPT | |
|---|---|---|---|---|
| quadratic objective | convex, continuous only | full (MIQP, nonconvex) | convex, incl. MIQP | convex, incl. MIQP |
| quadratic constraint | no | full (nonconvex, equality) | convex, <=/>= only | convex, <=/>= only |
Quadratic terms in indicator rows or abs/pwl arguments are rejected at
build time, and products of degree greater than two raise
Optex.NonlinearError.
Solver backends
optimize/2 takes solver: Optex.Solver.HiGHS (default),
Optex.Solver.Gurobi, Optex.Solver.CPLEX, or Optex.Solver.COPT. All
implement the full contract: options, stats, duals, reduced costs, log
streaming, cancellation, and IIS, and a cross-solver test suite pins them
to agreeing objectives and duals. The commercial backends are compile-gated
on their installations (GUROBI_HOME; the versioned CPLEX_STUDIO_DIR*
var; COPT_HOME); without them the rest of the library builds and works
normally and each backend's available?/0 returns false. Log messages
arrive as {:optex_<backend>_log, line} (for example
{:optex_gurobi_log, line}), and cancel tokens come from each backend's
own cancel_token/0 (tokens are backend-specific). COPT supports
indicator constraints and convex quadratics but has no native abs, pwl, or
min/max constructs, so those inputs are rejected on it.
Not in scope
Deliberately deferred, so the boundary is visible:
- General nonlinearity beyond quadratics and second-order cones - products of degree greater than two are rejected at build time, never represented.
- Persistent solver handles, warm starts, incremental modification.
- Basis information.
- Multi-objective; control callbacks (lazy constraints, user cuts, heuristic injection) - progress/incumbent streaming is built in.
Building from source
Consumers on the precompiled platforms need none of this (see Installation). Developing Optex itself, or building on other platforms, requires Elixir (~> 1.20), Rust (1.91+), CMake, and libclang (for bindgen):
- Set
FORCE_OPTEX_BUILD=1so the HiGHS NIF compiles from source instead of looking for a release binary (development checkouts should always set it). highs-sysis pinned to 1.15.0 and builds HiGHS 1.15.0 from source via CMake atmix compiletime.- On Windows, install LLVM and set
LIBCLANG_PATHto itsbindirectory if bindgen cannot find libclang.
Run tests with mix test. Oracle tests cross-check the NIF against a
standalone HiGHS binary via an MPS emitter, and backend tests self-exclude
without the corresponding solver installed; the suite includes performance
regression tests that guard the scaling of every hot phase. Benchmarks live
in bench/ (mix run bench/benchmarks.exs, mix run bench/scale.exs)
with tracked baselines in bench/BASELINE.md.
Generate API docs with mix docs (ExDoc; output in doc/).
Design decisions and version-pin verification notes live in DECISIONS.md.
License
MIT, see LICENSE. HiGHS itself is MIT-licensed and is built from source via
the highs-sys crate at compile time.