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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LpSolver
4
+ # Represents a linear expression: sum of (coefficient * variable) + constant.
5
+ #
6
+ # Linear expressions are the fundamental building blocks of linear programming
7
+ # models. They represent quantities like costs, resource usage, or returns
8
+ # that scale linearly with decision variables.
9
+ #
10
+ # A linear expression has the mathematical form:
11
+ # c₀ + c₁·x₁ + c₂·x₂ + ... + cₙ·xₙ
12
+ #
13
+ # where c₀ is the constant offset and cᵢ are the coefficients for each variable.
14
+ #
15
+ # @example Building a linear expression
16
+ # x = model.add_variable(:x, lb: 0)
17
+ # y = model.add_variable(:y, lb: 0)
18
+ # expr = x * 2 + y * 3 + 5 # 2x + 3y + 5
19
+ # expr.terms # => {0 => 2.0, 1 => 3.0}
20
+ # expr.constant # => 5.0
21
+ #
22
+ # @example Using in constraints
23
+ # model.add_constraint(:budget, (x * 2 + y * 3 + 5) <= 100)
24
+ #
25
+ # @example Using in objectives
26
+ # model.set_objective(x * 2 + y * 3 + 5)
27
+ class LinearExpression
28
+ # @return [Hash{Integer => Float}] Maps variable indices to their coefficients.
29
+ # The keys are the internal indices assigned by the model, and the values
30
+ # are the floating-point coefficients.
31
+ # @example
32
+ # (x * 2 + y * 3).terms # => {0 => 2.0, 1 => 3.0}
33
+ attr_reader :terms
34
+
35
+ # @return [Float] The constant offset in the expression.
36
+ # @example
37
+ # (x * 2 + 5).constant # => 5.0
38
+ attr_reader :constant
39
+
40
+ # Creates a new LinearExpression.
41
+ #
42
+ # @param terms [Hash{Integer => Float}] Maps variable indices to coefficients.
43
+ # @param constant [Float] The constant offset (default: 0.0).
44
+ def initialize(terms = {}, constant = 0.0)
45
+ @terms = terms
46
+ @constant = constant
47
+ end
48
+
49
+ # Adds another expression, variable, constant, or quadratic expression.
50
+ #
51
+ # When adding a LinearExpression or Variable, returns a new LinearExpression.
52
+ # When adding a QuadraticExpression, returns a new QuadraticExpression
53
+ # that combines the linear and quadratic parts.
54
+ #
55
+ # @param other [LinearExpression, QuadraticExpression, Variable, Numeric] The operand to add.
56
+ # @return [LinearExpression] When +other+ is a Numeric, Variable, or LinearExpression.
57
+ # @return [QuadraticExpression] When +other+ is a QuadraticExpression.
58
+ # @example Adding two linear expressions
59
+ # (x * 2 + 3) + (x + 5) # => 3x + 8
60
+ # @example Adding a quadratic expression
61
+ # (x * 2) + (y * y) # => QuadraticExpression with linear: {x => 2}, quadratic: [[y, y, 1]]
62
+ def +(other)
63
+ if other.is_a?(Variable)
64
+ LinearExpression.new(
65
+ merge_terms(@terms, { other.index => 1.0 }),
66
+ @constant
67
+ )
68
+ elsif other.is_a?(LinearExpression)
69
+ LinearExpression.new(
70
+ merge_terms(@terms, other.terms),
71
+ @constant + other.constant
72
+ )
73
+ elsif other.is_a?(QuadraticExpression)
74
+ new_linear = other.linear_terms.dup
75
+ @terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) + coeff }
76
+ QuadraticExpression.new(new_linear.reject { |_, v| v.zero? }, other.quadratic_terms.dup)
77
+ else
78
+ LinearExpression.new(@terms.dup, @constant + other.to_f)
79
+ end
80
+ end
81
+
82
+ # Subtracts another expression, variable, constant, or quadratic expression.
83
+ #
84
+ # @param other [LinearExpression, QuadraticExpression, Variable, Numeric] The operand to subtract.
85
+ # @return [LinearExpression] When +other+ is a Numeric, Variable, or LinearExpression.
86
+ # @return [QuadraticExpression] When +other+ is a QuadraticExpression.
87
+ # @example Subtracting a linear expression
88
+ # (x * 5 + 10) - (x * 2 + 3) # => 3x + 7
89
+ def -(other)
90
+ if other.is_a?(Variable)
91
+ LinearExpression.new(
92
+ merge_terms(@terms, { other.index => -1.0 }),
93
+ @constant
94
+ )
95
+ elsif other.is_a?(LinearExpression)
96
+ LinearExpression.new(
97
+ merge_terms(@terms, negate_terms(other.terms)),
98
+ @constant - other.constant
99
+ )
100
+ elsif other.is_a?(QuadraticExpression)
101
+ new_linear = other.linear_terms.dup
102
+ @terms.each { |idx, coeff| new_linear[idx] = (new_linear[idx] || 0) + coeff }
103
+ neg_quad = other.quadratic_terms.map { |i1, i2, c| [i1, i2, -c] }
104
+ QuadraticExpression.new(new_linear.reject { |_, v| v.zero? }, neg_quad)
105
+ else
106
+ LinearExpression.new(@terms.dup, @constant - other.to_f)
107
+ end
108
+ end
109
+
110
+ # Multiplies this expression by a scalar.
111
+ #
112
+ # Scales both the variable coefficients and the constant by the given factor.
113
+ #
114
+ # @param scalar [Numeric] The scalar multiplier.
115
+ # @return [LinearExpression] A new expression with all coefficients scaled.
116
+ # @example
117
+ # (x * 2 + 3) * 4 # => 8x + 12
118
+ # @param [Object] other
119
+ def *(other)
120
+ s = other.to_f
121
+ LinearExpression.new(
122
+ @terms.transform_values { |c| c * s },
123
+ @constant * s
124
+ )
125
+ end
126
+
127
+ # Returns a LinearExpression with all coefficients and the constant negated.
128
+ #
129
+ # @return [LinearExpression] A new expression with negated terms.
130
+ # @example
131
+ # -(x * 3 + 5) # => -3x - 5
132
+ def -@
133
+ LinearExpression.new(
134
+ @terms.transform_values { |c| -c },
135
+ -@constant
136
+ )
137
+ end
138
+
139
+ # Creates a less-than-or-equal-to constraint specification.
140
+ #
141
+ # @param value [Numeric] The right-hand side upper bound.
142
+ # @return [ConstraintSpec] A constraint specification with operator :le.
143
+ # @example
144
+ # model.add_constraint(:c, (x * 2 + y * 3 + 5) <= 100)
145
+ # @param [Object] other
146
+ def <=(other)
147
+ ConstraintSpec.new(:le, @terms.dup, @constant, other.to_f)
148
+ end
149
+
150
+ # Creates a greater-than-or-equal-to constraint specification.
151
+ #
152
+ # @param value [Numeric] The right-hand side lower bound.
153
+ # @return [ConstraintSpec] A constraint specification with operator :ge.
154
+ # @example
155
+ # model.add_constraint(:c, (x * 2 + y * 3 + 5) >= 50)
156
+ # @param [Object] other
157
+ def >=(other)
158
+ ConstraintSpec.new(:ge, @terms.dup, @constant, other.to_f)
159
+ end
160
+
161
+ # Creates an equality constraint specification.
162
+ #
163
+ # @param value [Numeric] The exact value the expression must equal.
164
+ # @return [ConstraintSpec] A constraint specification with operator :eq.
165
+ # @example
166
+ # model.add_constraint(:c, (x * 2 + y * 3 + 5) == 75)
167
+ # @param [Object] other
168
+ def ==(other)
169
+ ConstraintSpec.new(:eq, @terms.dup, @constant, other.to_f)
170
+ end
171
+
172
+ private
173
+
174
+ # Merges two term hashes, combining coefficients for duplicate indices.
175
+ #
176
+ # @param a [Hash{Integer => Float}] First term hash.
177
+ # @param b [Hash{Integer => Float}] Second term hash.
178
+ # @return [Hash{Integer => Float}] The merged term hash with zero coefficients removed.
179
+ def merge_terms(a, b)
180
+ merged = a.dup
181
+ b.each { |idx, coeff| merged[idx] = (merged[idx] || 0) + coeff }
182
+ merged.reject! { |_, v| v.zero? }
183
+ merged
184
+ end
185
+
186
+ # Negates all coefficients in a term hash.
187
+ #
188
+ # @param terms [Hash{Integer => Float}] The term hash to negate.
189
+ # @return [Hash{Integer => Float}] A new hash with negated coefficients.
190
+ def negate_terms(terms)
191
+ terms.transform_values { |c| -c }
192
+ end
193
+ end
194
+ end