quicopt 0.1.0 → 0.4.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,236 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
3
+ # frozen_string_literal: true
4
+
5
+ # quicopt/indicators — terms that switch: whether a variable is in use
6
+ # (+Variable#active+), whether a comparison holds (+Quicopt.holds+), and the
7
+ # smaller or larger of several expressions (+Quicopt.min+, +Quicopt.max+).
8
+ #
9
+ # None of them is a polynomial, and each enters an +Expression+ the way a read of
10
+ # a permutation does (+quicopt/structured+): as one factor of degree 1, a key of
11
+ # the expression's linear terms. So +fee * x.active+ is linear, +q * x.active+
12
+ # quadratic, and the degree check stays exact.
13
+ #
14
+ # Factors are interned per model — asking twice for the same term returns the
15
+ # same object — so +x.active - x.active+ cancels and two equal terms meet on one
16
+ # coefficient. A comparison is mapped to its 0/1 form once, in +Quicopt.holds+,
17
+ # the same mapping as the R client's +holds()+:
18
+ #
19
+ # a <= b ⇒ step(b − a + tol) a >= b ⇒ step(a − b + tol)
20
+ # a < b ⇒ 1 − step(a − b − tol) a > b ⇒ 1 − step(b − a − tol)
21
+ # a == b ⇒ 1 − indicator(a − b) a != b ⇒ indicator(a − b)
22
+ #
23
+ # where +step(e)+ is 1 when +e ≥ 0+ and +indicator(e)+ is 1 when +e ≠ 0+; with a
24
+ # tolerance, +==+ and +!=+ compare +|a − b|+ against it instead.
25
+
26
+ module Quicopt
27
+ class << self
28
+ # 1 where the comparison holds and 0 where it does not, as an expression the
29
+ # service evaluates at the values it chooses. Its main use is a cost or a
30
+ # discount that applies once a total crosses a level: a warehouse's opening
31
+ # fee, due once it ships anything, is
32
+ #
33
+ # opening_fee * Quicopt.holds(shipped >= 1)
34
+ #
35
+ # and a discount of 30 from 60 units on is +-30 * Quicopt.holds(shipped >= 60)+.
36
+ #
37
+ # @param comparison [Constraint] built with +<=+, +>=+, +<+, +>+, +==+ or
38
+ # +!=+ between expressions, such as +shipped >= 60+
39
+ # @param tol [Numeric] how far a comparison may miss and still count as
40
+ # holding: +a >= b+ holds from +a = b − tol+ on, +a > b+ from
41
+ # <tt>a > b + tol</tt>. Non-negative.
42
+ # @return [Expression] a 0/1 expression; a number when the comparison
43
+ # involves no decision
44
+ # @raise [TypeError] if +comparison+ is not a comparison — often one Ruby
45
+ # already reduced to +true+ or +false+ because both sides were numbers
46
+ # @raise [ArgumentError] if +tol+ is negative, or the two sides belong to
47
+ # different models
48
+ def holds(comparison, tol: 0.0)
49
+ unless comparison.is_a?(Constraint)
50
+ raise TypeError, "holds takes a comparison such as `x >= 3`, got #{comparison.inspect}"
51
+ end
52
+ raise ArgumentError, "tol is a non-negative number, got #{tol.inspect}" unless tol.is_a?(Numeric) && tol >= 0
53
+
54
+ Conditions.holds(comparison.expr, comparison.sense, tol.to_f)
55
+ end
56
+
57
+ # The smallest of the given expressions, as an expression the service
58
+ # evaluates. <tt>fee * Quicopt.min(1, a.active + b.active)</tt> is a fee
59
+ # that is due once, however many of +a+ and +b+ are in use.
60
+ #
61
+ # @param args [Array<Expression, Variable, Numeric>] at least two
62
+ # @return [Expression] a number when every argument is one
63
+ # @raise [ArgumentError] on fewer than two arguments, or arguments from
64
+ # different models
65
+ def min(*args)
66
+ Extremum.build(:min, args)
67
+ end
68
+
69
+ # The largest of the given expressions, as an expression the service
70
+ # evaluates.
71
+ #
72
+ # @param args [Array<Expression, Variable, Numeric>] at least two
73
+ # @return [Expression] a number when every argument is one
74
+ # @raise [ArgumentError] on fewer than two arguments, or arguments from
75
+ # different models
76
+ def max(*args)
77
+ Extremum.build(:max, args)
78
+ end
79
+ end
80
+
81
+ # The mapping from a comparison to its 0/1 form, and the model a term belongs
82
+ # to.
83
+ #
84
+ # @api private
85
+ module Conditions
86
+ module_function
87
+
88
+ # +residual+ is +lhs − rhs+; the result follows the table in this file's
89
+ # header.
90
+ def holds(residual, sense, tol)
91
+ return Expression.new(constant: truth(residual.constant, sense, tol) ? 1.0 : 0.0) if residual.constant?
92
+
93
+ negated = residual.scale(-1.0)
94
+ case sense
95
+ when :le then term(:step, negated.plus(Expression.new(constant: tol)))
96
+ when :ge then term(:step, residual.plus(Expression.new(constant: tol)))
97
+ when :lt then one_minus(term(:step, residual.plus(Expression.new(constant: -tol))))
98
+ when :gt then one_minus(term(:step, negated.plus(Expression.new(constant: -tol))))
99
+ when :eq then tol.positive? ? term(:near, residual, tol) : one_minus(term(:indicator, residual))
100
+ when :ne then tol.positive? ? one_minus(term(:near, residual, tol)) : term(:indicator, residual)
101
+ end
102
+ end
103
+
104
+ # Whether a comparison between two numbers (<tt>value = lhs − rhs</tt>) holds.
105
+ def truth(value, sense, tol)
106
+ case sense
107
+ when :le then -value + tol >= 0
108
+ when :ge then value + tol >= 0
109
+ when :lt then (value - tol).negative?
110
+ when :gt then (-value - tol).negative?
111
+ when :eq then tol.positive? ? value.abs <= tol : value.zero?
112
+ when :ne then tol.positive? ? value.abs > tol : !value.zero?
113
+ end
114
+ end
115
+
116
+ # The interned +Condition+ +op+ over +argument+, as an expression.
117
+ def term(op, argument, tol = 0.0)
118
+ model = owner(argument.factors)
119
+ key = [:condition, op, argument_key(argument), tol]
120
+ Expression.new(linear: { model.intern(key) { Condition.new(model, op, argument, tol, model.next_order) } => 1.0 })
121
+ end
122
+
123
+ # +1 − e+.
124
+ def one_minus(expr)
125
+ Expression.new(constant: 1.0).plus(expr.scale(-1.0))
126
+ end
127
+
128
+ # The one model every factor in +factors+ belongs to.
129
+ #
130
+ # @raise [ArgumentError] if they belong to more than one
131
+ def owner(factors)
132
+ models = factors.map(&:model).uniq(&:object_id)
133
+ raise ArgumentError, "the terms of one expression belong to different Quicopt::Models" if models.size > 1
134
+
135
+ models.first
136
+ end
137
+
138
+ # An expression as an interning key: its constant and its terms in model
139
+ # order. Factors are interned, so they key by identity.
140
+ def argument_key(expr)
141
+ [expr.constant, expr.linear.sort_by { |f, _| f.order }, expr.quadratic.sort_by { |(a, b), _| [a.order, b.order] }]
142
+ end
143
+ end
144
+
145
+ # A 0/1 term: +indicator(e)+ (1 when +e ≠ 0+), +step(e)+ (1 when +e ≥ 0+) or
146
+ # the near test +step(tol − |e|)+. One factor of an +Expression+.
147
+ #
148
+ # @api private
149
+ class Condition
150
+ # @return [Model]
151
+ attr_reader :model
152
+ # @return [Symbol] +:indicator+, +:step+ or +:near+
153
+ attr_reader :op
154
+ # @return [Expression] the expression tested
155
+ attr_reader :argument
156
+ # @return [Float] the tolerance of +:near+, 0 otherwise
157
+ attr_reader :tol
158
+ # @return [Array] where this term sorts among a model's terms
159
+ attr_reader :order
160
+
161
+ def initialize(model, op, argument, tol, order)
162
+ @model = model
163
+ @op = op
164
+ @argument = argument
165
+ @tol = tol
166
+ @order = order
167
+ freeze
168
+ end
169
+
170
+ # @return [Array<Variable>] the variables the tested expression mentions
171
+ def variables
172
+ @argument.variables
173
+ end
174
+
175
+ # @return [String] the term as sent, e.g. <tt>step(x + y - 1)</tt>; the
176
+ # indicator of a bare variable reads +x.active+
177
+ def to_s
178
+ bare = @argument.constant.zero? && @argument.quadratic.empty? && @argument.linear.size == 1 &&
179
+ @argument.linear.first[0].is_a?(Variable) && @argument.linear.first[1] == 1.0
180
+ case @op
181
+ when :indicator then bare ? "#{@argument}.active" : "indicator(#{@argument})"
182
+ when :step then "step(#{@argument})"
183
+ when :near then "step(#{Format.number(@tol)} - |#{@argument}|)"
184
+ end
185
+ end
186
+ end
187
+
188
+ # The smaller or the larger of several expressions. One factor of an
189
+ # +Expression+.
190
+ #
191
+ # @api private
192
+ class Extremum
193
+ # @return [Model]
194
+ attr_reader :model
195
+ # @return [Symbol] +:min+ or +:max+
196
+ attr_reader :op
197
+ # @return [Array<Expression>] the arguments, in the order given
198
+ attr_reader :args
199
+ # @return [Array] where this term sorts among a model's terms
200
+ attr_reader :order
201
+
202
+ # The interned +op+ over +args+ as an expression, or the number itself when
203
+ # every argument is one.
204
+ def self.build(op, args)
205
+ raise ArgumentError, "#{op} takes at least two arguments, got #{args.size}" if args.size < 2
206
+
207
+ exprs = args.map { |a| Expression.cast(a) }
208
+ if exprs.all?(&:constant?)
209
+ values = exprs.map(&:constant)
210
+ return Expression.new(constant: op == :min ? values.min : values.max)
211
+ end
212
+ model = Conditions.owner(exprs.flat_map(&:factors))
213
+ key = [:extremum, op, exprs.map { |e| Conditions.argument_key(e) }]
214
+ Expression.new(linear: { model.intern(key) { new(model, op, exprs.freeze, model.next_order) } => 1.0 })
215
+ end
216
+
217
+ def initialize(model, op, args, order)
218
+ @model = model
219
+ @op = op
220
+ @args = args
221
+ @order = order
222
+ freeze
223
+ end
224
+
225
+ # @return [Array<Variable>] the variables the arguments mention
226
+ def variables
227
+ @args.flat_map(&:variables).uniq
228
+ end
229
+
230
+ # @return [String] the term as written, e.g.
231
+ # <tt>min(1, a.active + b.active)</tt>
232
+ def to_s
233
+ "#{@op}(#{@args.join(', ')})"
234
+ end
235
+ end
236
+ end