lpsolver 0.2.1 → 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: 92ca955854e8330a4c7bc4000076d0a4e1b6c2828507b1bbaae1cdc4168a80b1
4
- data.tar.gz: 20993564cfc5e75be002b3a558b6a3cab49136a96f73e693cdfb7e1791f51c71
3
+ metadata.gz: d76ac229fc048afc42cb81ae328ac716095d43767fb8329ae6cdc8d8a69f8f14
4
+ data.tar.gz: 76b6e5e26102cc8ad5cd8fbc9da236a6b5bdd7496c5099083d0ea750d27e9000
5
5
  SHA512:
6
- metadata.gz: c61c459e3a47afc69fe0090aca6979ff4079cf73a4b17216594888babafba706511e115c59c4d23b7f14ff511cde0778f7e7557c716b31246fafc4b8d17431a2
7
- data.tar.gz: bcc475ef900fab92a6c21696d2de870cc78e2890df0c35529385a68d3d80e0b7a1c908828e5806a80fbddc786277f4bb5db6502c530f3e210a48aaea3b4b00f9
6
+ metadata.gz: c969d6289498dfa06bb0ed44633fa0ea0f9100aa50ec9ec3be9a1a9e11c2f2c27ad2978c7d7f802e3834692edc9fe9802742035e3847249c8bb726112a25b769
7
+ data.tar.gz: 45f270ac494d18748344cbc446ebf591f28af625341f424649e7f2452c880d8478bb8bde1ce7dc0ea2805cbb909e5d796dbbe5df60506e9d25e9b04e96505085
data/README.md CHANGED
@@ -115,6 +115,32 @@ ruby -Ilib examples/coin_purse.rb
115
115
 
116
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.
117
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
+
118
144
  ## Usage
119
145
 
120
146
  ### Simple Example (Operator DSL)
@@ -248,7 +274,7 @@ x = model.add_variable(:x, lb: 0)
248
274
  y = model.add_variable(:y, lb: 0)
249
275
 
250
276
  model.add_constraint(:c1, (x * 2 + y) <= 10)
251
- model.set_objective(x + y)
277
+ model.minimize!(x + y)
252
278
 
253
279
  # Print the LP file format
254
280
  puts model.to_lp
@@ -313,24 +339,23 @@ Set the objective to minimize and solve in one call.
313
339
  ### `Model#maximize!(objective)`
314
340
  Set the objective to maximize and solve in one call.
315
341
 
316
- ### `Model#minimize`
317
- Set the optimization sense to minimization (legacy, use `minimize!` instead).
318
-
319
- ### `Model#maximize`
320
- Set the optimization sense to maximization (legacy, use `maximize!` instead).
321
-
322
- ### `Model#set_objective(objective)`
323
- Set the objective function without solving (legacy, use `minimize!`/`maximize!` instead).
324
- - `objective` can be a `LinearExpression`, `QuadraticExpression`, or Hash.
325
-
326
342
  ### `Model#to_lp`
327
343
  Returns the model as a HiGHS LP format string.
328
344
 
329
345
  ### `Model#write_lp(filename)`
330
346
  Writes the model to an LP file.
331
347
 
332
- ### `Model#solve`
333
- 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.
334
359
 
335
360
  ### `Solution#[]`
336
361
  Get a variable's value. Accepts Symbol, String, or Variable object.
@@ -86,11 +86,11 @@ warnflags = -Wall -Wextra -Wdeprecated-declarations -Wdiv-by-zero -Wduplicated-c
86
86
  cppflags =
87
87
  CCDLFLAGS = -fPIC
88
88
  CFLAGS = $(CCDLFLAGS) $(cflags) -fPIC $(ARCH_FLAG)
89
- INCFLAGS = -I. -I$(arch_hdrdir) -I$(hdrdir)/ruby/backward -I$(hdrdir) -I$(srcdir) -I/workspace/lpsolver/ext/lpsolver-highs/highs -I/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
90
90
  DEFS =
91
91
  CPPFLAGS = $(DEFS) $(cppflags)
92
92
  CXXFLAGS = $(CCDLFLAGS) $(ARCH_FLAG)
93
- ldflags = -L. -fstack-protector-strong -rdynamic -Wl,-export-dynamic -Wl,--no-as-needed -L/workspace/lpsolver/ext/lpsolver-highs/build/lib -Wl,-rpath,/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
94
94
  dldflags = -Wl,--compress-debug-sections=zlib
95
95
  ARCH_FLAG =
96
96
  DLDFLAGS = $(ldflags) $(dldflags) $(ARCH_FLAG)
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'