lpsolver 0.1.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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LpSolver
4
+ # Represents the solution returned by solving a model.
5
+ #
6
+ # The Solution object contains the optimal (or best-found) values for
7
+ # all decision variables, the optimal objective value, and metadata
8
+ # about the solver's execution.
9
+ #
10
+ # @example Accessing solution values
11
+ # solution = model.solve
12
+ # solution[:x] # => 4.0 (value of variable x)
13
+ # solution[:y] # => 0.0 (value of variable y)
14
+ # solution.objective_value # => 12.0 (optimal objective)
15
+ #
16
+ # @example Checking solution status
17
+ # solution.feasible? # => true
18
+ # solution.infeasible? # => false
19
+ # solution.unbounded? # => false
20
+ class Solution
21
+ # @return [Hash{String => Float}] Maps variable names to their optimal values.
22
+ # The keys are the variable names as strings (as produced by HiGHS),
23
+ # and the values are the optimal decision variable values.
24
+ attr_reader :variables
25
+
26
+ # @return [Float] The optimal objective function value.
27
+ # For minimization problems, this is the minimum value.
28
+ # For maximization problems, this is the maximum value.
29
+ attr_reader :objective_value
30
+
31
+ # @return [String] The status of the model as reported by HiGHS.
32
+ # Possible values include:
33
+ # - "optimal": An optimal solution was found.
34
+ # - "infeasible": No feasible solution exists.
35
+ # - "unbounded": The objective can be improved without bound.
36
+ # - "unknown": The solver could not determine the status.
37
+ attr_reader :model_status
38
+
39
+ # @return [Integer] The number of iterations the solver performed.
40
+ # This is a diagnostic metric; may be 0 for some solver types.
41
+ attr_reader :iterations
42
+
43
+ # Creates a new Solution object.
44
+ #
45
+ # @param variables [Hash{String => Float}] Maps variable names to values.
46
+ # @param objective_value [Float] The optimal objective value.
47
+ # @param model_status [String] The solver-reported model status.
48
+ # @param iterations [Integer] The number of solver iterations.
49
+ def initialize(variables:, objective_value:, model_status:, iterations:)
50
+ @variables = variables
51
+ @objective_value = objective_value
52
+ @model_status = model_status
53
+ @iterations = iterations
54
+ end
55
+
56
+ # Retrieves the value of a variable by name.
57
+ #
58
+ # @param name [Symbol, String] The variable name.
59
+ # @return [Float] The optimal value of the variable.
60
+ # @raise [KeyError] If the variable name is not found in the solution.
61
+ # @example
62
+ # solution[:x] # => 4.0
63
+ def [](name)
64
+ variables[name.to_s]
65
+ end
66
+
67
+ # Retrieves values for multiple variables by name.
68
+ #
69
+ # @param *names [Symbol, String] The variable names to retrieve.
70
+ # @return [Array<Float>] An array of variable values in the same order.
71
+ # @example
72
+ # solution.values_at(:x, :y) # => [4.0, 0.0]
73
+ # @param [Array<Object>] names
74
+ def values_at(*names)
75
+ names.map { |name| variables[name.to_s] }
76
+ end
77
+
78
+ # Checks if the solution is feasible.
79
+ #
80
+ # A solution is feasible if the solver found a solution that satisfies
81
+ # all constraints and bounds.
82
+ #
83
+ # @return [Boolean] True if the model status is "optimal".
84
+ def feasible?
85
+ @model_status == 'optimal'
86
+ end
87
+
88
+ # Checks if the model is infeasible.
89
+ #
90
+ # A model is infeasible if no solution exists that satisfies all
91
+ # constraints simultaneously.
92
+ #
93
+ # @return [Boolean] True if the model status is "infeasible".
94
+ def infeasible?
95
+ @model_status == 'infeasible'
96
+ end
97
+
98
+ # Checks if the model is unbounded.
99
+ #
100
+ # A model is unbounded if the objective can be improved indefinitely
101
+ # without violating any constraints.
102
+ #
103
+ # @return [Boolean] True if the model status is "unbounded".
104
+ def unbounded?
105
+ @model_status == 'unbounded'
106
+ end
107
+
108
+ # Returns a string representation of the solution.
109
+ #
110
+ # @return [String] A formatted string showing variable values and
111
+ # the objective value.
112
+ # @example
113
+ # puts solution
114
+ # # x = 4.0
115
+ # # y = 0.0
116
+ # # Objective: 12.0
117
+ def to_s
118
+ lines = variables.map { |name, value| "#{name} = #{value}" }
119
+ lines << "Objective: #{objective_value}"
120
+ lines.join("\n")
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LpSolver
4
+ # Represents a decision variable in an LP/MIP/QP model.
5
+ #
6
+ # Variables are the building blocks of optimization models. Each variable
7
+ # represents a quantity to be determined by the solver (e.g., production
8
+ # levels, investment amounts, resource allocations).
9
+ #
10
+ # Variables support arithmetic operators for building expressions and
11
+ # comparison operators for creating constraints. The operator overloading
12
+ # enables a natural, mathematical DSL:
13
+ #
14
+ # x = model.add_variable(:x, lb: 0)
15
+ # model.add_constraint(:budget, (x * 2 + y) <= 100)
16
+ #
17
+ # @note Variable * Variable produces a QuadraticExpression (for QP).
18
+ # Variable * Scalar produces a LinearExpression (for LP).
19
+ #
20
+ # @example Creating a variable
21
+ # x = model.add_variable(:x, lb: 0, ub: 100, integer: false)
22
+ # x.index # => 0
23
+ # x.name # => :x
24
+ #
25
+ # @example Using in expressions
26
+ # expr = x * 2 + y * 3 # LinearExpression
27
+ # quad = x * x + y * y # QuadraticExpression
28
+ # spec = (x * 2 + y) <= 100 # ConstraintSpec
29
+ class Variable
30
+ # @return [Integer] The internal index of this variable in the model.
31
+ # This is used internally to map the variable to a column in the
32
+ # solver's constraint matrix.
33
+ attr_reader :index
34
+
35
+ # @return [Symbol] The human-readable name of this variable.
36
+ attr_reader :name
37
+
38
+ # Creates a new Variable instance.
39
+ #
40
+ # @param index [Integer] The internal index assigned by the model.
41
+ # @param name [Symbol] The human-readable name of this variable.
42
+ def initialize(index, name)
43
+ @index = index
44
+ @name = name
45
+ end
46
+
47
+ # Multiplies this variable by a scalar or another variable.
48
+ #
49
+ # When multiplied by a Numeric, returns a LinearExpression with this
50
+ # variable scaled by the given coefficient. When multiplied by another
51
+ # Variable, returns a QuadraticExpression representing the product term
52
+ # (used for quadratic objectives in QP).
53
+ #
54
+ # @param other [Numeric, Variable] The multiplier.
55
+ # @return [LinearExpression] When +other+ is a Numeric.
56
+ # Example: `x * 2` → LinearExpression with terms {0 => 2.0}
57
+ # @return [QuadraticExpression] When +other+ is a Variable.
58
+ # Example: `x * y` → QuadraticExpression with terms [[0, 1, 1.0]]
59
+ def *(other)
60
+ if other.is_a?(Variable)
61
+ QuadraticExpression.new({}, [[@index, other.index, 1.0]])
62
+ else
63
+ LinearExpression.new({ @index => other.to_f })
64
+ end
65
+ end
66
+
67
+ # Adds another variable, expression, or constant to this variable.
68
+ #
69
+ # Creates a new LinearExpression containing the sum of this variable
70
+ # and the other operand.
71
+ #
72
+ # @param other [Variable, LinearExpression, Numeric] The operand to add.
73
+ # @return [LinearExpression] A new expression representing the sum.
74
+ # @example Adding two variables
75
+ # (x + y).terms # => {0 => 1.0, 1 => 1.0}
76
+ # @example Adding a constant
77
+ # (x + 5).constant # => 5.0
78
+ def +(other)
79
+ if other.is_a?(Variable)
80
+ LinearExpression.new({ @index => 1.0, other.index => 1.0 })
81
+ elsif other.is_a?(LinearExpression)
82
+ LinearExpression.new(merge_terms({ @index => 1.0 }, other.terms), other.constant)
83
+ else
84
+ LinearExpression.new({ @index => 1.0 }, other.to_f)
85
+ end
86
+ end
87
+
88
+ # Subtracts another variable, expression, or constant from this variable.
89
+ #
90
+ # Creates a new LinearExpression containing the difference between this
91
+ # variable and the other operand.
92
+ #
93
+ # @param other [Variable, LinearExpression, Numeric] The operand to subtract.
94
+ # @return [LinearExpression] A new expression representing the difference.
95
+ # @example Subtracting two variables
96
+ # (x - y).terms # => {0 => 1.0, 1 => -1.0}
97
+ # @example Subtracting a constant
98
+ # (x - 5).constant # => -5.0
99
+ def -(other)
100
+ if other.is_a?(Variable)
101
+ LinearExpression.new({ @index => 1.0, other.index => -1.0 })
102
+ elsif other.is_a?(LinearExpression)
103
+ LinearExpression.new(negate_add_terms({ @index => 1.0 }, other.terms), -other.constant)
104
+ else
105
+ LinearExpression.new({ @index => 1.0 }, -other.to_f)
106
+ end
107
+ end
108
+
109
+ # Returns a LinearExpression with this variable negated.
110
+ #
111
+ # @return [LinearExpression] A new expression with negated coefficients.
112
+ # @example
113
+ # (-x).terms # => {0 => -1.0}
114
+ def -@
115
+ LinearExpression.new({ @index => -1.0 })
116
+ end
117
+
118
+ # Creates a less-than-or-equal-to constraint specification.
119
+ #
120
+ # This is used with Model#add_constraint to define upper bounds on
121
+ # linear expressions. The constraint represents: expression <= value.
122
+ #
123
+ # @param value [Numeric] The right-hand side upper bound.
124
+ # @return [ConstraintSpec] A constraint specification with operator :le.
125
+ # @example
126
+ # model.add_constraint(:budget, (x * 2 + y) <= 100)
127
+ # @param [Object] other
128
+ def <=(other)
129
+ ConstraintSpec.new(:le, { @index => 1.0 }, 0, other.to_f)
130
+ end
131
+
132
+ # Creates a greater-than-or-equal-to constraint specification.
133
+ #
134
+ # This is used with Model#add_constraint to define lower bounds on
135
+ # linear expressions. The constraint represents: expression >= value.
136
+ #
137
+ # @param value [Numeric] The right-hand side lower bound.
138
+ # @return [ConstraintSpec] A constraint specification with operator :ge.
139
+ # @example
140
+ # model.add_constraint(:demand, (x + y * 2) >= 50)
141
+ # @param [Object] other
142
+ def >=(other)
143
+ ConstraintSpec.new(:ge, { @index => 1.0 }, 0, other.to_f)
144
+ end
145
+
146
+ # Creates an equality constraint specification.
147
+ #
148
+ # This is used with Model#add_constraint to define exact values for
149
+ # linear expressions. The constraint represents: expression == value.
150
+ #
151
+ # @param value [Numeric] The exact value the expression must equal.
152
+ # @return [ConstraintSpec] A constraint specification with operator :eq.
153
+ # @example
154
+ # model.add_constraint(:weights, (x + y + z) == 1)
155
+ # @param [Object] other
156
+ def ==(other)
157
+ ConstraintSpec.new(:eq, { @index => 1.0 }, 0, other.to_f)
158
+ end
159
+
160
+ # Checks if two variables refer to the same underlying variable.
161
+ #
162
+ # @param other [Object] The object to compare against.
163
+ # @return [Boolean] True if +other+ is a Variable with the same index.
164
+ def equals?(other)
165
+ other.is_a?(Variable) && other.index == @index
166
+ end
167
+
168
+ # Returns a string representation of this variable.
169
+ #
170
+ # @return [String] A string in the format "@name(index)".
171
+ # @example
172
+ # x.to_s # => "@x(0)"
173
+ def to_s
174
+ "@#{@name}(#{@index})"
175
+ end
176
+
177
+ # Converts this variable's name to a Symbol.
178
+ #
179
+ # @return [Symbol] The variable's name.
180
+ # @example
181
+ # x.to_sym # => :x
182
+ def to_sym
183
+ @name
184
+ end
185
+
186
+ # Returns the hash code based on this variable's index.
187
+ #
188
+ # @return [Integer] The hash code of the variable's index.
189
+ def hash
190
+ @index.hash
191
+ end
192
+
193
+ alias eql? equals?
194
+
195
+ private
196
+
197
+ # Merges two term hashes, combining coefficients for duplicate indices.
198
+ #
199
+ # @param a [Hash{Integer => Float}] First term hash.
200
+ # @param b [Hash{Integer => Float}] Second term hash.
201
+ # @return [Hash{Integer => Float}] The merged term hash with zero coefficients removed.
202
+ def merge_terms(a, b)
203
+ merged = a.dup
204
+ b.each { |idx, coeff| merged[idx] = (merged[idx] || 0) + coeff }
205
+ merged.reject! { |_, v| v.zero? }
206
+ merged
207
+ end
208
+
209
+ # Merges two term hashes with subtraction (a - b).
210
+ #
211
+ # @param a [Hash{Integer => Float}] First term hash.
212
+ # @param b [Hash{Integer => Float}] Second term hash to subtract.
213
+ # @return [Hash{Integer => Float}] The difference term hash with zero coefficients removed.
214
+ def negate_add_terms(a, b)
215
+ merged = a.dup
216
+ b.each { |idx, coeff| merged[idx] = (merged[idx] || 0) - coeff }
217
+ merged.reject! { |_, v| v.zero? }
218
+ merged
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LpSolver
4
+ # The current version of the LpSolver gem.
5
+ #
6
+ # @return [String] The version string (e.g., "0.1.0").
7
+ VERSION = '0.1.0'
8
+ end
data/lib/lpsolver.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # LpSolver - A Ruby gem for solving Linear Programming (LP), Quadratic
4
+ # Programming (QP), and Mixed Integer Programming (MIP) problems.
5
+ #
6
+ # This gem provides a Ruby DSL for building optimization models and
7
+ # interfaces with the HiGHS solver via its command-line interface.
8
+ #
9
+ # == Quick Start
10
+ #
11
+ # require 'lpsolver'
12
+ #
13
+ # model = LpSolver::Model.new
14
+ # x = model.add_variable(:x, lb: 0)
15
+ # y = model.add_variable(:y, lb: 0)
16
+ #
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
22
+ # puts solution.objective_value # => 12.0
23
+ #
24
+ # @see LpSolver::Model
25
+ # @see LpSolver::Variable
26
+ # @see LpSolver::Solution
27
+ module LpSolver
28
+ end
29
+
30
+ require_relative 'lpsolver/version'
31
+ require_relative 'lpsolver/exception'
32
+
33
+ # Core DSL classes (loaded in dependency order)
34
+ require_relative 'lpsolver/constraint_spec'
35
+ require_relative 'lpsolver/variable'
36
+ require_relative 'lpsolver/linear_expression'
37
+ require_relative 'lpsolver/quadratic_expression'
38
+
39
+ # Solvers and data classes
40
+ require_relative 'lpsolver/solution'
41
+ require_relative 'lpsolver/model'
data/lpsolver.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/lpsolver/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lpsolver'
7
+ spec.version = LpSolver::VERSION
8
+ spec.authors = ['David Siaw']
9
+ spec.email = ['874280+davidsiaw@users.noreply.github.com']
10
+
11
+ spec.summary = 'HiGHS LP/MIP/QP solver for Ruby'
12
+ spec.description = 'A Ruby gem providing access to the HiGHS linear, quadratic, and mixed-integer programming solver via CLI.'
13
+ spec.homepage = 'https://github.com/davidsiaw/lpsolver'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = Gem::Requirement.new('>= 3.0')
16
+
17
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
18
+
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/davidsiaw/lpsolver'
21
+ spec.metadata['changelog_uri'] = 'https://github.com/davidsiaw/lpsolver'
22
+ spec.metadata['documentation_uri'] = 'https://davidsiaw.github.io/lpsolver'
23
+ spec.metadata['rubygems_mfa_required'] = 'true'
24
+
25
+ spec.files = Dir['{exe,data,lib}/**/*'] + %w[Gemfile lpsolver.gemspec README.md LICENSE.txt]
26
+ spec.bindir = 'exe'
27
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ['lib']
29
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lpsolver
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - David Siaw
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-05-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A Ruby gem providing access to the HiGHS linear, quadratic, and mixed-integer
14
+ programming solver via CLI.
15
+ email:
16
+ - 874280+davidsiaw@users.noreply.github.com
17
+ executables:
18
+ - README.md
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - Gemfile
23
+ - LICENSE.txt
24
+ - README.md
25
+ - exe/README.md
26
+ - lib/lpsolver.rb
27
+ - lib/lpsolver/constraint_spec.rb
28
+ - lib/lpsolver/exception.rb
29
+ - lib/lpsolver/linear_expression.rb
30
+ - lib/lpsolver/model.rb
31
+ - lib/lpsolver/quadratic_expression.rb
32
+ - lib/lpsolver/solution.rb
33
+ - lib/lpsolver/variable.rb
34
+ - lib/lpsolver/version.rb
35
+ - lpsolver.gemspec
36
+ homepage: https://github.com/davidsiaw/lpsolver
37
+ licenses:
38
+ - MIT
39
+ metadata:
40
+ allowed_push_host: https://rubygems.org
41
+ homepage_uri: https://github.com/davidsiaw/lpsolver
42
+ source_code_uri: https://github.com/davidsiaw/lpsolver
43
+ changelog_uri: https://github.com/davidsiaw/lpsolver
44
+ documentation_uri: https://davidsiaw.github.io/lpsolver
45
+ rubygems_mfa_required: 'true'
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.5.22
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: HiGHS LP/MIP/QP solver for Ruby
65
+ test_files: []