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
23 changes: 21 additions & 2 deletions cmd/gspl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import (
"context"
"fmt"
"os"
"slices"

"github.com/chriso345/gspl/internal/lang"
"github.com/chriso345/gspl/internal/lang/ast"
_ "github.com/chriso345/gspl/internal/lang/gmpl"
"github.com/chriso345/gspl/solver"

_ "github.com/chriso345/gspl/internal/lang/gmpl" // Required to register GMPL language
_ "github.com/chriso345/gspl/internal/lang/mps" // Required to register MPS language
)

var supportedLangs = []string{"gmpl", "mps"}

func exit(code int, err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
Expand All @@ -25,7 +30,21 @@ func main() {
path := args.Run.File.Value
fmt.Println("Running file:", path)
ctx := context.Background()
node, err := lang.ParseFile(ctx, "gmpl", path)

// Get the language from the file extension
ext := ""
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '.' {
ext = path[i+1:]
break
}
}

if !slices.Contains(supportedLangs, ext) {
exit(1, fmt.Errorf("unsupported file extension: %q", ext))
}

node, err := lang.ParseFile(ctx, ext, path)
if err != nil {
exit(1, err)
}
Expand Down
27 changes: 26 additions & 1 deletion internal/lang/gmpl/gmpl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import (

"github.com/chriso345/gspl/internal/lang/ast"
"github.com/chriso345/gspl/lp"
"github.com/chriso345/gspl/solver"
)

func TestParseExampleFile(t *testing.T) {
func TestParseGMPLExampleFile(t *testing.T) {
b, err := os.ReadFile("../testdata/example.gmpl")
if err != nil {
t.Fatalf("read example: %v", err)
Expand Down Expand Up @@ -40,6 +41,30 @@ func TestParseExampleFile(t *testing.T) {
if m.LP.Sense != lp.LpMaximise {
t.Fatalf("expected maximise sense, got %v", m.LP.Sense)
}

// Solve and validate optimal solution
sol, err := solver.Solve(m.LP)
if err != nil {
t.Fatalf("solve: %v", err)
}
if sol == nil {
t.Fatalf("expected non-nil solution")
}
// Expect x1=x2=4.5 and objective 22.5
if sol.PrimalSolution.Len() < 2 {
t.Fatalf("unexpected solution length: %d", sol.PrimalSolution.Len())
}
x1 := sol.PrimalSolution.AtVec(0)
x2 := sol.PrimalSolution.AtVec(1)
if diff := x1 - 4.5; diff < -1e-6 || diff > 1e-6 {
t.Fatalf("unexpected x1: %v", x1)
}
if diff := x2 - 4.5; diff < -1e-6 || diff > 1e-6 {
t.Fatalf("unexpected x2: %v", x2)
}
if diff := sol.ObjectiveValue - 22.5; diff < -1e-6 || diff > 1e-6 {
t.Fatalf("unexpected objective: %v", sol.ObjectiveValue)
}
}

func TestParseExpressionInvalidCoefficient(t *testing.T) {
Expand Down
216 changes: 216 additions & 0 deletions internal/lang/mps/mps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package mps

import (
"bufio"
"context"
"io"
"strconv"
"strings"

"github.com/chriso345/gspl/internal/lang"
"github.com/chriso345/gspl/internal/lang/ast"
"github.com/chriso345/gspl/lp"
)

// MPSLanguage provides a tiny MPS parser sufficient for the repository examples.
type MPSLanguage struct{}

func (m *MPSLanguage) Name() string { return "mps" }

func parseMPSToLP(r io.Reader, filename string) (*lp.LinearProgram, error) {
s := bufio.NewScanner(r)
rows := map[string]rune{} // name -> type (N,L,G,E)
cols := map[string]map[string]float64{} // var -> row -> coef
rhs := map[string]map[string]float64{} // rhsname -> row -> value
bounds := []struct{ typ, varname string; val float64 }{}
var orderVars []string
desc := filename
section := ""
for s.Scan() {
line := s.Text()
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Fields(line)
if len(f) == 0 {
continue
}
switch strings.ToUpper(f[0]) {
case "NAME":
if len(f) > 1 {
desc = strings.Join(f[1:], " ")
}
case "ROWS":
section = "ROWS"
continue
case "COLUMNS":
section = "COLUMNS"
continue
case "RHS":
section = "RHS"
continue
case "BOUNDS":
section = "BOUNDS"
continue
case "ENDATA":
section = ""
continue
}

switch section {
case "ROWS":
// format: type name
if len(f) >= 2 {
typ := strings.ToUpper(f[0])
name := f[1]
if typ == "N" || typ == "L" || typ == "G" || typ == "E" {
rows[name] = rune(typ[0])
}
}
case "COLUMNS":
// format: var row val [row val]
if len(f) >= 3 {
varname := f[0]
if _, ok := cols[varname]; !ok {
cols[varname] = map[string]float64{}
orderVars = append(orderVars, varname)
}
for i := 1; i+1 < len(f); i += 2 {
rowname := f[i]
valStr := f[i+1]
val, err := strconv.ParseFloat(valStr, 64)
if err != nil {
continue
}
cols[varname][rowname] = val
}
}
case "RHS":
// format: name row val [row val]
if len(f) >= 3 {
name := f[0]
if _, ok := rhs[name]; !ok {
rhs[name] = map[string]float64{}
}
for i := 1; i+1 < len(f); i += 2 {
row := f[i]
valStr := f[i+1]
val, err := strconv.ParseFloat(valStr, 64)
if err != nil {
continue
}
rhs[name][row] = val
}
}
case "BOUNDS":
// format: typ bname var val
if len(f) >= 4 {
typ := f[0]
varname := f[2]
valStr := f[3]
val, err := strconv.ParseFloat(valStr, 64)
if err == nil {
bounds = append(bounds, struct{ typ, varname string; val float64 }{typ, varname, val})
}
}
default:
// ignore
}
}
if err := s.Err(); err != nil {
return nil, err
}

// build vars
vars := []lp.LpVariable{}
for _, v := range orderVars {
vars = append(vars, lp.NewVariable(v))
}
lprog := lp.NewLinearProgram(desc, vars)

// objective: find row with type N
objRow := ""
for name, t := range rows {
if t == 'N' {
objRow = name
break
}
}
if objRow != "" {
terms := []lp.LpTerm{}
for _, v := range vars {
if coef, ok := cols[v.Name][objRow]; ok {
terms = append(terms, lp.NewTerm(coef, lp.NewVariable(v.Name)))
}
}
// MPS problems are traditionally minimisation by default
lprog.AddObjective(lp.LpMinimise, lp.NewExpression(terms))
}

// constraints: rows with L,G,E
for name, t := range rows {
if t == 'N' {
continue
}
terms := []lp.LpTerm{}
for _, v := range vars {
if coef, ok := cols[v.Name][name]; ok {
terms = append(terms, lp.NewTerm(coef, lp.NewVariable(v.Name)))
}
}
// rhs take first rhs map entry if present
rhsVal := 0.0
for _, m := range rhs {
if val, ok := m[name]; ok {
rhsVal = val
break
}
}
var ctype lp.LpConstraintType
switch t {
case 'L':
ctype = lp.LpConstraintLE
case 'G':
ctype = lp.LpConstraintGE
case 'E':
ctype = lp.LpConstraintEQ
}
lprog.AddConstraint(lp.NewExpression(terms), ctype, rhsVal)
}

// bounds: implement simple UP and LO parsing from cols of bounds section
// not implemented in depth; use defaults present in example
// scan again to pick up bounds (cheap approach)
if len(bounds) > 0 {
for _, b := range bounds {
switch strings.ToUpper(b.typ) {
case "UP":
// set upper bound by adding constraint var <= val
terms := []lp.LpTerm{{Coefficient: 1, Variable: lp.NewVariable(b.varname)}}
lprog.AddConstraint(lp.NewExpression(terms), lp.LpConstraintLE, b.val)
case "LO":
// set lower bound by adding constraint var >= val
terms := []lp.LpTerm{{Coefficient: 1, Variable: lp.NewVariable(b.varname)}}
lprog.AddConstraint(lp.NewExpression(terms), lp.LpConstraintGE, b.val)
}
}
}

return &lprog, nil
}

func (m *MPSLanguage) Parse(ctx context.Context, src io.Reader, opts ...lang.ParseOption) (ast.Node, error) {
filename := ""
if f, ok := src.(interface{ Name() string }); ok {
filename = f.Name()
}
lpProg, err := parseMPSToLP(src, filename)
if err != nil {
return nil, err
}
return &ast.Module{LP: lpProg, Name: lpProg.Description}, nil
}

func New() lang.Language { return &MPSLanguage{} }

func init() { lang.MustRegisterLanguage(New()) }
68 changes: 68 additions & 0 deletions internal/lang/mps/mps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package mps

import (
"context"
"os"
"strings"
"testing"

"github.com/chriso345/gspl/internal/lang/ast"
"github.com/chriso345/gspl/lp"
"github.com/chriso345/gspl/solver"
)

func TestParseExampleFile(t *testing.T) {
b, err := os.ReadFile("../testdata/example.mps")
if err != nil {
t.Fatalf("read example: %v", err)
}

p := New()
node, err := p.Parse(context.Background(), strings.NewReader(string(b)))
if err != nil {
t.Fatalf("parse: %v", err)
}
m, ok := node.(*ast.Module)
if !ok {
t.Fatalf("unexpected node type: %T", node)
}
if m.LP == nil {
t.Fatalf("expected LP to be non-nil")
}
// ensure primary variables exist
found := map[string]bool{}
for _, v := range m.LP.Vars {
found[v.Name] = true
}
if !found["X1"] || !found["X2"] {
t.Fatalf("expected X1 and X2 to be present, vars: %v", m.LP.Vars)
}
// objective should be minimise for MPS default
if m.LP.Sense != lp.LpMinimise {
t.Fatalf("expected minimise sense, got %v", m.LP.Sense)
}

// Solve and validate optimal solution
sol, err := solver.Solve(m.LP)
if err != nil {
t.Fatalf("solve: %v", err)
}
if sol == nil {
t.Fatalf("expected non-nil solution")
}
// For the example MPS: expect x1=0 (bounded by up 4, but objective min -> choose 0) and x2=10 (from LIM2 >=10)
if sol.PrimalSolution.Len() < 2 {
t.Fatalf("unexpected solution length: %d", sol.PrimalSolution.Len())
}
x1 := sol.PrimalSolution.AtVec(0)
x2 := sol.PrimalSolution.AtVec(1)
if diff := x1 - 0.0; diff < -1e-6 || diff > 1e-6 {
t.Fatalf("unexpected X1: %v", x1)
}
if diff := x2 - 10.0; diff < -1e-6 || diff > 1e-6 {
t.Fatalf("unexpected X2: %v", x2)
}
if diff := sol.ObjectiveValue - 20.0; diff < -1e-6 || diff > 1e-6 {
t.Fatalf("unexpected objective: %v", sol.ObjectiveValue)
}
}
17 changes: 17 additions & 0 deletions internal/lang/testdata/example.mps
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
NAME SAMPLE
ROWS
N COST
L LIM1
G LIM2
COLUMNS
X1 COST 1
X1 LIM1 1
X2 COST 2
X2 LIM2 1
RHS
RHS1 LIM1 5
RHS1 LIM2 10
BOUNDS
UP BOUND1 X1 4
LO BOUND1 X2 0
ENDATA
Loading