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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9ff84f9d0bcf181ac202f5f25cf5754e04319e1bf459ab830af40f8e91c42bc7
4
+ data.tar.gz: f51148b23deca2cd3070726c78fc505d07bc3d9c3594e386cfbba4048e68187c
5
+ SHA512:
6
+ metadata.gz: 827cfb4a818544a8a90a47231ec46de845b973c4522f4e3496ce9c85d4b55f01fd9cfed0eaf206d93757db09208e61047b8106519b38285ce789940518cb8e11
7
+ data.tar.gz: 253710d8517bbbc6fd817cb15ad630b8d9949c89ea08f6c3a9b0f71d08e1abf627b2739ceca7f133904ddc310a5925073475a2cfdfcaac0c921db782cfa49820
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in lpsolver.gemspec
6
+ gemspec
7
+
8
+ # Specify development dependencies here and not in the gemspec
9
+ gem 'rake'
10
+ gem 'rspec'
11
+ gem 'rubocop'
12
+ gem 'rubocop-rake'
13
+ gem 'rubocop-rspec'
14
+ gem 'rubocop-yard'
15
+ gem 'yard'
16
+ gem 'yard-rspec'
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 You
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,345 @@
1
+ # LpSolver
2
+
3
+ A Ruby gem for solving optimization problems using the [HiGHS](https://github.com/ERGO-Code/HiGHS) solver.
4
+
5
+ ## What is this for?
6
+
7
+ 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.
8
+
9
+ You describe your problem in simple math, and the solver finds the optimal solution.
10
+
11
+ ### Real-world examples
12
+
13
+ - **Coin change**: You need exactly $9.99 in coins. Which combination uses the least weight?
14
+ - **Diet plan**: You need 2000 calories, 50g protein, and 100g carbs per day. What combination of foods costs the least?
15
+ - **Factory**: You have limited materials and labor. How many of each product should you make to maximize profit?
16
+ - **Investment**: You want to split money across stocks, bonds, and gold. How do you minimize risk while earning a target return?
17
+
18
+ ## Linear Programming (LP)
19
+
20
+ **LP** is for problems where everything scales in a straight line. If making one widget earns $5, making two earns $10. There are no "bulk discounts" or "diminishing returns" — just simple multiplication.
21
+
22
+ **Use LP when:**
23
+ - Your goal is a simple sum: `total_cost = price_a * qty_a + price_b * qty_b`
24
+ - Your rules are simple: `total_cost <= budget`, `qty_a + qty_b >= 100`
25
+
26
+ ### LP Example: Diet Problem
27
+
28
+ ```ruby
29
+ require 'lpsolver'
30
+
31
+ model = LpSolver::Model.new
32
+
33
+ bread = model.add_variable(:bread, lb: 0) # how many slices
34
+ milk = model.add_variable(:milk, lb: 0) # how many liters
35
+
36
+ # Rules first
37
+ model.add_constraint(:calories, (bread * 80 + milk * 60) >= 2000)
38
+ model.add_constraint(:protein, (bread * 3 + milk * 3.2) >= 50)
39
+
40
+ # Then solve
41
+ solution = model.minimize!(bread * 0.50 + milk * 1.20)
42
+
43
+ puts solution.objective_value # minimum cost
44
+ ```
45
+
46
+ ## Quadratic Programming (QP)
47
+
48
+ **QP** is like LP, but your goal can involve multiplying variables together. This is useful when the "cost" or "risk" depends on how things interact, not just their individual values.
49
+
50
+ The most common use: **minimizing variance** (risk) in a portfolio. If Stock A and Stock B tend to move together, the combined risk isn't just the sum of their individual risks — it also depends on how they correlate.
51
+
52
+ **Use QP when:**
53
+ - Your goal involves squares or products: `risk = x² + y² + 2xy`
54
+ - You need to minimize deviation: `(actual - target)²`
55
+
56
+ ### QP Example: Portfolio Optimization
57
+
58
+ ```ruby
59
+ require 'lpsolver'
60
+
61
+ model = LpSolver::Model.new
62
+
63
+ tech = model.add_variable(:tech, lb: 0)
64
+ bonds = model.add_variable(:bonds, lb: 0)
65
+ gold = model.add_variable(:gold, lb: 0)
66
+
67
+ # Rules first
68
+ model.add_constraint(:all_money, (tech + bonds + gold) == 1)
69
+ model.add_constraint(:target_return, (tech * 0.15 + bonds * 0.05 + gold * 0.08) >= 0.10)
70
+
71
+ # Then solve — minimize portfolio variance (risk)
72
+ solution = model.minimize!(
73
+ tech * tech * 0.04 + # tech variance
74
+ bonds * bonds * 0.0025 + # bonds variance
75
+ gold * gold * 0.01 + # gold variance
76
+ (tech * bonds) * 0.002 + # tech-bonds interaction
77
+ (tech * gold) * (-0.004) # tech-gold interaction (negative = they move apart)
78
+ )
79
+
80
+ puts "Std dev: #{(Math.sqrt(solution.objective_value) * 100).round(2)}%"
81
+ ```
82
+
83
+ ## LP vs QP: Quick Comparison
84
+
85
+ | | LP | QP |
86
+ |---|---|---|
87
+ | **Goal** | Simple sum: `2x + 3y` | Includes products: `x² + 2xy + y²` |
88
+ | **Best for** | Cost, profit, weight, time | Risk, variance, error, deviation |
89
+ | **Speed** | Very fast | Fast |
90
+ | **Example** | "Minimize shipping cost" | "Minimize investment risk" |
91
+
92
+ ## Installation
93
+
94
+ Add to your Gemfile:
95
+
96
+ ```ruby
97
+ gem 'lpsolver'
98
+ ```
99
+
100
+ Then:
101
+
102
+ ```bash
103
+ bundle install
104
+ ```
105
+
106
+ Or run directly from source:
107
+
108
+ ```bash
109
+ ruby -Ilib examples/coin_purse.rb
110
+ ```
111
+
112
+ ## Prerequisites
113
+
114
+ HiGHS must be installed:
115
+
116
+ ```bash
117
+ # Ubuntu/Debian
118
+ sudo apt install highs
119
+
120
+ # macOS
121
+ brew install highs
122
+
123
+ # Or from source
124
+ git clone https://github.com/ERGO-Code/HiGHS.git
125
+ cd HiGHS && mkdir build && cd build
126
+ cmake .. -DCMAKE_BUILD_TYPE=Release
127
+ make -j$(nproc)
128
+ sudo make install
129
+ ```
130
+
131
+ Or set a custom path:
132
+
133
+ ```bash
134
+ export HIGHS_PATH=/path/to/highs
135
+ ```
136
+
137
+ ## Usage
138
+
139
+ ### Simple Example (Operator DSL)
140
+
141
+ The recommended approach uses Ruby operators for natural, readable code. Build the model first, then call `minimize!` or `maximize!` at the end — it sets the objective, picks the direction, and solves in one call:
142
+
143
+ ```ruby
144
+ require 'lpsolver'
145
+
146
+ model = LpSolver::Model.new
147
+
148
+ # 1. Add variables
149
+ x = model.add_variable(:x, lb: 0)
150
+ y = model.add_variable(:y, lb: 0)
151
+
152
+ # 2. Add constraints
153
+ model.add_constraint(:budget, (x * 2 + y) <= 100)
154
+ model.add_constraint(:demand, (x + y * 2) >= 50)
155
+
156
+ # 3. Solve
157
+ solution = model.minimize!(x * 3 + y * 5)
158
+
159
+ puts "Cost: $#{solution.objective_value}"
160
+ puts "x = #{solution[:x]}"
161
+ puts "y = #{solution[:y]}"
162
+ ```
163
+
164
+ ### Integer (MIP) Example
165
+
166
+ Some problems require whole numbers only (you can't make half a car):
167
+
168
+ ```ruby
169
+ model = LpSolver::Model.new
170
+
171
+ # integer: true means the value must be a whole number
172
+ car = model.add_variable(:car, lb: 0, integer: true)
173
+ bike = model.add_variable(:bike, lb: 0, integer: true)
174
+
175
+ model.add_constraint(:vehicles, (car + bike) >= 10)
176
+ model.add_constraint(:wheels, (car * 4 + bike * 2) >= 24)
177
+
178
+ solution = model.minimize!(car * 30 + bike * 5) # minimize cost
179
+ puts solution
180
+ ```
181
+
182
+ ### Maximization
183
+
184
+ ```ruby
185
+ model = LpSolver::Model.new
186
+ x = model.add_variable(:x, lb: 0)
187
+ y = model.add_variable(:y, lb: 0)
188
+
189
+ model.add_constraint(:c1, (x + y) <= 10)
190
+ solution = model.maximize!(x * 3 + y * 5)
191
+ puts solution.objective_value # => 50.0
192
+ ```
193
+
194
+ ### Complex Expressions
195
+
196
+ You can chain operators with constants and unary minus:
197
+
198
+ ```ruby
199
+ x = model.add_variable(:x, lb: 0)
200
+ y = model.add_variable(:y, lb: 0)
201
+
202
+ # Arithmetic
203
+ expr = x * 2 + y * 3 # LinearExpression
204
+ expr = x * 2 + y * 3 + 5 # add constant
205
+ expr = x * 2 - y * 3 - 5 # subtract
206
+ expr = -(x * 2 + y * 3) # negate
207
+
208
+ # Constraints
209
+ model.add_constraint(:c, (x * 2 + y * 3 + 5) <= 100)
210
+ model.add_constraint(:c, (x * 2 + y * 3 + 5) >= 100)
211
+ model.add_constraint(:c, (x * 2 + y * 3 + 5) == 100)
212
+ ```
213
+
214
+ ### Export LP Format
215
+
216
+ ```ruby
217
+ model = LpSolver::Model.new
218
+ x = model.add_variable(:x, lb: 0)
219
+ y = model.add_variable(:y, lb: 0)
220
+
221
+ model.add_constraint(:c1, (x * 2 + y) <= 10)
222
+ model.set_objective(x + y)
223
+
224
+ # Print the LP file format
225
+ puts model.to_lp
226
+
227
+ # Or write to a file
228
+ model.write_lp('model.lp')
229
+ ```
230
+
231
+ ## DSL Quick Reference
232
+
233
+ ### Variable (from `model.add_variable`)
234
+
235
+ | Operator | Example | Result |
236
+ |----------|---------|--------|
237
+ | `*` | `x * 2` | Linear expression (2x) |
238
+ | `*` | `x * y` | Quadratic term (xy) |
239
+ | `+` | `x + y`, `x + 5` | Sum or constant offset |
240
+ | `-` | `x - y`, `x - 5` | Difference or negative constant |
241
+ | `-` | `-x` | Negated expression |
242
+ | `<=` | `x + y <= 10` | Upper bound constraint |
243
+ | `>=` | `x + y >= 5` | Lower bound constraint |
244
+ | `==` | `x + y == 10` | Exact equality constraint |
245
+
246
+ ### LinearExpression (from arithmetic)
247
+
248
+ | Operator | Example | Result |
249
+ |----------|---------|--------|
250
+ | `*` | `expr * 2` | Scaled expression |
251
+ | `+` | `expr + y`, `expr + 5` | Combined expression |
252
+ | `-` | `expr - y`, `expr - 5` | Difference expression |
253
+ | `-` | `-expr` | Negated expression |
254
+ | `<=`, `>=`, `==` | `expr <= 10` | Constraint |
255
+
256
+ ### QuadraticExpression (from `Variable * Variable`)
257
+
258
+ | Operator | Example | Result |
259
+ |----------|---------|--------|
260
+ | `*` | `quad * 2` | Scaled expression |
261
+ | `+` | `quad + expr`, `quad + 5` | Combined expression |
262
+ | `-` | `quad - expr`, `quad - 5` | Difference expression |
263
+ | `-` | `-quad` | Negated expression |
264
+
265
+ > **Note:** `Variable * Variable` creates a quadratic term. `Variable * Scalar` creates a linear term. To scale a quadratic term, use `(x * y) * 2` (not `2 * (x * y)`).
266
+
267
+ ## API Reference
268
+
269
+ ### `Model#add_variable(name, lb: 0, ub: Float::INFINITY, integer: false)`
270
+ Add a variable. Returns a `Variable` object.
271
+ - `name` — variable name (Symbol or String)
272
+ - `lb` — lower bound (default: 0)
273
+ - `ub` — upper bound (default: no limit)
274
+ - `integer` — set to `true` for whole-number-only variables
275
+
276
+ ### `Model#add_constraint(name, expr, lb: -Float::INFINITY, ub: Float::INFINITY)`
277
+ Add a constraint. `expr` can be:
278
+ - A `ConstraintSpec` from comparison operators: `(x * 2 + y) <= 100`
279
+ - An array of `[variable_index, coefficient]` pairs (legacy format)
280
+
281
+ ### `Model#minimize!(objective)`
282
+ Set the objective to minimize and solve in one call.
283
+
284
+ ### `Model#maximize!(objective)`
285
+ Set the objective to maximize and solve in one call.
286
+
287
+ ### `Model#minimize`
288
+ Set the optimization sense to minimization (legacy, use `minimize!` instead).
289
+
290
+ ### `Model#maximize`
291
+ Set the optimization sense to maximization (legacy, use `maximize!` instead).
292
+
293
+ ### `Model#set_objective(objective)`
294
+ Set the objective function without solving (legacy, use `minimize!`/`maximize!` instead).
295
+ - `objective` can be a `LinearExpression`, `QuadraticExpression`, or Hash.
296
+
297
+ ### `Model#to_lp`
298
+ Returns the model as a HiGHS LP format string.
299
+
300
+ ### `Model#write_lp(filename)`
301
+ Writes the model to an LP file.
302
+
303
+ ### `Model#solve`
304
+ Solves the model without setting an objective (legacy).
305
+
306
+ ### `Solution#[]`
307
+ Get a variable's value: `solution[:x]`
308
+
309
+ ### `Solution#values_at(*names)`
310
+ Get multiple values: `solution.values_at(:x, :y)`
311
+
312
+ ### `Solution#objective_value`
313
+ The optimal (best) objective value.
314
+
315
+ ### `Solution#feasible?`
316
+ True if a valid solution was found.
317
+
318
+ ### `Solution#infeasible?`
319
+ True if no solution satisfies all constraints.
320
+
321
+ ### `Solution#unbounded?`
322
+ True if the objective can improve without limit.
323
+
324
+ ## Examples
325
+
326
+ - `examples/coin_purse.rb` — Minimize coin weight for exactly $9.99 (MIP)
327
+ - `examples/diet_problem.rb` — Minimize food cost while meeting nutrition (LP)
328
+ - `examples/factory.rb` — Maximize factory profit (LP)
329
+ - `examples/portfolio.rb` — Minimize investment risk (QP)
330
+
331
+ ## Supported Problem Types
332
+
333
+ | Type | Goal | Constraints | Whole Numbers? |
334
+ |------|------|-------------|----------------|
335
+ | LP | Linear sum | Linear | No |
336
+ | QP | Quadratic (squares/products) | Linear | No |
337
+ | MIP | Linear | Linear | Yes |
338
+
339
+ ## License
340
+
341
+ [MIT License](https://opensource.org/licenses/MIT)
342
+
343
+ ## Code of Conduct
344
+
345
+ See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
data/exe/README.md ADDED
@@ -0,0 +1 @@
1
+ # Executables go here
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LpSolver
4
+ # Represents a constraint specification derived from comparing an expression with a value.
5
+ #
6
+ # ConstraintSpec objects are created when comparison operators (<=, >=, ==) are
7
+ # applied to LinearExpression or Variable objects. They encode the constraint's
8
+ # operator type, variable coefficients, and bounds.
9
+ #
10
+ # A constraint specification has the form:
11
+ # operator: sum(coeff_i * x_i) + constant_offset compared to rhs
12
+ #
13
+ # The bounds are computed by rearranging the expression to isolate the
14
+ # variables on the left-hand side:
15
+ # sum(coeff_i * x_i) <= (rhs - constant_offset) for <= constraints
16
+ # sum(coeff_i * x_i) >= (rhs - constant_offset) for >= constraints
17
+ # sum(coeff_i * x_i) == (rhs - constant_offset) for == constraints
18
+ #
19
+ # @example Creating a constraint specification
20
+ # x = model.add_variable(:x, lb: 0)
21
+ # y = model.add_variable(:y, lb: 0)
22
+ # spec = (x * 2 + y * 3 + 5) <= 100
23
+ # spec.operator # => :le
24
+ # spec.terms # => {x_idx => 2.0, y_idx => 3.0}
25
+ # spec.lhs_constant # => 5.0
26
+ # spec.rhs # => 100.0
27
+ # spec.bounds # => [-Infinity, 95.0]
28
+ #
29
+ # @example Using in a model
30
+ # model.add_constraint(:budget, (x * 2 + y * 3 + 5) <= 100)
31
+ class ConstraintSpec
32
+ # @return [Symbol] The constraint operator: :le (<=), :ge (>=), or :eq (==).
33
+ attr_reader :operator
34
+
35
+ # @return [Hash{Integer => Float}] Maps variable indices to their coefficients
36
+ # in the expression (excluding the constant offset).
37
+ attr_reader :terms
38
+
39
+ # @return [Float] The constant offset on the left-hand side of the comparison.
40
+ # For example, in `(x * 2 + y * 3 + 5) <= 100`, this is 5.0.
41
+ attr_reader :lhs_constant
42
+
43
+ # @return [Float] The right-hand side value of the comparison.
44
+ # For example, in `(x * 2 + y * 3 + 5) <= 100`, this is 100.0.
45
+ attr_reader :rhs
46
+
47
+ # Creates a new ConstraintSpec.
48
+ #
49
+ # @param operator [Symbol] The constraint operator: :le, :ge, or :eq.
50
+ # @param terms [Hash{Integer => Float}] Maps variable indices to coefficients.
51
+ # @param lhs_constant [Float] The constant offset on the left-hand side.
52
+ # @param rhs [Float] The right-hand side value.
53
+ def initialize(operator, terms, lhs_constant, rhs)
54
+ @operator = operator
55
+ @terms = terms
56
+ @lhs_constant = lhs_constant
57
+ @rhs = rhs
58
+ end
59
+
60
+ # Converts this constraint specification to lower and upper bounds.
61
+ #
62
+ # Rearranges the constraint expression to isolate the variable terms,
63
+ # computing the effective bounds for the expression.
64
+ #
65
+ # @return [Array<Float, Float>] An array [lb, ub] representing the
66
+ # lower and upper bounds for the variable terms.
67
+ # @example For (x * 2 + y * 3 + 5) <= 100
68
+ # spec.bounds # => [-Infinity, 95.0]
69
+ # @example For (x * 2 + y * 3 + 5) >= 50
70
+ # spec.bounds # => [45.0, Infinity]
71
+ # @example For (x * 2 + y * 3 + 5) == 75
72
+ # spec.bounds # => [70.0, 70.0]
73
+ def bounds
74
+ case @operator
75
+ when :le
76
+ [-Float::INFINITY, @rhs - @lhs_constant]
77
+ when :ge
78
+ [@rhs - @lhs_constant, Float::INFINITY]
79
+ when :eq
80
+ v = @rhs - @lhs_constant
81
+ [v, v]
82
+ end
83
+ end
84
+
85
+ # Returns the expression terms as an array of [variable_index, coefficient] pairs.
86
+ #
87
+ # This format is used internally when serializing the model to HiGHS LP format.
88
+ #
89
+ # @return [Array<[Integer, Float]>] Array of [var_index, coefficient] pairs.
90
+ # @example
91
+ # spec.expr # => [[0, 2.0], [1, 3.0]]
92
+ def expr
93
+ @terms.map { |idx, coeff| [idx, coeff] }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LpSolver
4
+ # The base exception class for all LpSolver errors.
5
+ #
6
+ # All LpSolver-specific exceptions inherit from this class.
7
+ #
8
+ # @example Rescuing LpSolver errors
9
+ # begin
10
+ # model.solve
11
+ # rescue LpSolver::Error => e
12
+ # puts "Solver error: #{e.message}"
13
+ # end
14
+ class Error < StandardError; end
15
+
16
+ # Raised when the HiGHS solver encounters an error.
17
+ #
18
+ # This exception is raised when the HiGHS command-line tool exits
19
+ # with a non-zero status, indicating a problem with the model
20
+ # (e.g., syntax error, infeasibility not handled, etc.).
21
+ #
22
+ # @example
23
+ # begin
24
+ # model.solve
25
+ # rescue LpSolver::SolverError => e
26
+ # puts "HiGHS error: #{e.message}"
27
+ # puts "Stderr: #{e.stderr}" if e.stderr
28
+ # end
29
+ class SolverError < Error
30
+ # @return [String, nil] The stderr output from the HiGHS solver.
31
+ attr_reader :stderr
32
+
33
+ # Creates a new SolverError.
34
+ #
35
+ # @param message [String] The error message.
36
+ # @param stderr [String, nil] The stderr output from HiGHS.
37
+ def initialize(message, stderr: nil)
38
+ @stderr = stderr
39
+ super(message)
40
+ end
41
+ end
42
+
43
+ # Raised when the HiGHS binary cannot be found.
44
+ #
45
+ # This exception is raised when the HIGHS_PATH environment variable
46
+ # is not set and 'highs' is not on the system PATH.
47
+ #
48
+ # @example
49
+ # begin
50
+ # model.solve
51
+ # rescue LpSolver::NotFoundError => e
52
+ # puts "HiGHS not found. Install it or set HIGHS_PATH."
53
+ # end
54
+ class NotFoundError < Error; end
55
+ end