lpsolver 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +72 -13
- data/ext/lpsolver/Makefile +8 -12
- data/ext/lpsolver/ext.o +0 -0
- data/ext/lpsolver/extconf.rb +11 -8
- data/ext/lpsolver/native.so +0 -0
- data/lib/lpsolver/drivers/README.md +91 -0
- data/lib/lpsolver/drivers/cli_driver.rb +123 -0
- data/lib/lpsolver/drivers/native_driver.rb +150 -0
- data/lib/lpsolver/drivers.rb +9 -0
- data/lib/lpsolver/lp_generator.rb +200 -0
- data/lib/lpsolver/model.rb +105 -406
- data/lib/lpsolver/native.so +0 -0
- data/lib/lpsolver/solution.rb +21 -1
- data/lib/lpsolver/version.rb +1 -1
- data/lib/lpsolver.rb +10 -4
- metadata +6 -2
- data/lib/lpsolver/native_model.rb +0 -261
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d76ac229fc048afc42cb81ae328ac716095d43767fb8329ae6cdc8d8a69f8f14
|
|
4
|
+
data.tar.gz: 76b6e5e26102cc8ad5cd8fbc9da236a6b5bdd7496c5099083d0ea750d27e9000
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c969d6289498dfa06bb0ed44633fa0ea0f9100aa50ec9ec3be9a1a9e11c2f2c27ad2978c7d7f802e3834692edc9fe9802742035e3847249c8bb726112a25b769
|
|
7
|
+
data.tar.gz: 45f270ac494d18748344cbc446ebf591f28af625341f424649e7f2452c880d8478bb8bde1ce7dc0ea2805cbb909e5d796dbbe5df60506e9d25e9b04e96505085
|
data/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# LpSolver
|
|
2
2
|
|
|
3
|
+
[](https://github.com/davidsiaw/lpsolver/actions/workflows/test.yml)
|
|
4
|
+
|
|
3
5
|
A Ruby gem for solving optimization problems using the [HiGHS](https://github.com/ERGO-Code/HiGHS) solver.
|
|
4
6
|
|
|
7
|
+
Reference documentation can be found at https://davidsiaw.github.io/lpsolver/
|
|
8
|
+
|
|
5
9
|
## What is this for?
|
|
6
10
|
|
|
7
11
|
Imagine you want to **maximize profit** or **minimize cost** while following certain rules (like a budget limit or a minimum requirement). This gem helps you find the best answer.
|
|
@@ -111,6 +115,32 @@ ruby -Ilib examples/coin_purse.rb
|
|
|
111
115
|
|
|
112
116
|
> **HiGHS is bundled automatically.** The `rake compile` task (run during `bundle install` or `rake`) downloads the HiGHS v1.14.0 precompiled static library from GitHub and links it into the gem. No system-level HiGHS installation is required — it ships with the gem.
|
|
113
117
|
|
|
118
|
+
## Solvers
|
|
119
|
+
|
|
120
|
+
The Model class uses a pluggable driver architecture. By default it uses the **CLI driver** (HiGHS subprocess), but you can switch to the **native driver** (C extension) for direct in-process solving.
|
|
121
|
+
|
|
122
|
+
### CLI Driver (default)
|
|
123
|
+
|
|
124
|
+
The CLI driver serializes the model to HiGHS LP format and invokes the HiGHS binary as a subprocess. This is the default and most reliable approach.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
model = LpSolver::Model.new
|
|
128
|
+
# Uses CliDriver by default
|
|
129
|
+
solution = model.minimize!(x * 3 + y * 5)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Native Driver
|
|
133
|
+
|
|
134
|
+
The native driver calls the HiGHS C extension directly, bypassing file serialization. It requires the native extension to be compiled (`rake compile`).
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
model = LpSolver::Model.new
|
|
138
|
+
model.driver = LpSolver::NativeDriver.new
|
|
139
|
+
solution = model.minimize!(x * 3 + y * 5)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
> **Note:** The native driver is currently experimental. The CLI driver is recommended for production use.
|
|
143
|
+
|
|
114
144
|
## Usage
|
|
115
145
|
|
|
116
146
|
### Simple Example (Operator DSL)
|
|
@@ -190,6 +220,32 @@ solution = model.maximize!(x * 3 + y * 5)
|
|
|
190
220
|
puts solution.objective_value # => 50.0
|
|
191
221
|
```
|
|
192
222
|
|
|
223
|
+
### Infeasible and Unbounded Solutions
|
|
224
|
+
|
|
225
|
+
Not all problems have a valid solution. Always check the status before accessing values:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
model = LpSolver::Model.new
|
|
229
|
+
x = model.add_variable(:x, lb: 0)
|
|
230
|
+
y = model.add_variable(:y, lb: 0)
|
|
231
|
+
|
|
232
|
+
# Contradictory constraints — no solution exists
|
|
233
|
+
model.add_constraint(:c1, (x + y) <= 2)
|
|
234
|
+
model.add_constraint(:c2, (x + y) >= 5)
|
|
235
|
+
|
|
236
|
+
solution = model.minimize!(x + y)
|
|
237
|
+
|
|
238
|
+
if solution.infeasible?
|
|
239
|
+
puts "No feasible solution exists"
|
|
240
|
+
elsif solution.unbounded?
|
|
241
|
+
puts "The objective can grow without limit"
|
|
242
|
+
else
|
|
243
|
+
puts "Optimal value: #{solution.objective_value}"
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
> **Important:** When a solution is infeasible, `objective_value` returns `0.0` and `variables` is empty. Always call `solution.infeasible?` or `solution.unbounded?` first before reading `objective_value` or variable values.
|
|
248
|
+
|
|
193
249
|
### Complex Expressions
|
|
194
250
|
|
|
195
251
|
You can chain operators with constants and unary minus:
|
|
@@ -218,7 +274,7 @@ x = model.add_variable(:x, lb: 0)
|
|
|
218
274
|
y = model.add_variable(:y, lb: 0)
|
|
219
275
|
|
|
220
276
|
model.add_constraint(:c1, (x * 2 + y) <= 10)
|
|
221
|
-
model.
|
|
277
|
+
model.minimize!(x + y)
|
|
222
278
|
|
|
223
279
|
# Print the LP file format
|
|
224
280
|
puts model.to_lp
|
|
@@ -283,24 +339,23 @@ Set the objective to minimize and solve in one call.
|
|
|
283
339
|
### `Model#maximize!(objective)`
|
|
284
340
|
Set the objective to maximize and solve in one call.
|
|
285
341
|
|
|
286
|
-
### `Model#minimize`
|
|
287
|
-
Set the optimization sense to minimization (legacy, use `minimize!` instead).
|
|
288
|
-
|
|
289
|
-
### `Model#maximize`
|
|
290
|
-
Set the optimization sense to maximization (legacy, use `maximize!` instead).
|
|
291
|
-
|
|
292
|
-
### `Model#set_objective(objective)`
|
|
293
|
-
Set the objective function without solving (legacy, use `minimize!`/`maximize!` instead).
|
|
294
|
-
- `objective` can be a `LinearExpression`, `QuadraticExpression`, or Hash.
|
|
295
|
-
|
|
296
342
|
### `Model#to_lp`
|
|
297
343
|
Returns the model as a HiGHS LP format string.
|
|
298
344
|
|
|
299
345
|
### `Model#write_lp(filename)`
|
|
300
346
|
Writes the model to an LP file.
|
|
301
347
|
|
|
302
|
-
### `Model#
|
|
303
|
-
|
|
348
|
+
### `Model#driver`
|
|
349
|
+
Returns the current solver driver.
|
|
350
|
+
|
|
351
|
+
### `Model#driver=(driver)`
|
|
352
|
+
Sets the solver driver. Accepts `LpSolver::CliDriver` (default) or `LpSolver::NativeDriver`.
|
|
353
|
+
|
|
354
|
+
### `LpSolver::CliDriver.new(highs_path: nil)`
|
|
355
|
+
Creates a CLI driver. Uses `Model::HIGHS_PATH` by default, or the provided path.
|
|
356
|
+
|
|
357
|
+
### `LpSolver::NativeDriver.new`
|
|
358
|
+
Creates a native driver. Requires the native extension to be compiled.
|
|
304
359
|
|
|
305
360
|
### `Solution#[]`
|
|
306
361
|
Get a variable's value. Accepts Symbol, String, or Variable object.
|
|
@@ -387,3 +442,7 @@ True if the objective can improve without limit.
|
|
|
387
442
|
## Code of Conduct
|
|
388
443
|
|
|
389
444
|
See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
445
|
+
|
|
446
|
+
# Disclaimer
|
|
447
|
+
|
|
448
|
+
Note that this is vibe-coded in nearly its entirety as part of a quick experiment to improve tokens per sec on my local machine and I do not recommend it for use in any sensitive environments. Note that I do not accept any responsibility for any damages or losses caused by the use of this gem, and I still expect contributed PRs to be provided in sensible and human-reviewable doses.
|
data/ext/lpsolver/Makefile
CHANGED
|
@@ -13,12 +13,12 @@ NULLCMD = :
|
|
|
13
13
|
#### Start of system configuration section. ####
|
|
14
14
|
|
|
15
15
|
srcdir = .
|
|
16
|
-
topdir = /
|
|
16
|
+
topdir = /home/node/.rvm/rubies/ruby-3.3.7/include/ruby-3.3.0
|
|
17
17
|
hdrdir = $(topdir)
|
|
18
|
-
arch_hdrdir = /
|
|
18
|
+
arch_hdrdir = /home/node/.rvm/rubies/ruby-3.3.7/include/ruby-3.3.0/x86_64-linux
|
|
19
19
|
PATH_SEPARATOR = :
|
|
20
20
|
VPATH = $(srcdir):$(arch_hdrdir)/ruby:$(hdrdir)/ruby
|
|
21
|
-
prefix = $(DESTDIR)/
|
|
21
|
+
prefix = $(DESTDIR)/home/node/.rvm/rubies/ruby-3.3.7
|
|
22
22
|
rubysitearchprefix = $(rubylibprefix)/$(sitearch)
|
|
23
23
|
rubyarchprefix = $(rubylibprefix)/$(arch)
|
|
24
24
|
rubylibprefix = $(libdir)/$(RUBY_BASE_NAME)
|
|
@@ -42,7 +42,6 @@ archincludedir = $(includedir)/$(arch)
|
|
|
42
42
|
sitearchlibdir = $(libdir)/$(sitearch)
|
|
43
43
|
archlibdir = $(libdir)/$(arch)
|
|
44
44
|
ridir = $(datarootdir)/$(RI_BASE_NAME)
|
|
45
|
-
modular_gc_dir = $(DESTDIR)
|
|
46
45
|
mandir = $(datarootdir)/man
|
|
47
46
|
localedir = $(datarootdir)/locale
|
|
48
47
|
libdir = $(exec_prefix)/lib
|
|
@@ -79,7 +78,7 @@ COUTFLAG = -o $(empty)
|
|
|
79
78
|
CSRCFLAG = $(empty)
|
|
80
79
|
|
|
81
80
|
RUBY_EXTCONF_H =
|
|
82
|
-
cflags = $(
|
|
81
|
+
cflags = $(optflags) $(debugflags) $(warnflags)
|
|
83
82
|
cxxflags =
|
|
84
83
|
optflags = -O3 -fno-fast-math
|
|
85
84
|
debugflags = -ggdb3
|
|
@@ -87,19 +86,17 @@ warnflags = -Wall -Wextra -Wdeprecated-declarations -Wdiv-by-zero -Wduplicated-c
|
|
|
87
86
|
cppflags =
|
|
88
87
|
CCDLFLAGS = -fPIC
|
|
89
88
|
CFLAGS = $(CCDLFLAGS) $(cflags) -fPIC $(ARCH_FLAG)
|
|
90
|
-
INCFLAGS = -I. -I$(arch_hdrdir) -I$(hdrdir)/ruby/backward -I$(hdrdir) -I$(srcdir) -I/
|
|
89
|
+
INCFLAGS = -I. -I$(arch_hdrdir) -I$(hdrdir)/ruby/backward -I$(hdrdir) -I$(srcdir) -I/workspace/lpsolver/ext/lpsolver-highs/build/include/highs
|
|
91
90
|
DEFS =
|
|
92
91
|
CPPFLAGS = $(DEFS) $(cppflags)
|
|
93
92
|
CXXFLAGS = $(CCDLFLAGS) $(ARCH_FLAG)
|
|
94
|
-
ldflags = -L. -fstack-protector-strong -rdynamic -Wl,-export-dynamic -Wl,--no-as-needed -L/
|
|
93
|
+
ldflags = -L. -fstack-protector-strong -rdynamic -Wl,-export-dynamic -Wl,--no-as-needed -L/workspace/lpsolver/ext/lpsolver-highs/build/lib
|
|
95
94
|
dldflags = -Wl,--compress-debug-sections=zlib
|
|
96
95
|
ARCH_FLAG =
|
|
97
96
|
DLDFLAGS = $(ldflags) $(dldflags) $(ARCH_FLAG)
|
|
98
97
|
LDSHARED = $(CC) -shared
|
|
99
98
|
LDSHAREDXX = $(CXX) -shared
|
|
100
|
-
POSTLINK = :
|
|
101
99
|
AR = gcc-ar
|
|
102
|
-
LD = ld
|
|
103
100
|
EXEEXT =
|
|
104
101
|
|
|
105
102
|
RUBY_INSTALL_NAME = $(RUBY_BASE_NAME)
|
|
@@ -111,7 +108,7 @@ RUBY_BASE_NAME = ruby
|
|
|
111
108
|
|
|
112
109
|
arch = x86_64-linux
|
|
113
110
|
sitearch = $(arch)
|
|
114
|
-
ruby_version = 3.
|
|
111
|
+
ruby_version = 3.3.0
|
|
115
112
|
ruby = $(bindir)/$(RUBY_BASE_NAME)
|
|
116
113
|
RUBY = $(ruby)
|
|
117
114
|
BUILTRUBY = $(bindir)/$(RUBY_BASE_NAME)
|
|
@@ -131,7 +128,7 @@ TOUCH = exit >
|
|
|
131
128
|
|
|
132
129
|
preload =
|
|
133
130
|
libpath = . $(libdir)
|
|
134
|
-
LIBPATH =
|
|
131
|
+
LIBPATH = -L. -L$(libdir) -Wl,-rpath,$(libdir)
|
|
135
132
|
DEFFILE =
|
|
136
133
|
|
|
137
134
|
CLEANFILES = mkmf.log
|
|
@@ -266,7 +263,6 @@ $(TARGET_SO): $(OBJS) Makefile
|
|
|
266
263
|
$(ECHO) linking shared-object lpsolver/$(DLLIB)
|
|
267
264
|
-$(Q)$(RM) $(@)
|
|
268
265
|
$(Q) $(LDSHARED) -o $@ $(OBJS) $(LIBPATH) $(DLDFLAGS) $(LOCAL_LIBS) $(LIBS)
|
|
269
|
-
$(Q) $(POSTLINK)
|
|
270
266
|
|
|
271
267
|
|
|
272
268
|
|
data/ext/lpsolver/ext.o
CHANGED
|
Binary file
|
data/ext/lpsolver/extconf.rb
CHANGED
|
@@ -11,6 +11,8 @@ highs_build = File.join(highs_src, 'build')
|
|
|
11
11
|
|
|
12
12
|
highs_found = false
|
|
13
13
|
|
|
14
|
+
|
|
15
|
+
|
|
14
16
|
# Priority: 1. HIGHS_DIR env var, 2. submodule build, 3. system paths
|
|
15
17
|
highs_dir = ENV.fetch('HIGHS_DIR', nil)
|
|
16
18
|
|
|
@@ -32,23 +34,24 @@ unless highs_found
|
|
|
32
34
|
# Check bundled submodule (CMake build or precompiled tarball)
|
|
33
35
|
if File.exist?(File.join(highs_build, 'lib', 'libhighs.so')) ||
|
|
34
36
|
File.exist?(File.join(highs_build, 'lib', 'libhighs.a'))
|
|
35
|
-
highs_include = File.join(highs_src, 'highs')
|
|
36
37
|
highs_lib = File.join(highs_build, 'lib')
|
|
37
38
|
|
|
38
|
-
# CMake build: HConfig.h is in build dir
|
|
39
|
-
# Precompiled tarball: HConfig.h is in build/include/highs/
|
|
39
|
+
# CMake build: HConfig.h is in build dir, headers in highs/ subdir
|
|
40
|
+
# Precompiled tarball: HConfig.h is in build/include/highs/, headers in include/highs/
|
|
40
41
|
highs_config = File.join(highs_build, 'HConfig.h')
|
|
41
42
|
if File.exist?(highs_config)
|
|
43
|
+
# CMake build layout
|
|
44
|
+
highs_include = File.join(highs_src, 'highs')
|
|
42
45
|
$INCFLAGS += " -I#{highs_include} -I#{highs_build}"
|
|
43
46
|
else
|
|
44
|
-
# Precompiled tarball layout
|
|
47
|
+
# Precompiled tarball layout: headers under include/highs/
|
|
45
48
|
highs_config_inc = File.join(highs_build, 'include', 'highs')
|
|
46
49
|
if File.exist?(File.join(highs_config_inc, 'HConfig.h'))
|
|
47
|
-
$INCFLAGS += " -I#{
|
|
50
|
+
$INCFLAGS += " -I#{highs_config_inc}"
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
$LDFLAGS += " -L#{highs_lib}
|
|
54
|
+
$LDFLAGS += " -L#{highs_lib}"
|
|
52
55
|
$LIBS += " -lhighs -lstdc++"
|
|
53
56
|
$LOAD_PATH << highs_lib
|
|
54
57
|
highs_found = true
|
|
@@ -70,10 +73,10 @@ abort <<~ERROR unless highs_found
|
|
|
70
73
|
Options:
|
|
71
74
|
1. Build from submodule: rake build_highs && rake compile
|
|
72
75
|
2. Set HIGHS_DIR=/path/to/highs
|
|
73
|
-
3. Install system-wide: sudo apt install highs
|
|
76
|
+
3. Install system-wide: brew install highs (macOS) or sudo apt install highs (Linux)
|
|
74
77
|
|
|
75
78
|
Submodule location: #{highs_src}
|
|
76
|
-
Build output: #{highs_build}/lib/libhighs.
|
|
79
|
+
Build output: #{highs_build}/lib/libhighs.a
|
|
77
80
|
ERROR
|
|
78
81
|
|
|
79
82
|
create_makefile('lpsolver/native')
|
data/ext/lpsolver/native.so
CHANGED
|
Binary file
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# LpSolver Drivers
|
|
2
|
+
|
|
3
|
+
Pluggable solving backends for `LpSolver::Model`.
|
|
4
|
+
|
|
5
|
+
## API Contract
|
|
6
|
+
|
|
7
|
+
Every driver must implement the following interface:
|
|
8
|
+
|
|
9
|
+
### `#initialize(opts = {})`
|
|
10
|
+
|
|
11
|
+
Optional constructor. Drivers may accept configuration options.
|
|
12
|
+
|
|
13
|
+
| Driver | Options |
|
|
14
|
+
|--------|---------|
|
|
15
|
+
| `CliDriver` | `highs_path: String` — path to HiGHS binary (uses `HIGHS_PATH` resolution if omitted) |
|
|
16
|
+
| `NativeDriver` | None |
|
|
17
|
+
|
|
18
|
+
### `#solve(model) → Solution`
|
|
19
|
+
|
|
20
|
+
Solves the given model and returns a `Solution` object.
|
|
21
|
+
|
|
22
|
+
**Parameters:**
|
|
23
|
+
- `model` (`LpSolver::Model`) — the model to solve
|
|
24
|
+
|
|
25
|
+
**Returns:**
|
|
26
|
+
- `LpSolver::Solution` — contains variable values, objective value, and model status
|
|
27
|
+
|
|
28
|
+
**Raises:**
|
|
29
|
+
- `LpSolver::SolverError` — if the solver encounters an error (e.g., HiGHS binary not found)
|
|
30
|
+
- `LoadError` — if required dependencies are missing (e.g., native extension not compiled)
|
|
31
|
+
|
|
32
|
+
## Model Data Access
|
|
33
|
+
|
|
34
|
+
Drivers read model state via the following `Model` accessors:
|
|
35
|
+
|
|
36
|
+
| Accessor | Type | Description |
|
|
37
|
+
|----------|------|-------------|
|
|
38
|
+
| `model.var_counter` | `Integer` | Number of variables |
|
|
39
|
+
| `model.heading` | `Symbol` | `:minimize` or `:maximize` |
|
|
40
|
+
| `model.constraints` | `Array<Hash>` | Constraint data: `{ name:, lb:, ub:, expr: [[col, coeff], ...] }` |
|
|
41
|
+
| `model.constraints_data` | `Hash` | Named constraint map |
|
|
42
|
+
| `model.var_types` | `Hash{Symbol => Symbol}` | Variable types: `:continuous` or `:integer` |
|
|
43
|
+
| `model.objective` | `Hash{Integer => Float}` | Objective coefficients by variable index |
|
|
44
|
+
| `model.quadratic_terms` | `Array<[Integer, Integer, Float]>` | Quadratic terms: `[col1, col2, coeff]` |
|
|
45
|
+
| `model.var_bounds` | `Hash{Symbol => [Float, Float]}` | Variable bounds: `[lb, ub]` |
|
|
46
|
+
| `model.variables` | `Hash{Symbol => Variable}` | Variable map (name → Variable object) |
|
|
47
|
+
|
|
48
|
+
## Writing a Custom Driver
|
|
49
|
+
|
|
50
|
+
To create a new driver:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
require 'lpsolver/drivers'
|
|
54
|
+
|
|
55
|
+
module LpSolver
|
|
56
|
+
module Drivers
|
|
57
|
+
class MyCustomDriver
|
|
58
|
+
def initialize(**opts)
|
|
59
|
+
# driver-specific setup
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def solve(model)
|
|
63
|
+
# 1. Read model state via model.var_counter, model.constraints, etc.
|
|
64
|
+
# 2. Call your solver
|
|
65
|
+
# 3. Return a LpSolver::Solution
|
|
66
|
+
LpSolver::Solution.new(
|
|
67
|
+
variables: { 'x' => 1.0, 'y' => 2.0 },
|
|
68
|
+
objective_value: 12.0,
|
|
69
|
+
model_status: 'optimal',
|
|
70
|
+
iterations: 0
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Then use it:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
model = LpSolver::Model.new
|
|
82
|
+
model.driver = LpSolver::Drivers::MyCustomDriver.new
|
|
83
|
+
solution = model.solve
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Available Drivers
|
|
87
|
+
|
|
88
|
+
| Driver | Description | Dependencies |
|
|
89
|
+
|--------|-------------|-------------|
|
|
90
|
+
| `CliDriver` | HiGHS subprocess via LP file | `highs` binary |
|
|
91
|
+
| `NativeDriver` | HiGHS C extension | Compiled native extension (`rake compile`) |
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
module LpSolver
|
|
6
|
+
module Drivers
|
|
7
|
+
# Solves a model using the HiGHS command-line interface.
|
|
8
|
+
#
|
|
9
|
+
# This driver serializes the model to HiGHS LP format, invokes the
|
|
10
|
+
# HiGHS executable as a subprocess, and parses the solution file.
|
|
11
|
+
#
|
|
12
|
+
class CliDriver
|
|
13
|
+
# The path to the HiGHS binary.
|
|
14
|
+
#
|
|
15
|
+
# Resolution order:
|
|
16
|
+
# 1. Path passed to initialize
|
|
17
|
+
# 2. HIGHS_PATH environment variable
|
|
18
|
+
# 3. Bundled binary at lib/lpsolver/highs (from rake compile)
|
|
19
|
+
# 4. 'highs' on system PATH
|
|
20
|
+
#
|
|
21
|
+
# @return [String] The path to the HiGHS executable.
|
|
22
|
+
HIGHS_PATH = begin
|
|
23
|
+
env_path = ENV.fetch('HIGHS_PATH', nil)
|
|
24
|
+
if env_path
|
|
25
|
+
env_path
|
|
26
|
+
else
|
|
27
|
+
bundled = File.expand_path('../../../lib/lpsolver/highs', __dir__)
|
|
28
|
+
if File.exist?(bundled)
|
|
29
|
+
bundled
|
|
30
|
+
else
|
|
31
|
+
'highs'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Creates a new CLI driver.
|
|
37
|
+
#
|
|
38
|
+
# @param highs_path [String, nil] Path to the HiGHS binary.
|
|
39
|
+
# If nil, uses the default resolution order.
|
|
40
|
+
def initialize(highs_path: nil)
|
|
41
|
+
@highs_path = highs_path || HIGHS_PATH
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Solves the model using the HiGHS CLI.
|
|
45
|
+
#
|
|
46
|
+
# @param model [Model] The model to solve.
|
|
47
|
+
# @return [Solution] The solution object.
|
|
48
|
+
# @raise [SolverError] If the HiGHS solver encounters an error.
|
|
49
|
+
def solve(model)
|
|
50
|
+
lp_content = model.to_lp
|
|
51
|
+
lp_file = Tempfile.new(['model', '.lp'])
|
|
52
|
+
lp_file.write(lp_content)
|
|
53
|
+
lp_file.close
|
|
54
|
+
|
|
55
|
+
solution_file = Tempfile.new(['solution', '.sol'])
|
|
56
|
+
opts_file = Tempfile.new(['highs_opts', '.txt'])
|
|
57
|
+
opts_file.write("log_to_console = false\noutput_flag = false\n")
|
|
58
|
+
opts_file.close
|
|
59
|
+
|
|
60
|
+
cmd = "#{@highs_path} " \
|
|
61
|
+
"--model_file #{lp_file.path} " \
|
|
62
|
+
"--options_file #{opts_file.path} " \
|
|
63
|
+
"--solution_file #{solution_file.path}"
|
|
64
|
+
|
|
65
|
+
output = `#{cmd} 2>&1`
|
|
66
|
+
lp_file.unlink
|
|
67
|
+
opts_file.unlink
|
|
68
|
+
|
|
69
|
+
# HiGHS returns non-zero exit code for infeasible/unbounded problems,
|
|
70
|
+
# but still writes a valid solution file. Check for valid status instead.
|
|
71
|
+
solution_content = File.read(solution_file.path)
|
|
72
|
+
status_match = solution_content.match(/Model status\s*\n\s*(\S+)/i)
|
|
73
|
+
unless status_match
|
|
74
|
+
raise SolverError, "HiGHS solver failed:\n#{output}" unless $?.success?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
parse_solution(solution_file.path)
|
|
78
|
+
ensure
|
|
79
|
+
solution_file&.unlink
|
|
80
|
+
opts_file&.unlink
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Parses the HiGHS solution file.
|
|
86
|
+
#
|
|
87
|
+
# @param path [String] The path to the HiGHS solution file.
|
|
88
|
+
# @return [Solution] The parsed solution object.
|
|
89
|
+
def parse_solution(path)
|
|
90
|
+
content = File.read(path)
|
|
91
|
+
variables = {}
|
|
92
|
+
objective_value = 0.0
|
|
93
|
+
model_status = 'unknown'
|
|
94
|
+
|
|
95
|
+
status_match = content.match(/Model status\s*\n\s*(\S+)/i)
|
|
96
|
+
model_status = status_match[1].downcase.gsub('_', ' ') if status_match
|
|
97
|
+
|
|
98
|
+
obj_match = content.match(/Objective\s+(\S+)/i)
|
|
99
|
+
objective_value = obj_match[1].to_f if obj_match
|
|
100
|
+
|
|
101
|
+
in_columns = false
|
|
102
|
+
content.each_line do |line|
|
|
103
|
+
if line =~ /# Columns/i
|
|
104
|
+
in_columns = true
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
next unless in_columns
|
|
108
|
+
break if line.strip.empty? || line.start_with?('#')
|
|
109
|
+
|
|
110
|
+
parts = line.strip.split(/\s+/, 2)
|
|
111
|
+
variables[parts[0]] = parts[1].to_f if parts.length == 2
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Solution.new(
|
|
115
|
+
variables: variables,
|
|
116
|
+
objective_value: objective_value,
|
|
117
|
+
model_status: model_status,
|
|
118
|
+
iterations: 0
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LpSolver
|
|
4
|
+
module Drivers
|
|
5
|
+
# Solves a model using the native C extension.
|
|
6
|
+
#
|
|
7
|
+
# This driver builds HiGHS data structures directly from the model
|
|
8
|
+
# and calls the native C API, bypassing LP file serialization.
|
|
9
|
+
# It requires the native extension to be compiled and loaded.
|
|
10
|
+
#
|
|
11
|
+
class NativeDriver
|
|
12
|
+
# Solves the model using the native C extension.
|
|
13
|
+
#
|
|
14
|
+
# @param model [Model] The model to solve.
|
|
15
|
+
# @return [Solution] The solution object.
|
|
16
|
+
# @raise [LoadError] If the native extension is not available.
|
|
17
|
+
# @raise [SolverError] If the native solver encounters an error.
|
|
18
|
+
def solve(model)
|
|
19
|
+
unless defined?(LpSolver::HiGhSSolver)
|
|
20
|
+
raise LoadError, 'Native extension not available. Compile with: rake compile'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
num_col = model.var_counter
|
|
24
|
+
num_row = model.constraints.size
|
|
25
|
+
|
|
26
|
+
# Determine heading — native API always minimizes, so negate for maximize
|
|
27
|
+
heading = model.heading == :maximize ? :maximize : :minimize
|
|
28
|
+
|
|
29
|
+
# Build column arrays
|
|
30
|
+
col_cost = Array.new(num_col, 0.0)
|
|
31
|
+
col_lower = Array.new(num_col)
|
|
32
|
+
col_upper = Array.new(num_col)
|
|
33
|
+
col_integrality = Array.new(num_col, 0)
|
|
34
|
+
|
|
35
|
+
model.var_bounds.each do |name, (lb, ub)|
|
|
36
|
+
idx = model.variables[name].index
|
|
37
|
+
col_lower[idx] = lb
|
|
38
|
+
col_upper[idx] = ub
|
|
39
|
+
col_integrality[idx] = model.var_types[name] == :integer ? 1 : 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
model.objective.each do |idx, coeff|
|
|
43
|
+
col_cost[idx] = heading == :maximize ? -coeff.to_f : coeff.to_f
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build constraint arrays
|
|
47
|
+
row_lower = Array.new(num_row)
|
|
48
|
+
row_upper = Array.new(num_row)
|
|
49
|
+
|
|
50
|
+
model.constraints.each_with_index do |constr, row_idx|
|
|
51
|
+
row_lower[row_idx] = constr[:lb]
|
|
52
|
+
row_upper[row_idx] = constr[:ub]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Build matrix in CSC (column-wise) format
|
|
56
|
+
# a_start[col] = index into a_index/a_value where column `col` starts
|
|
57
|
+
# a_index[nz] = row index of non-zero element
|
|
58
|
+
# a_value[nz] = value of non-zero element
|
|
59
|
+
nz_per_col = Array.new(num_col, 0)
|
|
60
|
+
nz_entries = []
|
|
61
|
+
|
|
62
|
+
model.constraints.each_with_index do |constr, row_idx|
|
|
63
|
+
constr[:expr].each do |col_idx, coeff|
|
|
64
|
+
nz_entries << [col_idx, row_idx, coeff.to_f]
|
|
65
|
+
nz_per_col[col_idx] += 1
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
total_nz = nz_entries.size
|
|
70
|
+
|
|
71
|
+
# Build a_start (cumulative column starts)
|
|
72
|
+
a_start = [0]
|
|
73
|
+
nz_per_col.each { |count| a_start << a_start.last + count }
|
|
74
|
+
|
|
75
|
+
# Sort entries by column index for CSC format
|
|
76
|
+
nz_entries.sort_by! { |col, row, _| col }
|
|
77
|
+
|
|
78
|
+
aindex = nz_entries.map { |_, row, _| row }
|
|
79
|
+
avalues = nz_entries.map { |_, _, val| val }
|
|
80
|
+
|
|
81
|
+
# Call native solver (skip if no columns/rows)
|
|
82
|
+
if num_col > 0 && num_row > 0
|
|
83
|
+
result = call_native(
|
|
84
|
+
num_col, num_row, total_nz,
|
|
85
|
+
col_cost, col_lower, col_upper, col_integrality,
|
|
86
|
+
row_lower, row_upper,
|
|
87
|
+
a_start, aindex, avalues
|
|
88
|
+
)
|
|
89
|
+
else
|
|
90
|
+
result = { status: :unbounded, objective: 0.0, col_value: [] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Parse result — variables are empty for infeasible/unbounded
|
|
94
|
+
variables = {}
|
|
95
|
+
unless [:infeasible, :unbounded, :unbounded_or_infeasible].include?(result[:status])
|
|
96
|
+
result[:col_value].each_with_index do |val, idx|
|
|
97
|
+
var_name = model.variables.find { |_, v| v.index == idx }&.first
|
|
98
|
+
variables[var_name.to_s] = val if var_name
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
Solution.new(
|
|
103
|
+
variables: variables,
|
|
104
|
+
objective_value: heading == :maximize ? -result[:objective] : result[:objective],
|
|
105
|
+
model_status: result[:status].to_s,
|
|
106
|
+
iterations: 0
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Calls the native HiGHS solver.
|
|
113
|
+
#
|
|
114
|
+
# @param num_col [Integer] Number of columns.
|
|
115
|
+
# @param num_row [Integer] Number of rows.
|
|
116
|
+
# @param num_nz [Integer] Number of non-zero elements.
|
|
117
|
+
# @param col_cost [Array<Float>] Column cost coefficients.
|
|
118
|
+
# @param col_lower [Array<Float>] Column lower bounds.
|
|
119
|
+
# @param col_upper [Array<Float>] Column upper bounds.
|
|
120
|
+
# @param col_integrality [Array<Integer>] Column integrality flags.
|
|
121
|
+
# @param row_lower [Array<Float>] Row lower bounds.
|
|
122
|
+
# @param row_upper [Array<Float>] Row upper bounds.
|
|
123
|
+
# @param a_start [Array<Integer>] Matrix column start indices (CSC format).
|
|
124
|
+
# @param aindex [Array<Integer>] Matrix row indices.
|
|
125
|
+
# @param avalues [Array<Float>] Matrix values.
|
|
126
|
+
# @return [Hash] Solver result with :status, :objective, :col_value, etc.
|
|
127
|
+
def call_native(num_col, num_row, num_nz, col_cost, col_lower, col_upper, col_integrality,
|
|
128
|
+
row_lower, row_upper, a_start, aindex, avalues)
|
|
129
|
+
solver = LpSolver::HiGhSSolver.new
|
|
130
|
+
solver.num_col = num_col
|
|
131
|
+
solver.num_row = num_row
|
|
132
|
+
solver.num_nz = num_nz
|
|
133
|
+
|
|
134
|
+
solver.col_cost = col_cost
|
|
135
|
+
solver.col_lower = col_lower
|
|
136
|
+
solver.col_upper = col_upper
|
|
137
|
+
solver.col_integrality = col_integrality
|
|
138
|
+
|
|
139
|
+
solver.row_lower = row_lower
|
|
140
|
+
solver.row_upper = row_upper
|
|
141
|
+
|
|
142
|
+
solver.a_start = a_start
|
|
143
|
+
solver.a_index = aindex
|
|
144
|
+
solver.a_value = avalues
|
|
145
|
+
|
|
146
|
+
solver.solve
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Driver classes — pluggable solving backends.
|
|
4
|
+
#
|
|
5
|
+
# Each driver implements the same interface so Model can swap them
|
|
6
|
+
# at runtime. See drivers/README.md for the API contract.
|
|
7
|
+
#
|
|
8
|
+
require_relative 'drivers/cli_driver'
|
|
9
|
+
require_relative 'drivers/native_driver'
|