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.
@@ -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,101 +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
- status = $?.success?
268
-
269
- lp_file.unlink
270
- opts_file.unlink
271
-
272
- raise SolverError, "HiGHS solver failed:\n#{output}" unless status
273
-
274
- @solution = parse_solution_file(solution_file.path)
275
- solution_file.unlink
276
- @solution
277
- end
278
-
279
- # Sets the optimization sense to minimization, sets the objective,
280
- # and solves the model in a single call.
281
- #
282
- # This is a convenience method that combines #minimize, #set_objective,
283
- # and #solve into one step.
284
- #
285
- # @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
286
- # The objective function to minimize. Can be:
287
- # - A LinearExpression: `x * 3 + y * 5`
288
- # - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
289
- # - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
290
- # @return [Solution] The solution object containing variable values,
291
- # objective value, and model status.
292
- # @raise [SolverError] If the HiGHS solver encounters an error.
293
- # @example
294
- # solution = model.minimize!(x * 3 + y * 5)
295
- # puts solution.objective_value # => optimal (minimum) value
296
- # @example Quadratic minimization (QP)
297
- # solution = model.minimize!(x * x + y * y)
298
- def minimize!(objective)
299
- @sense = :minimize
300
- set_objective(objective)
301
- solve
302
- end
303
-
304
- # Sets the optimization sense to maximization, sets the objective,
305
- # and solves the model in a single call.
306
- #
307
- # This is a convenience method that combines #maximize, #set_objective,
308
- # and #solve into one step.
309
- #
310
- # @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
311
- # The objective function to maximize. Can be:
312
- # - A LinearExpression: `x * 3 + y * 5`
313
- # - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
314
- # - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
315
- # @return [Solution] The solution object containing variable values,
316
- # objective value, and model status.
317
- # @raise [SolverError] If the HiGHS solver encounters an error.
318
- # @example
319
- # solution = model.maximize!(x * 3 + y * 5)
320
- # puts solution.objective_value # => optimal (maximum) value
321
- def maximize!(objective)
322
- @sense = :maximize
323
- set_objective(objective)
324
- solve
325
- end
190
+ public
326
191
 
327
192
  # Converts the model to HiGHS LP format string.
328
193
  #
329
- # The LP format is a text-based representation of the optimization
330
- # problem that HiGHS can read. It includes the objective function,
331
- # constraints, variable bounds, and integer declarations.
194
+ # Delegates to LpGenerator for serialization.
332
195
  #
333
196
  # @return [String] The LP format content.
334
197
  # @example
@@ -343,84 +206,7 @@ module LpSolver
343
206
  # # 0 <= y <= +Inf
344
207
  # # End
345
208
  def to_lp
346
- lines = []
347
-
348
- # Objective
349
- lines << (@sense == :minimize ? 'Minimize' : 'Maximize')
350
-
351
- obj_terms = @objective.map do |var_idx, coeff|
352
- var_name = find_var_name(var_idx)
353
- "#{format_coeff(coeff)} #{sanitize_name(var_name)}"
354
- end.join(' + ')
355
-
356
- if @quadratic_terms.any?
357
- quad_parts = @quadratic_terms.map do |i1, i2, coeff|
358
- n1 = sanitize_name(find_var_name(i1))
359
- n2 = sanitize_name(find_var_name(i2))
360
- if i1 == i2
361
- "#{format_coeff(coeff)} #{n1} ^ 2"
362
- else
363
- "#{format_coeff(coeff)} #{n1} * #{n2}"
364
- end
365
- end.join(' + ')
366
- lines << " obj: #{obj_terms} + [ #{quad_parts} ] / 2"
367
- else
368
- lines << " obj: #{obj_terms}"
369
- end
370
-
371
- # Constraints
372
- if @constraints.any?
373
- lines << 'Subject To'
374
- @constraints.each do |name, _idx|
375
- data = @constraints_data[name]
376
- terms = data[:expr].map do |var_idx, coeff|
377
- var_name = find_var_name(var_idx)
378
- "#{format_coeff(coeff)} #{sanitize_name(var_name)}"
379
- end.join(' + ')
380
-
381
- if data[:lb] == -Float::INFINITY && data[:ub] == Float::INFINITY
382
- lines << " #{sanitize_name(name)}: #{terms} free"
383
- elsif data[:lb] == -Float::INFINITY
384
- lines << " #{sanitize_name(name)}: #{terms} <= #{format_bound(data[:ub])}"
385
- elsif data[:ub] == Float::INFINITY
386
- lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
387
- elsif (data[:ub] - data[:lb]).abs < 1e-12
388
- lines << " #{sanitize_name(name)}: #{terms} = #{format_bound(data[:lb])}"
389
- else
390
- lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
391
- lines << " #{sanitize_name(name)}_ub: #{terms} <= #{format_bound(data[:ub])}"
392
- end
393
- end
394
- end
395
-
396
- # Bounds
397
- if @var_bounds.any?
398
- lines << 'Bounds'
399
- @variables.each do |name, _var|
400
- lb, ub = @var_bounds[name]
401
- sname = sanitize_name(name)
402
-
403
- if lb == ub
404
- lines << " #{sname} = #{format_bound(lb)}"
405
- elsif lb > -Float::INFINITY && ub < Float::INFINITY
406
- lines << " #{lb} <= #{sname} <= #{format_bound(ub)}"
407
- elsif lb > -Float::INFINITY
408
- lines << " #{sname} >= #{format_bound(lb)}"
409
- elsif ub < Float::INFINITY
410
- lines << " #{sname} <= #{format_bound(ub)}"
411
- end
412
- end
413
- end
414
-
415
- # Integer variables
416
- int_vars = @variables.select { |sym, _| @var_types[sym] == :integer }
417
- if int_vars.any?
418
- lines << 'Integers'
419
- int_vars.each { |name, _| lines << " #{sanitize_name(name)}" }
420
- end
421
-
422
- lines << 'End'
423
- lines.join("\n")
209
+ LpGenerator.new(self).generate
424
210
  end
425
211
 
426
212
  # Writes the model to an LP file.
@@ -433,28 +219,16 @@ module LpSolver
433
219
  File.write(filename, to_lp)
434
220
  end
435
221
 
436
- # Returns all variables defined in this model.
222
+ # Sets the solver driver.
437
223
  #
438
- # @return [Hash{Symbol => Variable}] Maps variable names (Symbols) to
439
- # their Variable objects.
440
- # @example
441
- # model.variables
442
- # # => { :x => @x(0), :y => @y(1) }
443
- def variables
444
- @variables
224
+ # @param driver [Drivers::CliDriver, Drivers::NativeDriver] The driver to use for solving.
225
+ def driver=(driver)
226
+ @driver = driver
445
227
  end
446
228
 
447
229
  private
448
230
 
449
- # Looks up a variable name by its internal index.
450
- #
451
- # @param idx [Integer] The internal variable index.
452
- # @return [String] The variable name, or "v#{idx}" if not found.
453
- def find_var_name(idx)
454
- @variables.find { |_, var| var.index == idx }&.first || "v#{idx}"
455
- end
456
-
457
- # Normalizes bound values for LP format output.
231
+ # Normalizes bound values for internal storage.
458
232
  #
459
233
  # Converts special infinity values to their canonical forms.
460
234
  #
@@ -463,82 +237,7 @@ module LpSolver
463
237
  def normalize_bound(val)
464
238
  return -Float::INFINITY if val == -Float::INFINITY || val == -1.0 / 0.0
465
239
  return Float::INFINITY if val == Float::INFINITY || val == 1.0 / 0.0
466
-
467
240
  val.to_f
468
241
  end
469
-
470
- # Formats a coefficient for LP output.
471
- #
472
- # Integers are output without decimal points for readability.
473
- #
474
- # @param value [Float] The coefficient value.
475
- # @return [String] The formatted coefficient string.
476
- def format_coeff(value)
477
- if value == value.to_i && value.abs < 1e15
478
- value.to_i.to_s
479
- else
480
- format('%.6g', value)
481
- end
482
- end
483
-
484
- # Formats a bound value for LP output.
485
- #
486
- # @param value [Float] The bound value.
487
- # @return [String] The formatted bound string (+Inf, -Inf, or numeric).
488
- def format_bound(value)
489
- return '+Inf' if value == Float::INFINITY
490
- return '-Inf' if value == -Float::INFINITY
491
-
492
- format_coeff(value)
493
- end
494
-
495
- # Sanitizes a name for LP format (no spaces, special characters).
496
- #
497
- # @param name [String, Symbol] The name to sanitize.
498
- # @return [String] The sanitized name (max 32 characters).
499
- def sanitize_name(name)
500
- name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')[0, 32]
501
- end
502
-
503
- # Parses the HiGHS solution file.
504
- #
505
- # Extracts variable values, objective value, and model status from
506
- # the solution file format produced by HiGHS.
507
- #
508
- # @param path [String] The path to the HiGHS solution file.
509
- # @return [Solution] The parsed solution object.
510
- def parse_solution_file(path)
511
- content = File.read(path)
512
- variables = {}
513
- objective_value = 0.0
514
- model_status = 'unknown'
515
- iterations = 0
516
-
517
- status_match = content.match(/Model status\s*\n\s*(\S+)/i)
518
- model_status = status_match[1].downcase.gsub('_', ' ') if status_match
519
-
520
- obj_match = content.match(/Objective\s+(\S+)/i)
521
- objective_value = obj_match[1].to_f if obj_match
522
-
523
- in_columns = false
524
- content.each_line do |line|
525
- if line =~ /# Columns/i
526
- in_columns = true
527
- next
528
- end
529
- next unless in_columns
530
- break if line.strip.empty? || line.start_with?('#')
531
-
532
- parts = line.strip.split(/\s+/, 2)
533
- variables[parts[0]] = parts[1].to_f if parts.length == 2
534
- end
535
-
536
- Solution.new(
537
- variables: variables,
538
- objective_value: objective_value,
539
- model_status: model_status,
540
- iterations: iterations
541
- )
542
- end
543
242
  end
544
243
  end
Binary file
@@ -21,11 +21,15 @@ module LpSolver
21
21
  # @return [Hash{String => Float}] Maps variable names to their optimal values.
22
22
  # The keys are the variable names as strings (as produced by HiGHS),
23
23
  # and the values are the optimal decision variable values.
24
+ # When the solution is infeasible, this hash is empty.
25
+ # Always check `infeasible?` or `unbounded?` before reading variable values.
24
26
  attr_reader :variables
25
27
 
26
28
  # @return [Float] The optimal objective function value.
27
29
  # For minimization problems, this is the minimum value.
28
30
  # For maximization problems, this is the maximum value.
31
+ # When the solution is infeasible, this returns `0.0`.
32
+ # Always check `infeasible?` or `unbounded?` before reading this value.
29
33
  attr_reader :objective_value
30
34
 
31
35
  # @return [String] The status of the model as reported by HiGHS.
@@ -40,6 +44,19 @@ module LpSolver
40
44
  # This is a diagnostic metric; may be 0 for some solver types.
41
45
  attr_reader :iterations
42
46
 
47
+ # Returns the model status as a Symbol.
48
+ #
49
+ # @return [Symbol] The solver status as a Ruby symbol:
50
+ # - :optimal — An optimal solution was found.
51
+ # - :infeasible — No feasible solution exists.
52
+ # - :unbounded — The objective can be improved without bound.
53
+ # - :unknown — The solver could not determine the status.
54
+ # @example
55
+ # solution.status # => :optimal
56
+ def status
57
+ @model_status.to_sym
58
+ end
59
+
43
60
  # Creates a new Solution object.
44
61
  #
45
62
  # @param variables [Hash{String => Float}] Maps variable names to values.
@@ -57,12 +74,15 @@ module LpSolver
57
74
  #
58
75
  # @param name [Symbol, String, Variable] The variable name (Symbol, String,
59
76
  # or Variable object).
60
- # @return [Float] The optimal value of the variable.
77
+ # @return [Float] The optimal value of the variable, or `nil` if the
78
+ # solution is infeasible or unbounded.
61
79
  # @raise [KeyError] If the variable name is not found in the solution.
62
80
  # @example
63
81
  # solution[:x] # => 4.0 (by symbol)
64
82
  # solution['x'] # => 4.0 (by string)
65
83
  # solution[x] # => 4.0 (by Variable object)
84
+ # @note When the solution is infeasible, all variables are empty.
85
+ # Check `infeasible?` first before accessing variable values.
66
86
  def [](name)
67
87
  key = if name.is_a?(Variable)
68
88
  name.name.to_s
@@ -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.0'
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