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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a24185adf2e045f36f5249e311b5d357ca16134ab259c4f350fddbe07e141d9c
4
- data.tar.gz: 9fb91dbea34b972e32876b97b4719ef771b30b8975d9ec5bf7494a4d7d49b2d7
3
+ metadata.gz: d76ac229fc048afc42cb81ae328ac716095d43767fb8329ae6cdc8d8a69f8f14
4
+ data.tar.gz: 76b6e5e26102cc8ad5cd8fbc9da236a6b5bdd7496c5099083d0ea750d27e9000
5
5
  SHA512:
6
- metadata.gz: b64322ff1240d58b92345ce8e9fff552dceeada28d57066fc56a94c02a89f66debaeeb1ccc3e37fbb434fe6619b828f91f536a5f8abc323d2792c8f8da37e4c9
7
- data.tar.gz: fe8f3fba95166b9608837886d84b81adfa4d9b1416864c3607a5f194d9aaa679d36af36389ec1e6db4dc239e7d64417bd9b985a3a1a300c3c35f75597a950988
6
+ metadata.gz: c969d6289498dfa06bb0ed44633fa0ea0f9100aa50ec9ec3be9a1a9e11c2f2c27ad2978c7d7f802e3834692edc9fe9802742035e3847249c8bb726112a25b769
7
+ data.tar.gz: 45f270ac494d18748344cbc446ebf591f28af625341f424649e7f2452c880d8478bb8bde1ce7dc0ea2805cbb909e5d796dbbe5df60506e9d25e9b04e96505085
data/README.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # LpSolver
2
2
 
3
+ [![test](https://github.com/davidsiaw/lpsolver/actions/workflows/test.yml/badge.svg)](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.set_objective(x + y)
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#solve`
303
- Solves the model without setting an objective (legacy).
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.
@@ -13,12 +13,12 @@ NULLCMD = :
13
13
  #### Start of system configuration section. ####
14
14
 
15
15
  srcdir = .
16
- topdir = /usr/local/include/ruby-3.4.0
16
+ topdir = /home/node/.rvm/rubies/ruby-3.3.7/include/ruby-3.3.0
17
17
  hdrdir = $(topdir)
18
- arch_hdrdir = /usr/local/include/ruby-3.4.0/x86_64-linux
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)/usr/local
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 = $(hardenflags) $(optflags) $(debugflags) $(warnflags)
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/home/david/programs/ai/pi/workspace/lpsolver/ext/lpsolver-highs/highs -I/home/david/programs/ai/pi/workspace/lpsolver/ext/lpsolver-highs/build/include/highs
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/home/david/programs/ai/pi/workspace/lpsolver/ext/lpsolver-highs/build/lib -Wl,-rpath,/home/david/programs/ai/pi/workspace/lpsolver/ext/lpsolver-highs/build/lib
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.4.0
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 = -L. -L$(libdir) -Wl,-rpath,$(libdir)
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
@@ -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#{highs_include} -I#{highs_config_inc}"
50
+ $INCFLAGS += " -I#{highs_config_inc}"
48
51
  end
49
52
  end
50
53
 
51
- $LDFLAGS += " -L#{highs_lib} -Wl,-rpath,#{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.so
79
+ Build output: #{highs_build}/lib/libhighs.a
77
80
  ERROR
78
81
 
79
82
  create_makefile('lpsolver/native')
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'