///|
/// The LP / MILP model layer.
///
/// A `Model` is the single source of truth for the optimisation problem: it
/// stores variables (with bounds and integrality flags), the objective and the
/// constraint rows. Nothing in this package knows how to solve anything.
///
/// Two conventions matter for the rest of the project:
/// – coefficients are stored as `(variable index, coefficient)` pairs, sorted by
/// index, duplicate indices merged and exact zeros dropped;
/// – `±1e30` is used as the numeric stand-in for an infinite bound, so that the
/// kernel never has to special-case infinities.
///|
/// Optimisation direction.
///
/// `pub(all)` on purpose: other packages (the parsers, the solver, user code)
/// must be able to build and match these values.
pub(all) enum Sense {
Minimize
Maximize
} derive(Debug, Eq)
///|
/// Relation of a constraint row.
pub(all) enum Rel {
LessEqual
GreaterEqual
Equal
} derive(Debug, Eq)
///|
/// One decision variable.
pub struct Var {
name : String
lb : Double
ub : Double
is_int : Bool
} derive(Debug)
///|
/// One constraint row: `terms relation rhs`.
pub struct Constraint {
name : String
rel : Rel
rhs : Double
terms : Array[(Int, Double)]
} derive(Debug)
///|
/// Parses an objective sense keyword as used by MPS (`MAX` / `MIN`, also the
/// long forms). Returns `None` for anything else so callers can report a
/// precise parse error.
pub fn Sense::from_symbol(symbol : String) -> Sense? {
if symbol == "MIN" || symbol == "MINIMIZE" || symbol == "MINIMUM" {
Some(Minimize)
} else if symbol == "MAX" || symbol == "MAXIMIZE" || symbol == "MAXIMUM" {
Some(Maximize)
} else {
None
}
}
///|
/// Parses an MPS row type keyword: `L` (less-equal), `G` (greater-equal),
/// `E` (equal). Returns `None` for anything else.
pub fn Rel::from_symbol(symbol : String) -> Rel? {
if symbol == "L" || symbol == "LE" || symbol == "<=" || symbol == "=<" {
Some(LessEqual)
} else if symbol == "G" || symbol == "GE" || symbol == ">=" || symbol == "=>" {
Some(GreaterEqual)
} else if symbol == "E" || symbol == "EQ" || symbol == "=" || symbol == "==" {
Some(Equal)
} else {
None
}
}
///|
/// Numeric stand-in for an infinite bound.
pub let infinite_bound : Double = 1.0e30
///|
/// An optimisation model under construction.
pub struct Model {
mut sense : Sense
mut objective_terms : Array[(Int, Double)]
vars : Array[Var]
constraints : Array[Constraint]
} derive(Debug)
///|
/// Creates an empty model with the given optimisation direction.
pub fn Model::new(sense : Sense) -> Model {
{ sense, objective_terms: [], vars: [], constraints: [], }
}
///|
/// Current optimisation direction.
pub fn Model::sense(self : Model) -> Sense {
self.sense
}
///|
/// Changes the optimisation direction after the fact.
pub fn Model::set_sense(self : Model, sense : Sense) -> Unit {
self.sense = sense
}
///|
/// Number of variables.
pub fn Model::num_vars(self : Model) -> Int {
self.vars.length()
}
///|
/// Number of constraint rows.
pub fn Model::num_constraints(self : Model) -> Int {
self.constraints.length()
}
///|
/// Adds a variable and returns its index.
///
/// `lb = 0.0` and `ub = infinite_bound` give the LP default `x >= 0`. Set
/// `is_int = true` for integer variables (binary variables are `lb = 0`,
/// `ub = 1`, `is_int = true`).
pub fn Model::add_var(
self : Model,
name : String,
lb? : Double = 0.0,
ub? : Double = infinite_bound,
is_int? : Bool = false,
) -> Int {
self.vars.push({ name, lb, ub, is_int, })
self.vars.length() - 1
}
///|
/// Reads back a variable definition. (`var` is a keyword in MoonBit, hence the
/// name.)
pub fn Model::get_var(self : Model, index : Int) -> Var {
self.vars[index]
}
///|
/// Replaces the bounds of a variable.
///
/// Model file readers need this: a file states bounds in a section that comes
/// after the columns, so the variable exists before its final bounds are known.
pub fn Model::set_var_bounds(
self : Model,
index : Int,
lb : Double,
ub : Double,
) -> Unit {
let variable = self.vars[index]
self.vars[index] = { name: variable.name, lb, ub, is_int: variable.is_int, }
}
///|
/// Marks a variable as integer. Kept separate from `set_var_bounds` because
/// model files express integrality through markers or sections of their own.
pub fn Model::set_var_integer(self : Model, index : Int) -> Unit {
let variable = self.vars[index]
self.vars[index] = {
name: variable.name,
lb: variable.lb,
ub: variable.ub,
is_int: true,
}
}
///|
/// Replaces the objective, merging duplicates and dropping zeros.
pub fn Model::set_objective(self : Model, terms : Array[(Int, Double)]) -> Unit {
self.objective_terms = normalize_terms(terms)
}
///|
/// Objective as sorted `(variable index, coefficient)` pairs.
pub fn Model::objective(self : Model) -> Array[(Int, Double)] {
self.objective_terms
}
///|
/// Adds an unnamed constraint row.
pub fn Model::add_constraint(
self : Model,
terms : Array[(Int, Double)],
rel : Rel,
rhs : Double,
) -> Unit {
self.add_named_constraint(terms, rel, rhs, "")
}
///|
/// A copy that can be changed without touching the original.
///
/// Used by the branch and bound, where every node is the parent model with one variable's
/// bound tightened: `set_var_bounds` replaces the entry it is given, so copying the
/// arrays is enough to keep the parent intact. The terms inside a constraint are shared
/// rather than copied — nothing in this package mutates them, and a tree of nodes that
/// copied every row's coefficients would spend its memory on the part of the model that
/// branching never touches.
pub fn Model::clone(self : Model) -> Model {
{
sense: self.sense,
objective_terms: self.objective_terms.copy(),
vars: self.vars.copy(),
constraints: self.constraints.copy(),
}
}
///|
/// Adds a constraint row carrying a human readable name (used by reports and by
/// the MPS writer).
pub fn Model::add_named_constraint(
self : Model,
terms : Array[(Int, Double)],
rel : Rel,
rhs : Double,
name : String,
) -> Unit {
self.constraints.push({ name, rel, rhs, terms: normalize_terms(terms), })
}
///|
/// Reads back a constraint row.
pub fn Model::constraint(self : Model, index : Int) -> Constraint {
self.constraints[index]
}
///|
/// Checks the model for structural problems. An empty result means the model is
/// well formed; a non-empty result lists human readable issues.
///
/// This is deliberately a pure function returning messages instead of raising,
/// so callers (CLI, tests, future LSP tooling) can render them however they
/// want.
pub fn Model::validate(self : Model) -> Array[String] {
let issues : Array[String] = []
let nvars = self.vars.length()
for i = 0; i < nvars; i = i + 1 {
let v = self.vars[i]
if !@core.is_finite(v.lb) {
issues.push("variable " + v.name + ": lower bound is not finite")
}
if !@core.is_finite(v.ub) {
issues.push("variable " + v.name + ": upper bound is not finite")
}
if v.lb > v.ub {
issues.push("variable " + v.name + ": lower bound exceeds upper bound")
}
for k = 0; k < i; k = k + 1 {
if self.vars[k].name == v.name && v.name != "" {
issues.push("variable " + v.name + ": duplicate name")
}
}
}
for j = 0; j < self.objective_terms.length(); j = j + 1 {
let (index, coeff) = self.objective_terms[j]
if index < 0 || index >= nvars {
issues.push("objective: variable index out of range")
}
if !@core.is_finite(coeff) {
issues.push("objective: non-finite coefficient")
}
}
for c = 0; c < self.constraints.length(); c = c + 1 {
let con = self.constraints[c]
let label = if con.name == "" { "#" + c.to_string() } else { con.name }
if con.terms.length() == 0 {
issues.push("constraint " + label + ": empty row")
}
if !@core.is_finite(con.rhs) {
issues.push("constraint " + label + ": non-finite right hand side")
}
for t = 0; t < con.terms.length(); t = t + 1 {
let (index, coeff) = con.terms[t]
if index < 0 || index >= nvars {
issues.push("constraint " + label + ": variable index out of range")
}
if !@core.is_finite(coeff) {
issues.push("constraint " + label + ": non-finite coefficient")
}
}
}
issues
}
///|
/// Returns `true` when the model passes `validate`.
pub fn Model::is_valid(self : Model) -> Bool {
self.validate().length() == 0
}
///|
/// Sorts terms by variable index, merges duplicates and drops zeros.
pub fn normalize_terms(terms : Array[(Int, Double)]) -> Array[(Int, Double)] {
let sorted = terms.copy()
let n = sorted.length()
for i = 1; i < n; i = i + 1 {
let cur = sorted[i]
let mut j = i - 1
while j >= 0 && sorted[j].0 > cur.0 {
sorted[j + 1] = sorted[j]
j = j - 1
}
sorted[j + 1] = cur
}
let merged : Array[(Int, Double)] = []
for k = 0; k < n; k = k + 1 {
let (index, coeff) = sorted[k]
if merged.length() > 0 && merged[merged.length() - 1].0 == index {
let last = merged.length() - 1
let previous = merged[last].1
merged[last] = (index, previous + coeff)
} else {
merged.push((index, coeff))
}
}
let out : Array[(Int, Double)] = []
for k = 0; k < merged.length(); k = k + 1 {
if merged[k].1 != 0.0 {
out.push(merged[k])
}
}
out
}