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,520 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+
5
+ module LpSolver
6
+ # A high-level interface to HiGHS for building and solving LP/QP/MIP models.
7
+ #
8
+ # 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:
26
+ #
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
+ class Model
73
+ # The path to the HiGHS binary.
74
+ #
75
+ # Set via the HIGHS_PATH environment variable, or defaults to 'highs'
76
+ # on the system PATH. This is used to invoke the HiGHS solver via
77
+ # the command line.
78
+ #
79
+ # @return [String] The path to the HiGHS executable.
80
+ HIGHS_PATH = ENV.fetch('HIGHS_PATH', 'highs')
81
+
82
+ # Creates a new empty LP/QP/MIP model.
83
+ #
84
+ # @param name [String] An optional name for this model, used for
85
+ # debugging and identification in logs. Defaults to 'untitled'.
86
+ def initialize(name = nil)
87
+ @name = name || 'untitled'
88
+ @variables = {} # { symbol => Variable }
89
+ @constraints = {} # { symbol => index }
90
+ @var_counter = 0
91
+ @constr_counter = 0
92
+ @sense = :minimize
93
+ @solution = nil
94
+ @objective = {} # { var_index => coefficient }
95
+ @quadratic_terms = [] # [[var1_idx, var2_idx, coefficient], ...]
96
+ @var_types = {} # { symbol => :continuous | :integer }
97
+ @var_bounds = {} # { symbol => [lb, ub] }
98
+ @constraints_data = {} # { symbol => { lb:, ub:, expr: [[var_idx, coeff], ...] } }
99
+ end
100
+
101
+ # Adds a variable to the model.
102
+ #
103
+ # Variables represent the decision quantities to be determined by the
104
+ # solver. Each variable is assigned a unique internal index and can be
105
+ # used in expressions via arithmetic operators.
106
+ #
107
+ # @param name [Symbol, String] The name of the variable. This is used
108
+ # for identification in the LP format output and solution results.
109
+ # @param lb [Float] The lower bound for the variable (default: 0.0).
110
+ # Use -Float::INFINITY for no lower bound.
111
+ # @param ub [Float] The upper bound for the variable (default: Float::INFINITY).
112
+ # Use Float::INFINITY for no upper bound. Setting lb == ub fixes the variable.
113
+ # @param integer [Boolean] Whether the variable must take integer values
114
+ # (default: false). When true, the model becomes a MIP problem.
115
+ # @return [Variable] The variable object, which supports arithmetic and
116
+ # comparison operators for building expressions and constraints.
117
+ # @example Adding a continuous variable
118
+ # x = model.add_variable(:x, lb: 0)
119
+ # @example Adding an integer variable
120
+ # count = model.add_variable(:count, lb: 0, integer: true)
121
+ # @example Adding a fixed variable
122
+ # capacity = model.add_variable(:capacity, lb: 100, ub: 100)
123
+ def add_variable(name, lb: 0.0, ub: Float::INFINITY, integer: false)
124
+ name = name.to_sym
125
+ idx = @var_counter
126
+ var = Variable.new(idx, name)
127
+ @variables[name] = var
128
+ @var_types[name] = integer ? :integer : :continuous
129
+ @var_bounds[name] = [normalize_bound(lb), normalize_bound(ub)]
130
+ @var_counter += 1
131
+ var
132
+ end
133
+
134
+ # Adds a constraint to the model.
135
+ #
136
+ # Constraints define the feasible region of the optimization problem.
137
+ # They can be specified using the DSL (comparison operators) or the
138
+ # legacy array format.
139
+ #
140
+ # @param name [Symbol, String] The name of the constraint.
141
+ # @param expr [ConstraintSpec, Array<[Integer, Float]>] The constraint
142
+ # specification. Can be either:
143
+ # - A ConstraintSpec from comparison operators: (x * 2 + y) <= 100
144
+ # - An array of [var_index, coefficient] pairs with explicit bounds
145
+ # @param lb [Float] Lower bound for the constraint (used with array-style expr).
146
+ # Default: -Float::INFINITY.
147
+ # @param ub [Float] Upper bound for the constraint (used with array-style expr).
148
+ # Default: Float::INFINITY.
149
+ # @return [Symbol] The constraint name.
150
+ # @example Using DSL comparison operators
151
+ # model.add_constraint(:budget, (x * 2 + y) <= 100)
152
+ # model.add_constraint(:demand, (x + y * 2) >= 50)
153
+ # model.add_constraint(:balance, (x + y) == 10)
154
+ # @example Using legacy array format
155
+ # model.add_constraint(:budget, [[x.index, 2], [y.index, 1]], ub: 100)
156
+ def add_constraint(name, expr, lb: -Float::INFINITY, ub: Float::INFINITY)
157
+ name = name.to_sym
158
+
159
+ if expr.is_a?(ConstraintSpec)
160
+ lb_val, ub_val = expr.bounds
161
+ data_expr = expr.expr
162
+ else
163
+ lb_val = lb
164
+ ub_val = ub
165
+ data_expr = expr
166
+ end
167
+
168
+ idx = @constr_counter
169
+ @constraints[name] = idx
170
+ @constraints_data[name] = {
171
+ lb: normalize_bound(lb_val),
172
+ ub: normalize_bound(ub_val),
173
+ expr: data_expr
174
+ }
175
+ @constr_counter += 1
176
+ name
177
+ end
178
+
179
+ # Sets the optimization sense to minimization.
180
+ #
181
+ # @return [void]
182
+ # @see #maximize
183
+ def minimize
184
+ @sense = :minimize
185
+ end
186
+
187
+ # Sets the optimization sense to maximization.
188
+ #
189
+ # @return [void]
190
+ # @see #minimize
191
+ def maximize
192
+ @sense = :maximize
193
+ end
194
+
195
+ # Sets the objective function for the model.
196
+ #
197
+ # The objective function defines what the solver should optimize.
198
+ # It can be a linear expression (for LP), a quadratic expression
199
+ # (for QP), or a hash of coefficients (legacy format).
200
+ #
201
+ # @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
202
+ # The objective function. Can be:
203
+ # - A LinearExpression: `x * 3 + y * 5`
204
+ # - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
205
+ # - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
206
+ # @return [void]
207
+ # @example Linear objective
208
+ # model.set_objective(x * 3 + y * 5)
209
+ # @example Quadratic objective (QP)
210
+ # model.set_objective(x * x + y * y)
211
+ def set_objective(objective)
212
+ if objective.is_a?(QuadraticExpression)
213
+ @objective = objective.linear_terms.transform_values(&:to_f)
214
+ @quadratic_terms = objective.hessian_entries
215
+ elsif objective.is_a?(LinearExpression)
216
+ @objective = objective.terms.transform_values(&:to_f)
217
+ @quadratic_terms = []
218
+ else
219
+ @objective = objective.transform_values { |v| v.is_a?(Variable) ? 1.0 : v.to_f }
220
+ @quadratic_terms = []
221
+ end
222
+ end
223
+
224
+ # Solves the model and returns the solution.
225
+ #
226
+ # Serializes the model to HiGHS LP format, invokes the HiGHS solver,
227
+ # and parses the solution file to return a Solution object.
228
+ #
229
+ # @return [Solution] The solution object containing variable values,
230
+ # objective value, and model status.
231
+ # @raise [SolverError] If the HiGHS solver encounters an error.
232
+ # @example
233
+ # solution = model.solve
234
+ # solution[:x] # => optimal value for variable x
235
+ # solution.objective_value # => optimal objective value
236
+ # solution.feasible? # => true
237
+ def solve
238
+ lp_content = to_lp
239
+ lp_file = Tempfile.new(['model', '.lp'])
240
+ lp_file.write(lp_content)
241
+ lp_file.close
242
+
243
+ solution_file = Tempfile.new(['solution', '.sol'])
244
+ opts_file = Tempfile.new(['highs_opts', '.txt'])
245
+ opts_file.write("log_to_console = false\noutput_flag = false\n")
246
+ opts_file.close
247
+
248
+ cmd = "#{self.class::HIGHS_PATH} " \
249
+ "--model_file #{lp_file.path} " \
250
+ "--options_file #{opts_file.path} " \
251
+ "--solution_file #{solution_file.path}"
252
+
253
+ output = `#{cmd} 2>&1`
254
+ status = $?.success?
255
+
256
+ lp_file.unlink
257
+ opts_file.unlink
258
+
259
+ raise SolverError, "HiGHS solver failed:\n#{output}" unless status
260
+
261
+ @solution = parse_solution_file(solution_file.path)
262
+ solution_file.unlink
263
+ @solution
264
+ end
265
+
266
+ # Sets the optimization sense to minimization, sets the objective,
267
+ # and solves the model in a single call.
268
+ #
269
+ # This is a convenience method that combines #minimize, #set_objective,
270
+ # and #solve into one step.
271
+ #
272
+ # @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
273
+ # The objective function to minimize. Can be:
274
+ # - A LinearExpression: `x * 3 + y * 5`
275
+ # - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
276
+ # - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
277
+ # @return [Solution] The solution object containing variable values,
278
+ # objective value, and model status.
279
+ # @raise [SolverError] If the HiGHS solver encounters an error.
280
+ # @example
281
+ # solution = model.minimize!(x * 3 + y * 5)
282
+ # puts solution.objective_value # => optimal (minimum) value
283
+ # @example Quadratic minimization (QP)
284
+ # solution = model.minimize!(x * x + y * y)
285
+ def minimize!(objective)
286
+ @sense = :minimize
287
+ set_objective(objective)
288
+ solve
289
+ end
290
+
291
+ # Sets the optimization sense to maximization, sets the objective,
292
+ # and solves the model in a single call.
293
+ #
294
+ # This is a convenience method that combines #maximize, #set_objective,
295
+ # and #solve into one step.
296
+ #
297
+ # @param objective [LinearExpression, QuadraticExpression, Hash{Variable|Integer => Float}]
298
+ # The objective function to maximize. Can be:
299
+ # - A LinearExpression: `x * 3 + y * 5`
300
+ # - A QuadraticExpression: `x * x + y * y + (x * y) * 2`
301
+ # - A Hash mapping variable indices to coefficients: `{ x.index => 3.0, y.index => 5.0 }`
302
+ # @return [Solution] The solution object containing variable values,
303
+ # objective value, and model status.
304
+ # @raise [SolverError] If the HiGHS solver encounters an error.
305
+ # @example
306
+ # solution = model.maximize!(x * 3 + y * 5)
307
+ # puts solution.objective_value # => optimal (maximum) value
308
+ def maximize!(objective)
309
+ @sense = :maximize
310
+ set_objective(objective)
311
+ solve
312
+ end
313
+
314
+ # Converts the model to HiGHS LP format string.
315
+ #
316
+ # The LP format is a text-based representation of the optimization
317
+ # problem that HiGHS can read. It includes the objective function,
318
+ # constraints, variable bounds, and integer declarations.
319
+ #
320
+ # @return [String] The LP format content.
321
+ # @example
322
+ # puts model.to_lp
323
+ # # Minimize
324
+ # # obj: 3 x + 5 y
325
+ # # Subject To
326
+ # # budget: 2 x + 1 y <= 100
327
+ # # demand: 1 x + 2 y >= 50
328
+ # # Bounds
329
+ # # 0 <= x <= +Inf
330
+ # # 0 <= y <= +Inf
331
+ # # End
332
+ def to_lp
333
+ lines = []
334
+
335
+ # Objective
336
+ lines << (@sense == :minimize ? 'Minimize' : 'Maximize')
337
+
338
+ obj_terms = @objective.map do |var_idx, coeff|
339
+ var_name = find_var_name(var_idx)
340
+ "#{format_coeff(coeff)} #{sanitize_name(var_name)}"
341
+ end.join(' + ')
342
+
343
+ if @quadratic_terms.any?
344
+ quad_parts = @quadratic_terms.map do |i1, i2, coeff|
345
+ n1 = sanitize_name(find_var_name(i1))
346
+ n2 = sanitize_name(find_var_name(i2))
347
+ if i1 == i2
348
+ "#{format_coeff(coeff)} #{n1} ^ 2"
349
+ else
350
+ "#{format_coeff(coeff)} #{n1} * #{n2}"
351
+ end
352
+ end.join(' + ')
353
+ lines << " obj: #{obj_terms} + [ #{quad_parts} ] / 2"
354
+ else
355
+ lines << " obj: #{obj_terms}"
356
+ end
357
+
358
+ # Constraints
359
+ if @constraints.any?
360
+ lines << 'Subject To'
361
+ @constraints.each do |name, _idx|
362
+ data = @constraints_data[name]
363
+ terms = data[:expr].map do |var_idx, coeff|
364
+ var_name = find_var_name(var_idx)
365
+ "#{format_coeff(coeff)} #{sanitize_name(var_name)}"
366
+ end.join(' + ')
367
+
368
+ if data[:lb] == -Float::INFINITY && data[:ub] == Float::INFINITY
369
+ lines << " #{sanitize_name(name)}: #{terms} free"
370
+ elsif data[:lb] == -Float::INFINITY
371
+ lines << " #{sanitize_name(name)}: #{terms} <= #{format_bound(data[:ub])}"
372
+ elsif data[:ub] == Float::INFINITY
373
+ lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
374
+ elsif (data[:ub] - data[:lb]).abs < 1e-12
375
+ lines << " #{sanitize_name(name)}: #{terms} = #{format_bound(data[:lb])}"
376
+ else
377
+ lines << " #{sanitize_name(name)}: #{terms} >= #{format_bound(data[:lb])}"
378
+ lines << " #{sanitize_name(name)}_ub: #{terms} <= #{format_bound(data[:ub])}"
379
+ end
380
+ end
381
+ end
382
+
383
+ # Bounds
384
+ if @var_bounds.any?
385
+ lines << 'Bounds'
386
+ @variables.each do |name, _var|
387
+ lb, ub = @var_bounds[name]
388
+ sname = sanitize_name(name)
389
+
390
+ if lb == ub
391
+ lines << " #{sname} = #{format_bound(lb)}"
392
+ elsif lb > -Float::INFINITY && ub < Float::INFINITY
393
+ lines << " #{lb} <= #{sname} <= #{format_bound(ub)}"
394
+ elsif lb > -Float::INFINITY
395
+ lines << " #{sname} >= #{format_bound(lb)}"
396
+ elsif ub < Float::INFINITY
397
+ lines << " #{sname} <= #{format_bound(ub)}"
398
+ end
399
+ end
400
+ end
401
+
402
+ # Integer variables
403
+ int_vars = @variables.select { |sym, _| @var_types[sym] == :integer }
404
+ if int_vars.any?
405
+ lines << 'Integers'
406
+ int_vars.each { |name, _| lines << " #{sanitize_name(name)}" }
407
+ end
408
+
409
+ lines << 'End'
410
+ lines.join("\n")
411
+ end
412
+
413
+ # Writes the model to an LP file.
414
+ #
415
+ # @param filename [String] The output file path.
416
+ # @return [void]
417
+ # @example
418
+ # model.write_lp('my_model.lp')
419
+ def write_lp(filename)
420
+ File.write(filename, to_lp)
421
+ end
422
+
423
+ private
424
+
425
+ # Looks up a variable name by its internal index.
426
+ #
427
+ # @param idx [Integer] The internal variable index.
428
+ # @return [String] The variable name, or "v#{idx}" if not found.
429
+ def find_var_name(idx)
430
+ @variables.find { |_, var| var.index == idx }&.first || "v#{idx}"
431
+ end
432
+
433
+ # Normalizes bound values for LP format output.
434
+ #
435
+ # Converts special infinity values to their canonical forms.
436
+ #
437
+ # @param val [Float] The bound value to normalize.
438
+ # @return [Float] The normalized bound value.
439
+ def normalize_bound(val)
440
+ return -Float::INFINITY if val == -Float::INFINITY || val == -1.0 / 0.0
441
+ return Float::INFINITY if val == Float::INFINITY || val == 1.0 / 0.0
442
+
443
+ val.to_f
444
+ end
445
+
446
+ # Formats a coefficient for LP output.
447
+ #
448
+ # Integers are output without decimal points for readability.
449
+ #
450
+ # @param value [Float] The coefficient value.
451
+ # @return [String] The formatted coefficient string.
452
+ def format_coeff(value)
453
+ if value == value.to_i && value.abs < 1e15
454
+ value.to_i.to_s
455
+ else
456
+ format('%.6g', value)
457
+ end
458
+ end
459
+
460
+ # Formats a bound value for LP output.
461
+ #
462
+ # @param value [Float] The bound value.
463
+ # @return [String] The formatted bound string (+Inf, -Inf, or numeric).
464
+ def format_bound(value)
465
+ return '+Inf' if value == Float::INFINITY
466
+ return '-Inf' if value == -Float::INFINITY
467
+
468
+ format_coeff(value)
469
+ end
470
+
471
+ # Sanitizes a name for LP format (no spaces, special characters).
472
+ #
473
+ # @param name [String, Symbol] The name to sanitize.
474
+ # @return [String] The sanitized name (max 32 characters).
475
+ def sanitize_name(name)
476
+ name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')[0, 32]
477
+ end
478
+
479
+ # Parses the HiGHS solution file.
480
+ #
481
+ # Extracts variable values, objective value, and model status from
482
+ # the solution file format produced by HiGHS.
483
+ #
484
+ # @param path [String] The path to the HiGHS solution file.
485
+ # @return [Solution] The parsed solution object.
486
+ def parse_solution_file(path)
487
+ content = File.read(path)
488
+ variables = {}
489
+ objective_value = 0.0
490
+ model_status = 'unknown'
491
+ iterations = 0
492
+
493
+ status_match = content.match(/Model status\s*\n\s*(\S+)/i)
494
+ model_status = status_match[1].downcase.gsub('_', ' ') if status_match
495
+
496
+ obj_match = content.match(/Objective\s+(\S+)/i)
497
+ objective_value = obj_match[1].to_f if obj_match
498
+
499
+ in_columns = false
500
+ content.each_line do |line|
501
+ if line =~ /# Columns/i
502
+ in_columns = true
503
+ next
504
+ end
505
+ next unless in_columns
506
+ break if line.strip.empty? || line.start_with?('#')
507
+
508
+ parts = line.strip.split(/\s+/, 2)
509
+ variables[parts[0]] = parts[1].to_f if parts.length == 2
510
+ end
511
+
512
+ Solution.new(
513
+ variables: variables,
514
+ objective_value: objective_value,
515
+ model_status: model_status,
516
+ iterations: iterations
517
+ )
518
+ end
519
+ end
520
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LpSolver
4
+ # Represents a quadratic expression: linear terms + quadratic terms.
5
+ #
6
+ # Quadratic expressions extend linear expressions by including second-order
7
+ # terms (products of two variables). They are used to model quadratic objectives
8
+ # in Quadratic Programming (QP) problems, such as portfolio variance,
9
+ # least-squares residuals, or convex penalty functions.
10
+ #
11
+ # A quadratic expression has the mathematical form:
12
+ # c₀ + Σᵢ cᵢ·xᵢ + ½ Σᵢⱼ Qᵢⱼ·xᵢ·xⱼ
13
+ #
14
+ # where c₀ is the constant, cᵢ are linear coefficients, and Q is the
15
+ # symmetric Hessian matrix of quadratic coefficients.
16
+ #
17
+ # @note The Hessian is stored in a normalized form where off-diagonal
18
+ # entries (i ≠ j) are stored once as [i, j, coefficient], and the
19
+ # LP format automatically applies the ½ factor.
20
+ #
21
+ # @example Building a quadratic expression
22
+ # x = model.add_variable(:x, lb: 0)
23
+ # y = model.add_variable(:y, lb: 0)
24
+ # quad = x * x + y * y + (x * y) * 2 # x² + 2xy + y² = (x+y)²
25
+ # quad.linear_terms # => {}
26
+ # quad.quadratic_terms # => [[0, 0, 1.0], [1, 1, 1.0], [0, 1, 2.0]]
27
+ #
28
+ # @example Using in a QP model
29
+ # model.minimize
30
+ # model.set_objective(x * x + y * y)
31
+ # solution = model.solve
32
+ class QuadraticExpression
33
+ # @return [Hash{Integer => Float}] Maps variable indices to linear coefficients.
34
+ # The keys are internal variable indices, and the values are the coefficients
35
+ # of the linear terms (e.g., 2x + 3y → {x_idx => 2.0, y_idx => 3.0}).
36
+ attr_reader :linear_terms
37
+
38
+ # @return [Array<[Integer, Integer, Float]>] Quadratic term pairs.
39
+ # Each element is [var1_index, var2_index, coefficient], representing
40
+ # coefficient * var1 * var2. For diagonal terms (var1 == var2), this
41
+ # represents coefficient * var1².
42
+ # @example
43
+ # (x * x).quadratic_terms # => [[0, 0, 1.0]]
44
+ # (x * y).quadratic_terms # => [[0, 1, 1.0]]
45
+ attr_reader :quadratic_terms
46
+
47
+ # Creates a new QuadraticExpression.
48
+ #
49
+ # @param linear_terms [Hash{Integer => Float}] Maps variable indices to linear coefficients.
50
+ # @param quadratic_terms [Array<[Integer, Integer, Float]>] Quadratic term pairs.
51
+ def initialize(linear_terms = {}, quadratic_terms = [])
52
+ @linear_terms = linear_terms
53
+ @quadratic_terms = quadratic_terms
54
+ end
55
+
56
+ # Adds another expression, variable, constant, or quadratic expression.
57
+ #
58
+ # @param other [QuadraticExpression, LinearExpression, Variable, Numeric] The operand to add.
59
+ # @return [QuadraticExpression] A new expression combining the operands.
60
+ # @example Adding two quadratic expressions
61
+ # (x * x) + (y * y) # => QuadraticExpression with [[0,0,1.0], [1,1,1.0]]
62
+ # @example Adding a linear expression
63
+ # (x * x) + (x * 2 + 1) # => QuadraticExpression with linear: {x => 2}, constant: 1
64
+ def +(other)
65
+ if other.is_a?(Variable)
66
+ new_linear = @linear_terms.dup
67
+ new_linear[other.index] = (new_linear[other.index] || 0) + 1
68
+ QuadraticExpression.new(new_linear, @quadratic_terms.dup)
69
+ elsif other.is_a?(LinearExpression)
70
+ new_linear = @linear_terms.dup
71
+ other.terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) + coeff }
72
+ QuadraticExpression.new(new_linear.reject { |_, v| v.zero? }, @quadratic_terms.dup)
73
+ elsif other.is_a?(QuadraticExpression)
74
+ new_linear = @linear_terms.dup
75
+ other.linear_terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) + coeff }
76
+ new_linear.reject! { |_, v| v.zero? }
77
+ QuadraticExpression.new(new_linear, @quadratic_terms + other.quadratic_terms)
78
+ else
79
+ new_linear = @linear_terms.dup
80
+ new_linear[0] = (new_linear[0] || 0) + other.to_f
81
+ QuadraticExpression.new(new_linear, @quadratic_terms.dup)
82
+ end
83
+ end
84
+
85
+ # Subtracts another expression, variable, constant, or quadratic expression.
86
+ #
87
+ # @param other [QuadraticExpression, LinearExpression, Variable, Numeric] The operand to subtract.
88
+ # @return [QuadraticExpression] A new expression representing the difference.
89
+ # @example
90
+ # (x * x + y * y) - (x * y) # => QuadraticExpression with [[0,0,1.0], [1,1,1.0], [0,1,-1.0]]
91
+ def -(other)
92
+ if other.is_a?(Variable)
93
+ new_linear = @linear_terms.dup
94
+ new_linear[other.index] = (new_linear[other.index] || 0) - 1
95
+ QuadraticExpression.new(new_linear.reject { |_, v| v.zero? }, @quadratic_terms.dup)
96
+ elsif other.is_a?(LinearExpression)
97
+ new_linear = @linear_terms.dup
98
+ other.terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) - coeff }
99
+ QuadraticExpression.new(new_linear.reject { |_, v| v.zero? }, @quadratic_terms.dup)
100
+ elsif other.is_a?(QuadraticExpression)
101
+ new_linear = @linear_terms.dup
102
+ other.linear_terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) - coeff }
103
+ new_linear.reject! { |_, v| v.zero? }
104
+ neg_quad = other.quadratic_terms.map { |i1, i2, c| [i1, i2, -c] }
105
+ QuadraticExpression.new(new_linear, @quadratic_terms + neg_quad)
106
+ else
107
+ new_linear = @linear_terms.dup
108
+ new_linear[0] = (new_linear[0] || 0) - other.to_f
109
+ QuadraticExpression.new(new_linear, @quadratic_terms.dup)
110
+ end
111
+ end
112
+
113
+ # Multiplies this expression by a scalar.
114
+ #
115
+ # Scales both linear coefficients and quadratic coefficients by the given factor.
116
+ #
117
+ # @param scalar [Numeric] The scalar multiplier.
118
+ # @return [QuadraticExpression] A new expression with all coefficients scaled.
119
+ # @example
120
+ # (x * x + y * y) * 2 # => QuadraticExpression with [[0,0,2.0], [1,1,2.0]]
121
+ # @param [Object] other
122
+ def *(other)
123
+ s = other.to_f
124
+ new_linear = @linear_terms.transform_values { |c| c * s }
125
+ new_linear.reject! { |_, v| v.zero? }
126
+ new_quad = @quadratic_terms.map { |i1, i2, c| [i1, i2, c * s] }
127
+ QuadraticExpression.new(new_linear, new_quad)
128
+ end
129
+
130
+ # Returns a QuadraticExpression with all coefficients negated.
131
+ #
132
+ # @return [QuadraticExpression] A new expression with negated terms.
133
+ # @example
134
+ # -(x * x + y * y) # => QuadraticExpression with [[0,0,-1.0], [1,1,-1.0]]
135
+ def -@
136
+ QuadraticExpression.new(
137
+ @linear_terms.transform_values { |c| -c }.reject { |_, v| v.zero? },
138
+ @quadratic_terms.map { |i1, i2, c| [i1, i2, -c] }
139
+ )
140
+ end
141
+
142
+ # Converts quadratic terms to HiGHS Hessian entries.
143
+ #
144
+ # Combines symmetric entries (e.g., x*y and y*x) and multiplies by 2
145
+ # to account for the ½ factor in the HiGHS LP format:
146
+ # [ 2·Qᵢⱼ·xᵢ·xⱼ ] / 2 = Qᵢⱼ·xᵢ·xⱼ
147
+ #
148
+ # @return [Array<[Integer, Integer, Float]>] Hessian entries as [var1_idx, var2_idx, coefficient].
149
+ # Each coefficient represents the value that will be divided by 2 in the LP format.
150
+ # @example
151
+ # (x * x).hessian_entries # => [[0, 0, 2.0]] → LP: [2x²]/2 = x²
152
+ # (x * y + y * x).hessian_entries # => [[0, 1, 4.0]] → LP: [4xy]/2 = 2xy
153
+ def hessian_entries
154
+ # Normalize: combine symmetric entries
155
+ pairs = {}
156
+ @quadratic_terms.each do |i1, i2, c|
157
+ key = [i1, i2].sort
158
+ pairs[key] = (pairs[key] || 0) + c
159
+ end
160
+ # Multiply by 2 to account for the "/ 2" in the LP format
161
+ pairs.map { |key, c| [key[0], key[1], c * 2.0] }
162
+ end
163
+ end
164
+ end