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.
@@ -1,93 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'tempfile'
4
-
5
3
  module LpSolver
6
4
  # A high-level interface to HiGHS for building and solving LP/QP/MIP models.
7
5
  #
8
6
  # The Model class provides a Ruby DSL for defining variables, constraints,
9
- # and objectives. Models are serialized to HiGHS LP format and solved via
10
- # the HiGHS command-line interface.
11
- #
12
- # == Problem Types Supported
13
- #
14
- # * **Linear Programming (LP)**: Linear objective with linear constraints.
15
- # Example: maximize profit given resource constraints.
16
- #
17
- # * **Quadratic Programming (QP)**: Quadratic objective (convex) with
18
- # linear constraints. Example: minimize portfolio variance for a target return.
19
- #
20
- # * **Mixed Integer Programming (MIP)**: LP or QP with some or all variables
21
- # restricted to integer values. Example: coin change problem with integer counts.
22
- #
23
- # == Usage
24
- #
25
- # The DSL uses Ruby operators to build expressions naturally:
7
+ # and objectives. Models are solved via a pluggable driver.
26
8
  #
27
- # model = LpSolver::Model.new
28
- # x = model.add_variable(:x, lb: 0)
29
- # y = model.add_variable(:y, lb: 0)
30
- #
31
- # # Constraints
32
- # model.add_constraint(:budget, (x * 2 + y) <= 100)
33
- # model.add_constraint(:demand, (x + y * 2) >= 50)
34
- #
35
- # # Objective
36
- # model.minimize
37
- # model.set_objective(x * 3 + y * 5)
38
- #
39
- # # Solve
40
- # solution = model.solve
41
- # puts solution[:x] # => optimal value for x
42
- #
43
- # @example Linear Programming
44
- # model = LpSolver::Model.new
45
- # x = model.add_variable(:x, lb: 0)
46
- # y = model.add_variable(:y, lb: 0)
47
- # model.add_constraint(:c1, (x + y) >= 4)
48
- # model.minimize
49
- # model.set_objective(x * 3 + y * 5)
50
- # solution = model.solve
51
- # solution.objective_value # => 12.0
52
- #
53
- # @example Quadratic Programming
54
- # model = LpSolver::Model.new
55
- # x = model.add_variable(:x, lb: 0)
56
- # y = model.add_variable(:y, lb: 0)
57
- # model.add_constraint(:c, (x + y) >= 2)
58
- # model.minimize
59
- # model.set_objective(x * x + y * y)
60
- # solution = model.solve
61
- # solution.objective_value # => 2.0 (at x=1, y=1)
62
- #
63
- # @example Mixed Integer Programming
64
- # model = LpSolver::Model.new
65
- # x = model.add_variable(:x, lb: 0, integer: true)
66
- # y = model.add_variable(:y, lb: 0, integer: true)
67
- # model.add_constraint(:c, (x + y) == 10)
68
- # model.minimize
69
- # model.set_objective(x * 2 + y * 3)
70
- # solution = model.solve
71
- # solution[:x] # => 10.0 (integer)
72
9
  class Model
73
- # The path to the HiGHS binary.
10
+ # @return [Hash{Symbol => Variable}] All variables defined in this model.
11
+ attr_reader :variables
12
+ # @return [Integer] The next available variable index.
13
+ attr_reader :var_counter
14
+ # @return [Symbol] The current optimization heading.
15
+ attr_reader :heading
16
+ # @return [Array<Hash>] Constraint data as an array of {name, lb, ub, expr}.
17
+ def constraints
18
+ @constraints_data.values
19
+ end
20
+ # @return [Hash{Symbol => Hash}] Maps constraint names to their data.
21
+ attr_reader :constraints_data
22
+ # @return [Hash{Symbol => Symbol}] Maps variable names to their types.
23
+ attr_reader :var_types
24
+ # @return [Hash{Integer => Float}] Maps variable indices to coefficients.
25
+ attr_reader :objective
26
+ # @return [Array<[Integer, Integer, Float]>] Quadratic term entries.
27
+ attr_reader :quadratic_terms
28
+ # @return [Hash{Symbol => Array<Float>}] Maps variable names to [lb, ub] bounds.
29
+ attr_reader :var_bounds
30
+ # @return [String] The model name.
31
+ attr_reader :name
32
+ # @return [Drivers::CliDriver, Drivers::NativeDriver] The configured solver driver.
33
+ attr_reader :driver
34
+
35
+ # Returns the default solver driver.
36
+ #
37
+ # Tries CliDriver first (if HiGHS binary is available), then falls back
38
+ # to NativeDriver (if native extension is compiled), then raises an error.
39
+ #
40
+ # @return [Drivers::CliDriver, Drivers::NativeDriver] The default driver.
41
+ # @raise [RuntimeError] If neither CLI nor native driver is available.
42
+ def self.default_driver
43
+ begin
44
+ Drivers::CliDriver.new
45
+ rescue
46
+ begin
47
+ Drivers::NativeDriver.new
48
+ rescue LoadError
49
+ raise 'No solver available. Install HiGHS or compile the native extension with: rake compile'
50
+ end
51
+ end
52
+ end
53
+
54
+ # Solves the model using the configured driver, with automatic fallback.
74
55
  #
75
- # Resolution order:
76
- # 1. HIGHS_PATH environment variable
77
- # 2. Bundled binary at lib/lpsolver/highs (from rake compile)
78
- # 3. 'highs' on system PATH
56
+ # If the configured driver is CliDriver and the HiGHS binary is not found,
57
+ # automatically falls back to NativeDriver. If NativeDriver is also not
58
+ # available, raises the original error.
79
59
  #
80
- # @return [String] The path to the HiGHS executable.
81
- HIGHS_PATH = begin
82
- env_path = ENV.fetch('HIGHS_PATH', nil)
83
- if env_path
84
- env_path
85
- else
86
- bundled = File.expand_path('../../lib/lpsolver/highs', __dir__)
87
- if File.exist?(bundled)
88
- bundled
60
+ # @return [Solution] The solution object.
61
+ # @raise [SolverError] If the solver encounters an error.
62
+ # @raise [LoadError] If no driver is available.
63
+ def solve
64
+ begin
65
+ @driver.solve(self)
66
+ rescue SolverError, LoadError => e
67
+ # If CLI driver fails and native driver is available, try it
68
+ if @driver.class.name.end_with?('CliDriver')
69
+ begin
70
+ @driver = Drivers::NativeDriver.new
71
+ @driver.solve(self)
72
+ rescue LoadError
73
+ raise e
74
+ end
89
75
  else
90
- 'highs'
76
+ raise e
91
77
  end
92
78
  end
93
79
  end
@@ -102,37 +88,23 @@ module LpSolver
102
88
  @constraints = {} # { symbol => index }
103
89
  @var_counter = 0
104
90
  @constr_counter = 0
105
- @sense = :minimize
91
+ @heading = :minimize
106
92
  @solution = nil
107
93
  @objective = {} # { var_index => coefficient }
108
94
  @quadratic_terms = [] # [[var1_idx, var2_idx, coefficient], ...]
109
95
  @var_types = {} # { symbol => :continuous | :integer }
110
96
  @var_bounds = {} # { symbol => [lb, ub] }
111
97
  @constraints_data = {} # { symbol => { lb:, ub:, expr: [[var_idx, coeff], ...] } }
98
+ @driver = self.class.default_driver
112
99
  end
113
100
 
114
101
  # Adds a variable to the model.
115
102
  #
116
- # Variables represent the decision quantities to be determined by the
117
- # solver. Each variable is assigned a unique internal index and can be
118
- # used in expressions via arithmetic operators.
119
- #
120
- # @param name [Symbol, String] The name of the variable. This is used
121
- # for identification in the LP format output and solution results.
122
- # @param lb [Float] The lower bound for the variable (default: 0.0).
123
- # Use -Float::INFINITY for no lower bound.
124
- # @param ub [Float] The upper bound for the variable (default: Float::INFINITY).
125
- # Use Float::INFINITY for no upper bound. Setting lb == ub fixes the variable.
126
- # @param integer [Boolean] Whether the variable must take integer values
127
- # (default: false). When true, the model becomes a MIP problem.
128
- # @return [Variable] The variable object, which supports arithmetic and
129
- # comparison operators for building expressions and constraints.
130
- # @example Adding a continuous variable
131
- # x = model.add_variable(:x, lb: 0)
132
- # @example Adding an integer variable
133
- # count = model.add_variable(:count, lb: 0, integer: true)
134
- # @example Adding a fixed variable
135
- # capacity = model.add_variable(:capacity, lb: 100, ub: 100)
103
+ # @param name [Symbol, String] The variable name.
104
+ # @param lb [Float] Lower bound (default: 0.0).
105
+ # @param ub [Float] Upper bound (default: +Inf).
106
+ # @param integer [Boolean] Whether the variable is integer (default: false).
107
+ # @return [Variable] The variable object.
136
108
  def add_variable(name, lb: 0.0, ub: Float::INFINITY, integer: false)
137
109
  name = name.to_sym
138
110
  idx = @var_counter
@@ -146,26 +118,12 @@ module LpSolver
146
118
 
147
119
  # Adds a constraint to the model.
148
120
  #
149
- # Constraints define the feasible region of the optimization problem.
150
- # They can be specified using the DSL (comparison operators) or the
151
- # legacy array format.
152
- #
153
- # @param name [Symbol, String] The name of the constraint.
121
+ # @param name [Symbol, String] The constraint name.
154
122
  # @param expr [ConstraintSpec, Array<[Integer, Float]>] The constraint
155
- # specification. Can be either:
156
- # - A ConstraintSpec from comparison operators: (x * 2 + y) <= 100
157
- # - An array of [var_index, coefficient] pairs with explicit bounds
158
- # @param lb [Float] Lower bound for the constraint (used with array-style expr).
159
- # Default: -Float::INFINITY.
160
- # @param ub [Float] Upper bound for the constraint (used with array-style expr).
161
- # Default: Float::INFINITY.
123
+ # specification (DSL expression or legacy array format).
124
+ # @param lb [Float] Lower bound (default: -Inf).
125
+ # @param ub [Float] Upper bound (default: +Inf).
162
126
  # @return [Symbol] The constraint name.
163
- # @example Using DSL comparison operators
164
- # model.add_constraint(:budget, (x * 2 + y) <= 100)
165
- # model.add_constraint(:demand, (x + y * 2) >= 50)
166
- # model.add_constraint(:balance, (x + y) == 10)
167
- # @example Using legacy array format
168
- # model.add_constraint(:budget, [[x.index, 2], [y.index, 1]], ub: 100)
169
127
  def add_constraint(name, expr, lb: -Float::INFINITY, ub: Float::INFINITY)
170
128
  name = name.to_sym
171
129
 
@@ -189,39 +147,34 @@ module LpSolver
189
147
  name
190
148
  end
191
149
 
192
- # Sets the optimization sense to minimization.
150
+ # Sets heading to minimize, sets the objective, and solves.
193
151
  #
194
- # @return [void]
195
- # @see #maximize
196
- def minimize
197
- @sense = :minimize
152
+ # @param objective [LinearExpression, QuadraticExpression, Hash{Integer => Float}] The objective.
153
+ # @return [Solution] The solution object.
154
+ # @raise [SolverError] If the solver encounters an error.
155
+ def minimize!(objective)
156
+ @heading = :minimize
157
+ set_objective_internal(objective)
158
+ solve
198
159
  end
199
160
 
200
- # Sets the optimization sense to maximization.
161
+ # Sets heading to maximize, sets the objective, and solves.
201
162
  #
202
- # @return [void]
203
- # @see #minimize
204
- def maximize
205
- @sense = :maximize
163
+ # @param objective [LinearExpression, QuadraticExpression, Hash{Integer => Float}] The objective.
164
+ # @return [Solution] The solution object.
165
+ # @raise [SolverError] If the solver encounters an error.
166
+ def maximize!(objective)
167
+ @heading = :maximize
168
+ set_objective_internal(objective)
169
+ solve
206
170
  end
207
171
 
208
- # Sets the objective function for the model.
209
- #
210
- # The objective function defines what the solver should optimize.
211
- # It can be a linear expression (for LP), a quadratic expression
212
- # (for QP), or a hash of coefficients (legacy format).
172
+ private
173
+
174
+ # Sets the objective function internally.
213
175
  #
214
- # @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
215
- # The objective function. Can be:
216
- # - A LinearExpression: `x * 3 + y * 5`
217
- # - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
218
- # - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
219
- # @return [void]
220
- # @example Linear objective
221
- # model.set_objective(x * 3 + y * 5)
222
- # @example Quadratic objective (QP)
223
- # model.set_objective(x * x + y * y)
224
- def set_objective(objective)
176
+ # @param objective [LinearExpression, QuadraticExpression, Hash{Integer => Float}] The objective.
177
+ def set_objective_internal(objective)
225
178
  if objective.is_a?(QuadraticExpression)
226
179
  @objective = objective.linear_terms.transform_values(&:to_f)
227
180
  @quadratic_terms = objective.hessian_entries
@@ -234,105 +187,11 @@ module LpSolver
234
187
  end
235
188
  end
236
189
 
237
- # Solves the model and returns the solution.
238
- #
239
- # Serializes the model to HiGHS LP format, invokes the HiGHS solver,
240
- # and parses the solution file to return a Solution object.
241
- #
242
- # @return [Solution] The solution object containing variable values,
243
- # objective value, and model status.
244
- # @raise [SolverError] If the HiGHS solver encounters an error.
245
- # @example
246
- # solution = model.solve
247
- # solution[:x] # => optimal value for variable x
248
- # solution.objective_value # => optimal objective value
249
- # solution.feasible? # => true
250
- def solve
251
- lp_content = to_lp
252
- lp_file = Tempfile.new(['model', '.lp'])
253
- lp_file.write(lp_content)
254
- lp_file.close
255
-
256
- solution_file = Tempfile.new(['solution', '.sol'])
257
- opts_file = Tempfile.new(['highs_opts', '.txt'])
258
- opts_file.write("log_to_console = false\noutput_flag = false\n")
259
- opts_file.close
260
-
261
- cmd = "#{self.class::HIGHS_PATH} " \
262
- "--model_file #{lp_file.path} " \
263
- "--options_file #{opts_file.path} " \
264
- "--solution_file #{solution_file.path}"
265
-
266
- output = `#{cmd} 2>&1`
267
- lp_file.unlink
268
- opts_file.unlink
269
-
270
- # HiGHS returns non-zero exit code for infeasible/unbounded problems,
271
- # but still writes a valid solution file. Check for valid status instead.
272
- solution_content = File.read(solution_file.path)
273
- status_match = solution_content.match(/Model status\s*\n\s*(\S+)/i)
274
- unless status_match
275
- raise SolverError, "HiGHS solver failed:\n#{output}" unless $?.success?
276
- end
277
-
278
- @solution = parse_solution_file(solution_file.path)
279
- solution_file.unlink
280
- @solution
281
- end
282
-
283
- # Sets the optimization sense to minimization, sets the objective,
284
- # and solves the model in a single call.
285
- #
286
- # This is a convenience method that combines #minimize, #set_objective,
287
- # and #solve into one step.
288
- #
289
- # @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
290
- # The objective function to minimize. Can be:
291
- # - A LinearExpression: `x * 3 + y * 5`
292
- # - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
293
- # - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
294
- # @return [Solution] The solution object containing variable values,
295
- # objective value, and model status.
296
- # @raise [SolverError] If the HiGHS solver encounters an error.
297
- # @example
298
- # solution = model.minimize!(x * 3 + y * 5)
299
- # puts solution.objective_value # => optimal (minimum) value
300
- # @example Quadratic minimization (QP)
301
- # solution = model.minimize!(x * x + y * y)
302
- def minimize!(objective)
303
- @sense = :minimize
304
- set_objective(objective)
305
- solve
306
- end
307
-
308
- # Sets the optimization sense to maximization, sets the objective,
309
- # and solves the model in a single call.
310
- #
311
- # This is a convenience method that combines #maximize, #set_objective,
312
- # and #solve into one step.
313
- #
314
- # @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
315
- # The objective function to maximize. Can be:
316
- # - A LinearExpression: `x * 3 + y * 5`
317
- # - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
318
- # - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
319
- # @return [Solution] The solution object containing variable values,
320
- # objective value, and model status.
321
- # @raise [SolverError] If the HiGHS solver encounters an error.
322
- # @example
323
- # solution = model.maximize!(x * 3 + y * 5)
324
- # puts solution.objective_value # => optimal (maximum) value
325
- def maximize!(objective)
326
- @sense = :maximize
327
- set_objective(objective)
328
- solve
329
- end
190
+ public
330
191
 
331
192
  # Converts the model to HiGHS LP format string.
332
193
  #
333
- # The LP format is a text-based representation of the optimization
334
- # problem that HiGHS can read. It includes the objective function,
335
- # constraints, variable bounds, and integer declarations.
194
+ # Delegates to LpGenerator for serialization.
336
195
  #
337
196
  # @return [String] The LP format content.
338
197
  # @example
@@ -347,84 +206,7 @@ module LpSolver
347
206
  # # 0 <= y <= +Inf
348
207
  # # End
349
208
  def to_lp
350
- lines = []
351
-
352
- # Objective
353
- lines << (@sense == :minimize ? 'Minimize' : 'Maximize')
354
-
355
- obj_terms = @objective.map do |var_idx, coeff|
356
- var_name = find_var_name(var_idx)
357
- "#{format_coeff(coeff)} #{sanitize_name(var_name)}"
358
- end.join(' + ')
359
-
360
- if @quadratic_terms.any?
361
- quad_parts = @quadratic_terms.map do |i1, i2, coeff|
362
- n1 = sanitize_name(find_var_name(i1))
363
- n2 = sanitize_name(find_var_name(i2))
364
- if i1 == i2
365
- "#{format_coeff(coeff)} #{n1} ^ 2"
366
- else
367
- "#{format_coeff(coeff)} #{n1} * #{n2}"
368
- end
369
- end.join(' + ')
370
- lines << " obj: #{obj_terms} + [ #{quad_parts} ] / 2"
371
- else
372
- lines << " obj: #{obj_terms}"
373
- end
374
-
375
- # Constraints
376
- if @constraints.any?
377
- lines << 'Subject To'
378
- @constraints.each do |name, _idx|
379
- data = @constraints_data[name]
380
- terms = data[:expr].map do |var_idx, coeff|
381
- var_name = find_var_name(var_idx)
382
- "#{format_coeff(coeff)} #{sanitize_name(var_name)}"
383
- end.join(' + ')
384
-
385
- if data[:lb] == -Float::INFINITY && data[:ub] == Float::INFINITY
386
- lines << " #{sanitize_name(name)}: #{terms} free"
387
- elsif data[:lb] == -Float::INFINITY
388
- lines << " #{sanitize_name(name)}: #{terms} <= #{format_bound(data[:ub])}"
389
- elsif data[:ub] == Float::INFINITY
390
- lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
391
- elsif (data[:ub] - data[:lb]).abs < 1e-12
392
- lines << " #{sanitize_name(name)}: #{terms} = #{format_bound(data[:lb])}"
393
- else
394
- lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
395
- lines << " #{sanitize_name(name)}_ub: #{terms} <= #{format_bound(data[:ub])}"
396
- end
397
- end
398
- end
399
-
400
- # Bounds
401
- if @var_bounds.any?
402
- lines << 'Bounds'
403
- @variables.each do |name, _var|
404
- lb, ub = @var_bounds[name]
405
- sname = sanitize_name(name)
406
-
407
- if lb == ub
408
- lines << " #{sname} = #{format_bound(lb)}"
409
- elsif lb > -Float::INFINITY && ub < Float::INFINITY
410
- lines << " #{lb} <= #{sname} <= #{format_bound(ub)}"
411
- elsif lb > -Float::INFINITY
412
- lines << " #{sname} >= #{format_bound(lb)}"
413
- elsif ub < Float::INFINITY
414
- lines << " #{sname} <= #{format_bound(ub)}"
415
- end
416
- end
417
- end
418
-
419
- # Integer variables
420
- int_vars = @variables.select { |sym, _| @var_types[sym] == :integer }
421
- if int_vars.any?
422
- lines << 'Integers'
423
- int_vars.each { |name, _| lines << " #{sanitize_name(name)}" }
424
- end
425
-
426
- lines << 'End'
427
- lines.join("\n")
209
+ LpGenerator.new(self).generate
428
210
  end
429
211
 
430
212
  # Writes the model to an LP file.
@@ -437,28 +219,16 @@ module LpSolver
437
219
  File.write(filename, to_lp)
438
220
  end
439
221
 
440
- # Returns all variables defined in this model.
222
+ # Sets the solver driver.
441
223
  #
442
- # @return [Hash{Symbol => Variable}] Maps variable names (Symbols) to
443
- # their Variable objects.
444
- # @example
445
- # model.variables
446
- # # => { :x => @x(0), :y => @y(1) }
447
- def variables
448
- @variables
224
+ # @param driver [Drivers::CliDriver, Drivers::NativeDriver] The driver to use for solving.
225
+ def driver=(driver)
226
+ @driver = driver
449
227
  end
450
228
 
451
229
  private
452
230
 
453
- # Looks up a variable name by its internal index.
454
- #
455
- # @param idx [Integer] The internal variable index.
456
- # @return [String] The variable name, or "v#{idx}" if not found.
457
- def find_var_name(idx)
458
- @variables.find { |_, var| var.index == idx }&.first || "v#{idx}"
459
- end
460
-
461
- # Normalizes bound values for LP format output.
231
+ # Normalizes bound values for internal storage.
462
232
  #
463
233
  # Converts special infinity values to their canonical forms.
464
234
  #
@@ -467,82 +237,7 @@ module LpSolver
467
237
  def normalize_bound(val)
468
238
  return -Float::INFINITY if val == -Float::INFINITY || val == -1.0 / 0.0
469
239
  return Float::INFINITY if val == Float::INFINITY || val == 1.0 / 0.0
470
-
471
240
  val.to_f
472
241
  end
473
-
474
- # Formats a coefficient for LP output.
475
- #
476
- # Integers are output without decimal points for readability.
477
- #
478
- # @param value [Float] The coefficient value.
479
- # @return [String] The formatted coefficient string.
480
- def format_coeff(value)
481
- if value == value.to_i && value.abs < 1e15
482
- value.to_i.to_s
483
- else
484
- format('%.6g', value)
485
- end
486
- end
487
-
488
- # Formats a bound value for LP output.
489
- #
490
- # @param value [Float] The bound value.
491
- # @return [String] The formatted bound string (+Inf, -Inf, or numeric).
492
- def format_bound(value)
493
- return '+Inf' if value == Float::INFINITY
494
- return '-Inf' if value == -Float::INFINITY
495
-
496
- format_coeff(value)
497
- end
498
-
499
- # Sanitizes a name for LP format (no spaces, special characters).
500
- #
501
- # @param name [String, Symbol] The name to sanitize.
502
- # @return [String] The sanitized name (max 32 characters).
503
- def sanitize_name(name)
504
- name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')[0, 32]
505
- end
506
-
507
- # Parses the HiGHS solution file.
508
- #
509
- # Extracts variable values, objective value, and model status from
510
- # the solution file format produced by HiGHS.
511
- #
512
- # @param path [String] The path to the HiGHS solution file.
513
- # @return [Solution] The parsed solution object.
514
- def parse_solution_file(path)
515
- content = File.read(path)
516
- variables = {}
517
- objective_value = 0.0
518
- model_status = 'unknown'
519
- iterations = 0
520
-
521
- status_match = content.match(/Model status\s*\n\s*(\S+)/i)
522
- model_status = status_match[1].downcase.gsub('_', ' ') if status_match
523
-
524
- obj_match = content.match(/Objective\s+(\S+)/i)
525
- objective_value = obj_match[1].to_f if obj_match
526
-
527
- in_columns = false
528
- content.each_line do |line|
529
- if line =~ /# Columns/i
530
- in_columns = true
531
- next
532
- end
533
- next unless in_columns
534
- break if line.strip.empty? || line.start_with?('#')
535
-
536
- parts = line.strip.split(/\s+/, 2)
537
- variables[parts[0]] = parts[1].to_f if parts.length == 2
538
- end
539
-
540
- Solution.new(
541
- variables: variables,
542
- objective_value: objective_value,
543
- model_status: model_status,
544
- iterations: iterations
545
- )
546
- end
547
242
  end
548
243
  end
@@ -4,5 +4,5 @@ module LpSolver
4
4
  # The current version of the LpSolver gem.
5
5
  #
6
6
  # @return [String] The version string (e.g., "0.1.0").
7
- VERSION = '0.2.1'
7
+ VERSION = '0.3.0'
8
8
  end
data/lib/lpsolver.rb CHANGED
@@ -15,10 +15,7 @@
15
15
  # y = model.add_variable(:y, lb: 0)
16
16
  #
17
17
  # model.add_constraint(:budget, (x * 2 + y) <= 100)
18
- # model.minimize
19
- # model.set_objective(x * 3 + y * 5)
20
- #
21
- # solution = model.solve
18
+ # solution = model.minimize!(x * 3 + y * 5)
22
19
  # puts solution.objective_value # => 12.0
23
20
  #
24
21
  # @see LpSolver::Model
@@ -39,3 +36,12 @@ require_relative 'lpsolver/quadratic_expression'
39
36
  # Solvers and data classes
40
37
  require_relative 'lpsolver/solution'
41
38
  require_relative 'lpsolver/model'
39
+ require_relative 'lpsolver/lp_generator'
40
+ require_relative 'lpsolver/drivers'
41
+
42
+ # Native extension (optional — only available if compiled)
43
+ begin
44
+ require_relative 'lpsolver/native'
45
+ rescue LoadError
46
+ # Native extension not compiled — NativeDriver will raise LoadError at runtime
47
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lpsolver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Siaw
@@ -1194,12 +1194,16 @@ files:
1194
1194
  - ext/lpsolver/native.so
1195
1195
  - lib/lpsolver.rb
1196
1196
  - lib/lpsolver/constraint_spec.rb
1197
+ - lib/lpsolver/drivers.rb
1198
+ - lib/lpsolver/drivers/README.md
1199
+ - lib/lpsolver/drivers/cli_driver.rb
1200
+ - lib/lpsolver/drivers/native_driver.rb
1197
1201
  - lib/lpsolver/exception.rb
1198
1202
  - lib/lpsolver/highs
1199
1203
  - lib/lpsolver/linear_expression.rb
1204
+ - lib/lpsolver/lp_generator.rb
1200
1205
  - lib/lpsolver/model.rb
1201
1206
  - lib/lpsolver/native.so
1202
- - lib/lpsolver/native_model.rb
1203
1207
  - lib/lpsolver/quadratic_expression.rb
1204
1208
  - lib/lpsolver/solution.rb
1205
1209
  - lib/lpsolver/variable.rb