Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 33 additions & 100 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
* Clean and idiomatic API for modeling and solving LPs.
* Focused on numerical stability and usability.
* Performs basic branch-and-bound techniques for pure integer problems.
* Provides native multi-objective optimization support.
* Builtin bindings for C and Python (coming soon).

`gspl` is ideal for embedding linear optimization into Go-based software.

Expand All @@ -28,95 +30,7 @@ go get github.com/chriso345/gspl

## Usage

```go
package main

import (
"fmt"

"github.com/chriso345/gspl/lp"
"github.com/chriso345/gspl/solver"
)

func main() {
// Create decision variables
variables := []lp.LpVariable{
lp.NewVariable("x1"),
lp.NewVariable("x2"),
lp.NewVariable("x3"),
}

x1 := &variables[0]
x2 := &variables[1]
x3 := &variables[2]

// Objective function: Minimize -6 * x1 + 7 * x2 + 4 * x3
objective := lp.NewExpression([]lp.LpTerm{
lp.NewTerm(-6, *x1),
lp.NewTerm(7, *x2),
lp.NewTerm(4, *x3),
})

// Set up the LP problem
example := lp.NewLinearProgram("README Example", variables)
example.AddObjective(lp.LpMinimise, objective)

// Add constraints
example.AddConstraint(lp.NewExpression([]lp.LpTerm{
lp.NewTerm(2, *x1),
lp.NewTerm(5, *x2),
lp.NewTerm(-1, *x3),
}), lp.LpConstraintLE, 18)

example.AddConstraint(lp.NewExpression([]lp.LpTerm{
lp.NewTerm(1, *x1),
lp.NewTerm(-1, *x2),
lp.NewTerm(-2, *x3),
}), lp.LpConstraintLE, -14)

example.AddConstraint(lp.NewExpression([]lp.LpTerm{
lp.NewTerm(3, *x1),
lp.NewTerm(2, *x2),
lp.NewTerm(2, *x3),
}), lp.LpConstraintEQ, 26)

// Print the current problem
fmt.Printf("%s\n", example.String())

// Solve it
sol, err := solver.Solve(&example)
if err != nil {
fmt.Println("solve error:", err)
return
}
fmt.Printf("Optimal Objective Value: %.2f\n", sol.ObjectiveValue)
fmt.Printf("Primal Solution: %v\n", sol.PrimalSolution.RawVector().Data)
}
```

### What's Happening?

We're setting up a linear program to:

1. **Minimize**:
$-6x_1 + 7x_2 + 4x_3$
2. **Subject to constraints**:

* $2x_1 + 5x_2 - x_3 \leq 18$
* $x_1 - x_2 - 2x_3 \leq -14$
* $3x_1 + 2x_2 + 2x_3 = 26$

The solution will print the optimal values of the variables and the minimized objective value.

See the [examples](examples) directory for other scenarios.

---

## Performance

A small benchmark is included (go test -bench ./solver) that measures solve performance on a tiny LP; results will vary by CPU and Go version. The solver is optimized for minimal allocations in the revised simplex implementation and supports cancellation via context passed with SolverOption. For concurrent use, provide each goroutine its own *lp.LinearProgram; Solve may temporarily reference fields inside the provided program for performance and thus the same program must not be mutated concurrently during a Solve call.

## API Overview
Full examples are available in the [gspl_examples_test.go](gspl_examples_test.go) file and the [examples/](examples/) directory.

### Variables

Expand All @@ -129,13 +43,13 @@ variables := []lp.LpVariable{
}
```

You can access and pass them as pointers:
You can access and pass them by value or take their address when needed:

```go
x1 := &variables[0]
```

Forcing integer constraints is done at the variable level:
Forcing integer constraints is done when creating the variable:

```go
variables := []lp.LpVariable{
Expand All @@ -150,26 +64,26 @@ Build the objective using terms:

```go
objective := lp.NewExpression([]lp.LpTerm{
lp.NewTerm(5, *x1),
lp.NewTerm(3, *x2),
lp.NewTerm(5, variables[0]),
lp.NewTerm(3, variables[1]),
})
```

Add it to the LP:

```go
lp := lp.NewLinearProgram("My LP", variables)
lp.AddObjective(lp.LpMaximise, objective)
prog := lp.NewLinearProgram("My LP", variables)
prog.AddObjective(lp.LpMaximise, objective)
```

### Constraints

Each constraint uses an expression, a comparison type, and a right-hand side:

```go
lp.AddConstraint(lp.NewExpression([]lp.LpTerm{
lp.NewTerm(2, *x1),
lp.NewTerm(3, *x2),
prog.AddConstraint(lp.NewExpression([]lp.LpTerm{
lp.NewTerm(2, variables[0]),
lp.NewTerm(3, variables[1]),
}), lp.LpConstraintLE, 10)
```

Expand All @@ -182,15 +96,34 @@ Constraint types:
### Solving

```go
solution, err := solver.Solve(&lp)
solution, err := solver.Solve(&prog)
if err != nil {
// handle error
}
solution.PrintSolution()
fmt.Printf("obj=%.2f, x=%.2f\n", solution.ObjectiveValue, solution.PrimalSolution.AtVec(0))
```

This solves the model and prints variable values and the objective result.

### Multi-Objective Support

gspl provides native multi-objective optimization capabilities via the `mop` package.

```go
x := lp.NewVariable("x")
y := lp.NewVariable("y")
p := lp.NewLinearProgram("Pareto", []lp.LpVariable{x, y})
p.AddObjective(lp.LpMaximise, lp.NewExpression([]lp.LpTerm{lp.NewTerm(1, x)}))
p.AddObjective(lp.LpMaximise, lp.NewExpression([]lp.LpTerm{lp.NewTerm(1, y)}))
p.AddConstraint(lp.NewExpression([]lp.LpTerm{lp.NewTerm(1, x), lp.NewTerm(1, y)}), lp.LpConstraintLE, 10)

sols, err := mop.SolvePareto(&p, mop.ParetoWeightedSum, mop.WithWeightedSums([][]float64{{1, 0}, {0, 1}}))
if err != nil {
fmt.Println("err", err)
return
}
```

---

## License
Expand Down
4 changes: 4 additions & 0 deletions internal/lang/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Package lang provides parsing and utilities for the GSPL language.
// It contains parsers, AST types, and helpers used across the solver and CLI.
// This file holds package documentation.
package lang
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,5 @@ run *args:

# Open the documentation in the browser
[group("Documentation")]
docs:
site:
pkgsite
60 changes: 60 additions & 0 deletions lp/examples_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package lp

import (
"fmt"
)

func ExampleNewLinearProgram() {
x := NewVariable("x")
y := NewVariable("y")
p := NewLinearProgram("Example LP", []LpVariable{x, y})
p.AddObjective(LpMinimise, NewExpression([]LpTerm{NewTerm(1, x), NewTerm(2, y)}))
p.AddConstraint(NewExpression([]LpTerm{NewTerm(1, x), NewTerm(1, y)}), LpConstraintGE, 3)

fmt.Println(p.Description)
// Output: Example LP
}

func ExampleNewVariable() {
x := NewVariable("x")
y := NewVariable("y", LpCategoryInteger)
z := NewVariable("z", LpCategoryBinary)
fmt.Printf("%s %d %s %d %s %d\n", x.Name, int(x.Category), y.Name, int(y.Category), z.Name, int(z.Category))
// Output: x 0 y 1 z 2
}

func ExampleNewTerm() {
x := NewVariable("x")
t := NewTerm(3, x)
fmt.Printf("%.0f %s\n", t.Coefficient, t.Variable.Name)
// Output: 3 x
}

func ExampleNewExpression() {
x := NewVariable("x")
t := NewTerm(2, x)
expr := NewExpression([]LpTerm{t})
fmt.Printf("%d\n", len(expr.Terms))
// Output: 1
}

func ExampleLinearProgram_AddObjective() {
x := NewVariable("x")
y := NewVariable("y")
p := NewLinearProgram("o", []LpVariable{x, y})
p.AddObjective(LpMaximise, NewExpression([]LpTerm{NewTerm(2, x), NewTerm(-3, y)}))
// Coefficients are negated for maximisation
fmt.Printf("%.0f %.0f %t\n", p.Objective.AtVec(0), p.Objective.AtVec(1), p.ObjectiveIsNegated)
// Output: -2 3 true
}

func ExampleLinearProgram_AddConstraint() {
x := NewVariable("x")
y := NewVariable("y")
p := NewLinearProgram("c", []LpVariable{x, y})
p.AddObjective(LpMinimise, NewExpression([]LpTerm{NewTerm(1, x), NewTerm(1, y)}))
p.AddConstraint(NewExpression([]LpTerm{NewTerm(1, x), NewTerm(2, y)}), LpConstraintGE, 3)
// Constraint row: 1 2 -1 (surplus) and objective expanded to length 3
fmt.Printf("%.0f %.0f %.0f %d\n", p.Constraints.At(0, 0), p.Constraints.At(0, 1), p.Constraints.At(0, 2), p.Objective.Len())
// Output: 1 2 -1 3
}
13 changes: 11 additions & 2 deletions lp/formulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import (
"gonum.org/v1/gonum/mat"
)

// AddObjective adds a new objective function to the LP model.
// AddObjective sets the objective function of the [LinearProgram].
// The provided expression defines the coefficients; sense selects [LpMinimise]
// or [LpMaximise]. If [LpMaximise] is used coefficients are negated to convert
// the problem to the solver's minimisation form and [LinearProgram].ObjectiveIsNegated
// is set to avoid double-negation.
func (lp *LinearProgram) AddObjective(sense LpSense, expr LpExpression) {
lp.Sense = sense

Expand Down Expand Up @@ -45,7 +49,12 @@ func (lp *LinearProgram) AddObjective(sense LpSense, expr LpExpression) {
}
}

// AddConstraint adds a new constraint to the LP model.
// AddConstraint appends a constraint to the [LinearProgram].
// expr is the left-hand side, conType is one of [LpConstraintLE], [LpConstraintEQ]
// or [LpConstraintGE], and rhs is the right-hand side. The objective must be
// defined before adding constraints. If rhs is negative the constraint is
// inverted. For LE/GE constraints a slack or surplus variable is created and
// the program matrices and objective are expanded.
func (lp *LinearProgram) AddConstraint(expr LpExpression, conType LpConstraintType, rhs float64) {
if lp.Objective == nil {
panic("objective function must be defined before adding constraints")
Expand Down
3 changes: 2 additions & 1 deletion lp/lp.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ type LinearProgram struct {
ObjectiveIsNegated bool
}

// NewLinearProgram Create a new Linear Program
// NewLinearProgram creates a new [LinearProgram] with the provided description and variables.
// The returned program's Status is initialized to [common.SolverStatusNotSolved].
func NewLinearProgram(desc string, vars []LpVariable) LinearProgram {

lp := LinearProgram{
Expand Down
26 changes: 16 additions & 10 deletions lp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,38 @@ package lp

import "github.com/chriso345/gspl/internal/common"

// LpExpression represents the LHS of a linear expression
// LpExpression represents the left-hand side of a linear expression and
// holds a slice of [LpTerm].
type LpExpression struct {
Terms []LpTerm
}

// NewExpression creates a new LpExpression with the given terms
// NewExpression creates a new [LpExpression] with the given terms.
func NewExpression(terms []LpTerm) LpExpression {
return LpExpression{terms}
}

// LpTerm represents a term in a linear expression, consisting of a coefficient and a variable.
// LpTerm represents a term in a linear expression, consisting of a coefficient and a [LpVariable].
type LpTerm struct {
Coefficient float64
Variable LpVariable // These get added to the variable list in the LinearProgram??
}

// NewTerm creates a new LpTerm with the given coefficient and variable.
// NewTerm returns a new [LpTerm] with the given coefficient and [LpVariable].
func NewTerm(coefficient float64, variable LpVariable) LpTerm {
return LpTerm{coefficient, variable}
}

// LpVariable represents a variable in a linear programming problem.
// LpVariable represents a variable in a linear programming problem and
// includes metadata such as slack/artificial flags and its [LpCategory].
type LpVariable struct {
Name string
IsSlack bool
IsArtificial bool
Category LpCategory
}

// NewVariable creates a new LpVariable with the given name.
// NewVariable creates a new [LpVariable] with the given name and optional [LpCategory].
func NewVariable(name string, category ...LpCategory) LpVariable {
if len(category) > 1 {
panic("Only one LpCategory can be specified for a variable")
Expand All @@ -42,7 +44,8 @@ func NewVariable(name string, category ...LpCategory) LpVariable {
return LpVariable{name, false, false, category[0]}
}

// LpCategory represents the category of a variable in a linear programming problem.
// LpCategory is an alias for [common.VarCategory] and classifies variables
// (continuous, integer, binary).
type LpCategory = common.VarCategory

const (
Expand All @@ -51,15 +54,17 @@ const (
LpCategoryBinary
)

// LpSense represents the sense of the linear programming problem, either minimization or maximization.
// LpSense represents the problem sense for a [LinearProgram].
// Possible values are [LpMinimise] and [LpMaximise].
type LpSense int

const (
LpMinimise LpSense = iota
LpMaximise
)

// LpStatus represents the current status of solving the linear programming problem.
// LpStatus represents the current status of solving a [LinearProgram].
// Values include [LpStatusNotSolved], [LpStatusOptimal], [LpStatusInfeasible], and [LpStatusUnbounded].
type LpStatus int

const (
Expand All @@ -79,7 +84,8 @@ func (s LpStatus) String() string {
}[s]
}

// LpConstraintType represents the type of a constraint in a linear programming problem.
// LpConstraintType represents the relation used in a constraint (<=, =, >=).
// Constants are [LpConstraintLE], [LpConstraintEQ], and [LpConstraintGE].
type LpConstraintType int

const (
Expand Down
Loading