Problems, variables, constraints.
Every grove program is the same four steps in a different
arrangement: create a Problem, declare
Vars, hand the problem an objective, attach
Constraints. This page is the whole modeling DSL
on one screen.
Creating a problem
A Problem owns the variables and constraints
you’ll hang off it. Construct one with a name and an
optimization sense.
prob := grove.NewProblem("nurse_schedule", grove.Minimize)
// or: grove.NewProblem("value", grove.Maximize)
The name is used for error messages and file-format exports.
Use the Go-idiomatic name-the-decision-problem convention
(nurse_schedule, vm_allocation,
diet). You can change the sense later by
reassigning the field, but it’s a footgun — do it before
you call Solve.
Variables
A variable is created through the Problem so
that grove can track it, assign it a column index, and later
feed you the value back:
x := prob.NewVar("x", grove.Continuous, grove.Bounds(0, grove.Inf))
Kinds
kind is one of three values, all of type
grove.VarKind:
| Kind | Domain | Status in v0.1 |
|---|---|---|
grove.Continuous |
real numbers within bounds | solved directly |
grove.Integer |
integers within bounds | LP relaxation only |
grove.Binary |
{0, 1} | LP relaxation only |
If you care about the Integer/Binary
row, read Integer variables
— grove runs a post-solve check and emits a warning if the
LP relaxation didn’t happen to land on an integer
vertex, but the caveat is important enough that the whole page
is about it.
Bounds
grove.Bounds(low, high) is the only
VarOption in v0.1. Both arguments are
float64. For unbounded sides, pass
grove.Inf or -grove.Inf:
a := prob.NewVar("a", grove.Continuous, grove.Bounds(0, grove.Inf)) // a ≥ 0
b := prob.NewVar("b", grove.Continuous, grove.Bounds(-grove.Inf, grove.Inf)) // free
c := prob.NewVar("c", grove.Continuous, grove.Bounds(0, 1)) // [0, 1]
d := prob.NewVar("d", grove.Continuous, grove.Bounds(-5, 5)) // [-5, 5]
If you omit Bounds entirely you get the default
[0, +∞), which is what most LP formulations want.
Internally, bounded variables are substituted so the standard form sees only non-negative variables; free variables are split into a positive and a negative component. You don’t have to know any of that to use the library — the point is that you don’t need to do any of it by hand.
Expressions
grove.Expr is a Go map from *Var to
coefficient. The map itself is the linear expression — there
is no wrapper type to remember:
e := grove.Expr{x: 3, y: -2, z: 1} // 3x - 2y + z
Two helpers exist for when the expression comes from data:
e := grove.Expr{x: 1}
e.Add(grove.Expr{y: 2}) // e is now {x: 1, y: 2}
e.Scale(0.5) // e is now {x: 0.5, y: 1}
Because Expr is just a map, the ergonomic way to
build a big constraint is the range-over-slice idiom:
obj := grove.Expr{}
for i := range foods {
obj[x[i]] = foods[i].pricePerGram
}
prob.SetObjective(obj)
Objectives
Set the objective by handing the problem one
Expr:
prob.SetObjective(grove.Expr{x: 1, y: 1})
The sense (Maximize / Minimize) is on the Problem,
not on the objective — one less sign to track. grove negates
internally for Maximize and un-negates the
returned objective for you.
A fixed constant offset is optional:
prob.SetObjectiveConstant(5) // z → z + 5
This is convenient when the mathematically clean objective is something like “minimise total cost with a flat $5 admin fee on top”.
Constraints
Each AddConstraint call returns a
*Constraint handle. Hold onto it if you want to
read the shadow price back from the Result:
budget := prob.AddConstraint(
"budget",
grove.Expr{day: 1200, night: 1500},
grove.LTE,
18000,
)
// later:
fmt.Println(res.Dual(budget)) // ∂z*/∂rhs of `budget`
The relation is grove.LTE, grove.GTE,
or grove.EQ. Right-hand side is a single
float64. Negative RHS, non-zero RHS, and
two-sided inequalities composed of a pair of rows all work.
Constraint names must be unique per problem; duplicates will
cause Validate to fail. Names show up in error
messages, LP/MPS exports, and the
sensitivity report.
Validation
Problem.Validate runs automatically at the top of
Solve, but you can call it yourself earlier if
you’re building a model in pieces. It returns
[]error with every problem found — it
never short-circuits on the first failure, so a single call
surfaces the whole class of issues at once:
if errs := prob.Validate(); len(errs) > 0 {
for _, err := range errs {
log.Println("bad model:", err)
}
return errors.Join(errs...)
}
The full list of checks:
| Kind | What it catches |
|---|---|
ValidationNoVariables |
The problem has no variables at all. |
ValidationNoObjective |
SetObjective was never called (the objective map is empty). |
ValidationZeroObjective |
Every objective coefficient is finite and zero. If any
coefficient is NaN or ±Inf, only
ValidationBadObjectiveCoef is reported for
those terms. grove treats an all-finite-zero objective as
a modeling mistake — add a non-zero coefficient on at
least one variable, or (for user-built models) drop the
objective call entirely and hit ValidationNoObjective.
Presolve-reduced problems may legally have an empty objective
map when the linear part was folded into
SetObjectiveConstant.
|
ValidationBadBound |
A variable has a NaN bound. ±Inf is still valid — that’s the sentinel for an unbounded side. |
ValidationInvertedBounds |
low > high on Bounds, which would leave the variable’s domain empty. |
ValidationBadObjectiveCoef |
The objective has a NaN or ±Inf coefficient. |
ValidationBadRHS |
A constraint has a NaN or ±Inf right-hand side. |
ValidationEmptyRow |
A constraint’s LHS is the empty expression Expr{}. |
ValidationZeroRow |
A constraint has a non-empty LHS and every coefficient is finite and zero (Expr{x: 0, y: 0}). Rows with any NaN/±Inf coefficient only report ValidationBadCoefficient, not ValidationZeroRow. These would be dropped by presolve anyway; rejecting up front surfaces the likely modeling typo. |
ValidationBadCoefficient |
A constraint has a NaN or ±Inf coefficient on one of its variables. |
ValidationDuplicateConstraint |
Two or more constraints share the same name. (AddConstraint panics on duplicates at registration time; Validate is the backstop for direct-slice construction and file readers.) |
Each entry in the returned slice is a *ValidationError
with a stable Kind field — handy when tooling wants
to react to a specific class of issue rather than grepping
strings:
var verr *grove.ValidationError
for _, err := range prob.Validate() {
if errors.As(err, &verr) && verr.Kind == grove.ValidationDuplicateConstraint {
fixDuplicateName(verr.Target)
}
}
Both NewVar and AddConstraint still
panic on duplicate names at registration time; Validate
exists to catch the same class of problem when a model is built
any other way — a file reader, code inside the
grove package that appends to the internal
constraint slice, or a test that mutates the live slice returned
by Constraints() (the backing array is shared; treat
it as read-only unless you know what you’re doing).
Putting it together
A complete model is short. This is the nurse-scheduling LP from the intro, with the shape of every section above made explicit:
prob := grove.NewProblem("nurses", grove.Maximize) // Problem
day := prob.NewVar("day", grove.Continuous, grove.Bounds(0, grove.Inf)) // Variables
night := prob.NewVar("night", grove.Continuous, grove.Bounds(0, grove.Inf))
prob.SetObjective(grove.Expr{day: 1, night: 1}) // Objective
prob.AddConstraint("day_min", grove.Expr{day: 1}, grove.GTE, 4)
prob.AddConstraint("night_min", grove.Expr{night: 1}, grove.GTE, 2)
prob.AddConstraint("budget", grove.Expr{day: 1200, night: 1500}, grove.LTE, 18000)
res, _ := prob.Solve() // Solve
With a model in hand, the next page is how to read the
Result object.